@gachlab/devup 0.1.0 → 0.2.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 +82 -0
- package/README.md +87 -11
- package/dist/config/cli.d.ts +5 -0
- package/dist/config/cli.d.ts.map +1 -1
- package/dist/config/types.d.ts +13 -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 +693 -114
- package/dist/index.js.map +1 -1
- package/dist/lazy/proxy.d.ts.map +1 -1
- package/dist/orchestrator/dry-run.d.ts +15 -0
- package/dist/orchestrator/dry-run.d.ts.map +1 -0
- package/dist/orchestrator/once.d.ts +19 -0
- package/dist/orchestrator/once.d.ts.map +1 -0
- package/dist/process/health.d.ts +10 -1
- package/dist/process/health.d.ts.map +1 -1
- package/dist/process/installer.d.ts +0 -10
- package/dist/process/installer.d.ts.map +1 -1
- package/dist/process/log-sink.d.ts +23 -0
- package/dist/process/log-sink.d.ts.map +1 -0
- package/dist/process/manager.d.ts +5 -3
- package/dist/process/manager.d.ts.map +1 -1
- package/dist/proxy-config/caddy.d.ts +10 -0
- package/dist/proxy-config/caddy.d.ts.map +1 -0
- package/dist/proxy-config/detect.d.ts.map +1 -1
- package/dist/proxy-config/nginx.d.ts +10 -0
- package/dist/proxy-config/nginx.d.ts.map +1 -0
- package/dist/tui/App.d.ts +3 -1
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/LogsPanel.d.ts +3 -1
- package/dist/tui/LogsPanel.d.ts.map +1 -1
- package/dist/tui/StatsPanel.d.ts +3 -1
- package/dist/tui/StatsPanel.d.ts.map +1 -1
- package/dist/tui/hooks/useKeyBindings.d.ts +6 -0
- package/dist/tui/hooks/useKeyBindings.d.ts.map +1 -1
- package/dist/tui/hooks/useProcessManager.d.ts +6 -3
- package/dist/tui/hooks/useProcessManager.d.ts.map +1 -1
- package/dist/tui/hooks/useProxySync.d.ts.map +1 -1
- package/dist/utils.d.ts +0 -5
- package/dist/utils.d.ts.map +1 -1
- package/package.json +19 -8
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
4
|
+
import React7 from "react";
|
|
5
5
|
import { render } from "ink";
|
|
6
|
-
import { join as
|
|
7
|
-
import { homedir } from "os";
|
|
6
|
+
import { join as join5 } from "path";
|
|
7
|
+
import { homedir as homedir2 } from "os";
|
|
8
8
|
|
|
9
9
|
// src/config/loader.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -48,6 +48,30 @@ async function loadConfig(configPath) {
|
|
|
48
48
|
// src/config/validator.ts
|
|
49
49
|
import { existsSync as existsSync2 } from "fs";
|
|
50
50
|
import { resolve as resolve2 } from "path";
|
|
51
|
+
|
|
52
|
+
// src/lazy/classifier.ts
|
|
53
|
+
var LAZY_PORT_OFFSET = 1e4;
|
|
54
|
+
function classifyServices(services, config) {
|
|
55
|
+
const alwaysOnSet = new Set(config?.alwaysOn ?? []);
|
|
56
|
+
const alwaysOn = [];
|
|
57
|
+
const lazy = [];
|
|
58
|
+
for (const svc of services) {
|
|
59
|
+
if (alwaysOnSet.has(svc.name)) alwaysOn.push(svc);
|
|
60
|
+
else lazy.push(svc);
|
|
61
|
+
}
|
|
62
|
+
return { alwaysOn, lazy };
|
|
63
|
+
}
|
|
64
|
+
function getLazyRealPort(originalPort) {
|
|
65
|
+
return originalPort + LAZY_PORT_OFFSET;
|
|
66
|
+
}
|
|
67
|
+
function rewriteServicePort(svc) {
|
|
68
|
+
const realPort = getLazyRealPort(svc.port);
|
|
69
|
+
const args = svc.args.map((a) => a === String(svc.port) ? String(realPort) : a);
|
|
70
|
+
const extraEnv = { ...svc.extraEnv, PORT_OVERRIDE: String(realPort) };
|
|
71
|
+
return { ...svc, port: realPort, args, extraEnv, realPort, originalPort: svc.port };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/config/validator.ts
|
|
51
75
|
function validateConfig(config, cwd) {
|
|
52
76
|
const errors = [];
|
|
53
77
|
if (!config.name?.trim()) {
|
|
@@ -88,6 +112,15 @@ function validateConfig(config, cwd) {
|
|
|
88
112
|
if (svc.cwd && !existsSync2(resolve2(cwd, svc.cwd))) {
|
|
89
113
|
errors.push({ field: `services[${svc.name}].cwd`, message: `Directory not found: ${svc.cwd}` });
|
|
90
114
|
}
|
|
115
|
+
if (svc.healthCheck) {
|
|
116
|
+
const hc = svc.healthCheck;
|
|
117
|
+
if (hc.type !== "tcp" && hc.type !== "http") {
|
|
118
|
+
errors.push({ field: `services[${svc.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type} (must be "tcp" or "http")` });
|
|
119
|
+
}
|
|
120
|
+
if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
|
|
121
|
+
errors.push({ field: `services[${svc.name}].healthCheck.path`, message: `healthCheck.path must start with "/": got "${hc.path}"` });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
91
124
|
}
|
|
92
125
|
if (config.lazy?.alwaysOn) {
|
|
93
126
|
for (const ref of config.lazy.alwaysOn) {
|
|
@@ -96,6 +129,22 @@ function validateConfig(config, cwd) {
|
|
|
96
129
|
}
|
|
97
130
|
}
|
|
98
131
|
}
|
|
132
|
+
if (config.lazy) {
|
|
133
|
+
const alwaysOn = new Set(config.lazy.alwaysOn ?? []);
|
|
134
|
+
const portToSvc = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const svc of config.services) portToSvc.set(svc.port, svc.name);
|
|
136
|
+
for (const svc of config.services) {
|
|
137
|
+
if (alwaysOn.has(svc.name)) continue;
|
|
138
|
+
const realPort = svc.port + LAZY_PORT_OFFSET;
|
|
139
|
+
const conflict = portToSvc.get(realPort);
|
|
140
|
+
if (conflict && conflict !== svc.name) {
|
|
141
|
+
errors.push({
|
|
142
|
+
field: `services[${svc.name}].port`,
|
|
143
|
+
message: `Lazy real port ${realPort} (= ${svc.port}+${LAZY_PORT_OFFSET}) collides with service ${conflict}`
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
99
148
|
if (config.proxy?.routes) {
|
|
100
149
|
for (const ref of Object.keys(config.proxy.routes)) {
|
|
101
150
|
if (!names.has(ref)) {
|
|
@@ -111,6 +160,7 @@ function formatValidationErrors(errors) {
|
|
|
111
160
|
|
|
112
161
|
// src/config/cli.ts
|
|
113
162
|
var DEFAULT_LAZY_TIMEOUT = 10;
|
|
163
|
+
var DEFAULT_ONCE_TIMEOUT = 90;
|
|
114
164
|
function parseCliArgs(argv) {
|
|
115
165
|
const args = {
|
|
116
166
|
skip: [],
|
|
@@ -118,7 +168,11 @@ function parseCliArgs(argv) {
|
|
|
118
168
|
lazyTimeout: DEFAULT_LAZY_TIMEOUT,
|
|
119
169
|
proxy: false,
|
|
120
170
|
proxyTls: true,
|
|
121
|
-
proxyEntrypoint: "websecure"
|
|
171
|
+
proxyEntrypoint: "websecure",
|
|
172
|
+
dryRun: false,
|
|
173
|
+
once: false,
|
|
174
|
+
onceTimeout: DEFAULT_ONCE_TIMEOUT,
|
|
175
|
+
logFile: true
|
|
122
176
|
};
|
|
123
177
|
for (let i = 0; i < argv.length; i++) {
|
|
124
178
|
const arg = argv[i];
|
|
@@ -171,6 +225,23 @@ function parseCliArgs(argv) {
|
|
|
171
225
|
args.proxyEntrypoint = next ?? "websecure";
|
|
172
226
|
i++;
|
|
173
227
|
break;
|
|
228
|
+
case "--dry-run":
|
|
229
|
+
args.dryRun = true;
|
|
230
|
+
break;
|
|
231
|
+
case "--once":
|
|
232
|
+
args.once = true;
|
|
233
|
+
break;
|
|
234
|
+
case "--once-timeout":
|
|
235
|
+
args.onceTimeout = parseInt(next ?? "", 10) || DEFAULT_ONCE_TIMEOUT;
|
|
236
|
+
i++;
|
|
237
|
+
break;
|
|
238
|
+
case "--no-log-file":
|
|
239
|
+
args.logFile = false;
|
|
240
|
+
break;
|
|
241
|
+
case "--log-dir":
|
|
242
|
+
args.logDir = next;
|
|
243
|
+
i++;
|
|
244
|
+
break;
|
|
174
245
|
}
|
|
175
246
|
}
|
|
176
247
|
return args;
|
|
@@ -268,9 +339,93 @@ ${svcs.join("\n")}
|
|
|
268
339
|
}
|
|
269
340
|
};
|
|
270
341
|
|
|
342
|
+
// src/proxy-config/nginx.ts
|
|
343
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
344
|
+
import { dirname as dirname2 } from "path";
|
|
345
|
+
var EMPTY_CONFIG2 = "# devup: no healthy services\n";
|
|
346
|
+
var NginxProvider = class {
|
|
347
|
+
name = "nginx";
|
|
348
|
+
generate(services, opts) {
|
|
349
|
+
const blocks = [];
|
|
350
|
+
for (const [name, st] of services) {
|
|
351
|
+
if (st.health !== "up") continue;
|
|
352
|
+
const sub = opts.routes[name];
|
|
353
|
+
if (sub === void 0) continue;
|
|
354
|
+
const serverName = sub ? `${sub}.${opts.domain}` : opts.domain;
|
|
355
|
+
const port = st.realPort ?? st.port;
|
|
356
|
+
const listen = opts.tls ? "443 ssl" : "80";
|
|
357
|
+
const tlsBlock = opts.tls ? ` ssl_certificate /etc/nginx/certs/${serverName}.crt;
|
|
358
|
+
ssl_certificate_key /etc/nginx/certs/${serverName}.key;
|
|
359
|
+
` : "";
|
|
360
|
+
blocks.push(
|
|
361
|
+
`server {
|
|
362
|
+
listen ${listen};
|
|
363
|
+
server_name ${serverName};
|
|
364
|
+
` + tlsBlock + ` location / {
|
|
365
|
+
proxy_pass http://${opts.host}:${port};
|
|
366
|
+
proxy_set_header Host $host;
|
|
367
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
368
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
369
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
370
|
+
proxy_http_version 1.1;
|
|
371
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
372
|
+
proxy_set_header Connection "upgrade";
|
|
373
|
+
}
|
|
374
|
+
}`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (!blocks.length) return EMPTY_CONFIG2;
|
|
378
|
+
return blocks.join("\n\n") + "\n";
|
|
379
|
+
}
|
|
380
|
+
write(content, opts) {
|
|
381
|
+
const dir = dirname2(opts.confPath);
|
|
382
|
+
if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
|
|
383
|
+
writeFileSync2(opts.confPath, content);
|
|
384
|
+
}
|
|
385
|
+
clear(opts) {
|
|
386
|
+
this.write(EMPTY_CONFIG2, opts);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// src/proxy-config/caddy.ts
|
|
391
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
392
|
+
import { dirname as dirname3 } from "path";
|
|
393
|
+
var EMPTY_CONFIG3 = "# devup: no healthy services\n";
|
|
394
|
+
var CaddyProvider = class {
|
|
395
|
+
name = "caddy";
|
|
396
|
+
generate(services, opts) {
|
|
397
|
+
const blocks = [];
|
|
398
|
+
for (const [name, st] of services) {
|
|
399
|
+
if (st.health !== "up") continue;
|
|
400
|
+
const sub = opts.routes[name];
|
|
401
|
+
if (sub === void 0) continue;
|
|
402
|
+
const host = sub ? `${sub}.${opts.domain}` : opts.domain;
|
|
403
|
+
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
|
+
|
|
271
424
|
// src/proxy-config/detect.ts
|
|
272
425
|
var providers = {
|
|
273
|
-
traefik: () => new TraefikProvider()
|
|
426
|
+
traefik: () => new TraefikProvider(),
|
|
427
|
+
nginx: () => new NginxProvider(),
|
|
428
|
+
caddy: () => new CaddyProvider()
|
|
274
429
|
};
|
|
275
430
|
function detectProxyProvider(name) {
|
|
276
431
|
const factory = providers[name];
|
|
@@ -282,12 +437,12 @@ function detectProxyProvider(name) {
|
|
|
282
437
|
}
|
|
283
438
|
|
|
284
439
|
// src/utils.ts
|
|
285
|
-
import { existsSync as
|
|
440
|
+
import { existsSync as existsSync6, readFileSync, writeFileSync as writeFileSync4 } from "fs";
|
|
286
441
|
import { createHash } from "crypto";
|
|
287
442
|
import { join as join2 } from "path";
|
|
288
443
|
function parseEnvFile(filePath, baseEnv = {}) {
|
|
289
444
|
const env = { ...baseEnv };
|
|
290
|
-
if (!
|
|
445
|
+
if (!existsSync6(filePath)) return env;
|
|
291
446
|
for (const line of readFileSync(filePath, "utf8").split("\n")) {
|
|
292
447
|
const trimmed = line.trim();
|
|
293
448
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -309,15 +464,17 @@ function fmtUptime(ms) {
|
|
|
309
464
|
const m = Math.floor(s / 60);
|
|
310
465
|
if (m < 60) return `${m}m${s % 60}s`;
|
|
311
466
|
const h = Math.floor(m / 60);
|
|
312
|
-
return `${h}h${m % 60}m`;
|
|
467
|
+
if (h < 24) return `${h}h${m % 60}m`;
|
|
468
|
+
const d = Math.floor(h / 24);
|
|
469
|
+
return `${d}d${h % 24}h`;
|
|
313
470
|
}
|
|
314
471
|
function needsInstall(fullCwd) {
|
|
315
472
|
const nm = join2(fullCwd, "node_modules");
|
|
316
|
-
if (!
|
|
473
|
+
if (!existsSync6(nm)) return true;
|
|
317
474
|
try {
|
|
318
475
|
const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
|
|
319
476
|
const stampFile = join2(nm, ".install-stamp");
|
|
320
|
-
if (
|
|
477
|
+
if (existsSync6(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
|
|
321
478
|
} catch {
|
|
322
479
|
}
|
|
323
480
|
return true;
|
|
@@ -325,7 +482,7 @@ function needsInstall(fullCwd) {
|
|
|
325
482
|
function writeInstallStamp(fullCwd) {
|
|
326
483
|
try {
|
|
327
484
|
const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
|
|
328
|
-
|
|
485
|
+
writeFileSync4(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
|
|
329
486
|
} catch {
|
|
330
487
|
}
|
|
331
488
|
}
|
|
@@ -384,7 +541,7 @@ var tagColors = [
|
|
|
384
541
|
];
|
|
385
542
|
|
|
386
543
|
// src/tui/App.tsx
|
|
387
|
-
import { useEffect as
|
|
544
|
+
import { useEffect as useEffect5, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
|
|
388
545
|
import { Box as Box6, Text as Text6, useStdout } from "ink";
|
|
389
546
|
|
|
390
547
|
// src/tui/hooks/useProcessManager.ts
|
|
@@ -396,10 +553,11 @@ import { join as join3 } from "path";
|
|
|
396
553
|
|
|
397
554
|
// src/process/health.ts
|
|
398
555
|
import net from "net";
|
|
399
|
-
|
|
556
|
+
import http from "http";
|
|
557
|
+
function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
|
|
400
558
|
return new Promise((resolve3) => {
|
|
401
559
|
const socket = new net.Socket();
|
|
402
|
-
socket.setTimeout(
|
|
560
|
+
socket.setTimeout(timeoutMs);
|
|
403
561
|
socket.once("connect", () => {
|
|
404
562
|
socket.destroy();
|
|
405
563
|
resolve3(true);
|
|
@@ -415,6 +573,39 @@ function checkPort(port, host = "127.0.0.1") {
|
|
|
415
573
|
socket.connect(port, host);
|
|
416
574
|
});
|
|
417
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
|
+
}
|
|
418
609
|
function waitForPort(port, opts = {}) {
|
|
419
610
|
const { timeout = 45e3, interval = 1e3 } = opts;
|
|
420
611
|
return new Promise((resolve3) => {
|
|
@@ -437,9 +628,9 @@ function deriveHealth(isUp, currentStatus) {
|
|
|
437
628
|
|
|
438
629
|
// src/process/installer.ts
|
|
439
630
|
import { spawn } from "child_process";
|
|
440
|
-
import { existsSync as
|
|
631
|
+
import { existsSync as existsSync7 } from "fs";
|
|
441
632
|
function installService(cwd, env, onLog) {
|
|
442
|
-
if (!
|
|
633
|
+
if (!existsSync7(cwd)) {
|
|
443
634
|
onLog?.(`\u26A0 directory not found: ${cwd}`);
|
|
444
635
|
return Promise.resolve(false);
|
|
445
636
|
}
|
|
@@ -449,7 +640,8 @@ function installService(cwd, env, onLog) {
|
|
|
449
640
|
}
|
|
450
641
|
onLog?.("\u{1F4E6} npm install...");
|
|
451
642
|
return new Promise((resolve3) => {
|
|
452
|
-
const
|
|
643
|
+
const command = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
644
|
+
const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
|
|
453
645
|
let stderr = "";
|
|
454
646
|
proc.stderr?.on("data", (d) => {
|
|
455
647
|
stderr += d.toString();
|
|
@@ -464,12 +656,36 @@ function installService(cwd, env, onLog) {
|
|
|
464
656
|
resolve3(true);
|
|
465
657
|
}
|
|
466
658
|
});
|
|
659
|
+
proc.on("error", (err) => {
|
|
660
|
+
onLog?.(`\u26A0 spawn error: ${err.message}`);
|
|
661
|
+
resolve3(false);
|
|
662
|
+
});
|
|
467
663
|
});
|
|
468
664
|
}
|
|
469
665
|
|
|
470
666
|
// src/process/manager.ts
|
|
471
667
|
var MAX_RESTARTS = 3;
|
|
472
668
|
var BACKOFF_BASE_MS = 2e3;
|
|
669
|
+
function lineBuffer(onLine) {
|
|
670
|
+
let buf = "";
|
|
671
|
+
return {
|
|
672
|
+
push(chunk) {
|
|
673
|
+
buf += chunk.toString();
|
|
674
|
+
let idx;
|
|
675
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
676
|
+
const line = buf.slice(0, idx).replace(/\r$/, "");
|
|
677
|
+
buf = buf.slice(idx + 1);
|
|
678
|
+
if (line.length) onLine(line);
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
flush() {
|
|
682
|
+
if (buf.length) {
|
|
683
|
+
onLine(buf);
|
|
684
|
+
buf = "";
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}
|
|
473
689
|
var ProcessManager = class {
|
|
474
690
|
state = /* @__PURE__ */ new Map();
|
|
475
691
|
procs = /* @__PURE__ */ new Set();
|
|
@@ -483,9 +699,10 @@ var ProcessManager = class {
|
|
|
483
699
|
this.platform = opts.platform;
|
|
484
700
|
this.events = opts.events;
|
|
485
701
|
}
|
|
486
|
-
async install(svc) {
|
|
702
|
+
async install(svc, colorIdx) {
|
|
487
703
|
const cwd = join3(this.baseCwd, svc.cwd);
|
|
488
|
-
|
|
704
|
+
const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
|
|
705
|
+
return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
|
|
489
706
|
}
|
|
490
707
|
async start(svc, colorIdx, isRestart = false) {
|
|
491
708
|
const cwd = join3(this.baseCwd, svc.cwd);
|
|
@@ -515,11 +732,15 @@ var ProcessManager = class {
|
|
|
515
732
|
this.state.set(svc.name, state);
|
|
516
733
|
this.procs.add(proc);
|
|
517
734
|
this.events.onStateChange(svc.name, state);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
state.errors +=
|
|
521
|
-
this.log(svc.name,
|
|
735
|
+
const stdoutBuf = lineBuffer((line) => this.log(svc.name, line, colorIdx));
|
|
736
|
+
const stderrBuf = lineBuffer((line) => {
|
|
737
|
+
state.errors += 1;
|
|
738
|
+
this.log(svc.name, line, colorIdx);
|
|
522
739
|
});
|
|
740
|
+
proc.stdout?.on("data", (d) => stdoutBuf.push(d));
|
|
741
|
+
proc.stderr?.on("data", (d) => stderrBuf.push(d));
|
|
742
|
+
proc.stdout?.on("end", () => stdoutBuf.flush());
|
|
743
|
+
proc.stderr?.on("end", () => stderrBuf.flush());
|
|
523
744
|
proc.on("close", (code) => {
|
|
524
745
|
this.procs.delete(proc);
|
|
525
746
|
if (state.intentionalStop) {
|
|
@@ -557,7 +778,7 @@ var ProcessManager = class {
|
|
|
557
778
|
const st = this.state.get(name);
|
|
558
779
|
if (!st) return;
|
|
559
780
|
this.stop(name);
|
|
560
|
-
st.restarts
|
|
781
|
+
st.restarts = 0;
|
|
561
782
|
const delay = st.proc ? 1500 : 100;
|
|
562
783
|
await new Promise((r) => setTimeout(r, delay));
|
|
563
784
|
await this.start(st.svc, st.colorIdx, true);
|
|
@@ -569,39 +790,65 @@ var ProcessManager = class {
|
|
|
569
790
|
st.health = st.status === "idle" ? "idle" : "down";
|
|
570
791
|
continue;
|
|
571
792
|
}
|
|
572
|
-
const
|
|
573
|
-
const isUp = await checkPort(port);
|
|
793
|
+
const isUp = await checkHealth(st.svc.port, st.svc.healthCheck);
|
|
574
794
|
const prev = st.health;
|
|
575
795
|
st.health = deriveHealth(isUp, st.status);
|
|
576
796
|
if (st.health === "up" && st.status === "starting") st.status = "running";
|
|
577
797
|
if (prev !== st.health) this.events.onStateChange(name, st);
|
|
578
798
|
}
|
|
579
799
|
}
|
|
580
|
-
cleanup() {
|
|
581
|
-
|
|
800
|
+
async cleanup(opts = {}) {
|
|
801
|
+
const grace = opts.gracePeriodMs ?? 3e3;
|
|
802
|
+
const procs = [...this.procs];
|
|
803
|
+
if (!procs.length) return;
|
|
804
|
+
for (const proc of procs) {
|
|
805
|
+
const st = this.findStateByProc(proc);
|
|
806
|
+
if (st) st.intentionalStop = true;
|
|
582
807
|
if (proc.pid) this.platform.killTree(proc.pid);
|
|
583
808
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
809
|
+
const waits = procs.map(
|
|
810
|
+
(p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve3) => p.once("close", () => resolve3()))
|
|
811
|
+
);
|
|
812
|
+
let timedOut = false;
|
|
813
|
+
await Promise.race([
|
|
814
|
+
Promise.all(waits),
|
|
815
|
+
new Promise((resolve3) => setTimeout(() => {
|
|
816
|
+
timedOut = true;
|
|
817
|
+
resolve3();
|
|
818
|
+
}, grace))
|
|
819
|
+
]);
|
|
820
|
+
if (timedOut) {
|
|
821
|
+
for (const proc of procs) {
|
|
822
|
+
if (proc.pid && proc.exitCode === null && proc.signalCode === null) {
|
|
823
|
+
this.platform.killTree(proc.pid, "SIGKILL");
|
|
824
|
+
}
|
|
587
825
|
}
|
|
588
|
-
|
|
826
|
+
await Promise.race([
|
|
827
|
+
Promise.all(waits),
|
|
828
|
+
new Promise((resolve3) => setTimeout(resolve3, 1e3))
|
|
829
|
+
]);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
findStateByProc(proc) {
|
|
833
|
+
for (const st of this.state.values()) if (st.proc === proc) return st;
|
|
834
|
+
return void 0;
|
|
589
835
|
}
|
|
590
836
|
log(name, text, colorIdx) {
|
|
591
837
|
this.events.onLog(name, text, colorIdx);
|
|
592
838
|
}
|
|
593
|
-
getColorIdx(name) {
|
|
594
|
-
return this.state.get(name)?.colorIdx ?? 0;
|
|
595
|
-
}
|
|
596
839
|
};
|
|
597
840
|
|
|
598
841
|
// src/tui/hooks/useProcessManager.ts
|
|
599
|
-
function useProcessManager(platform, baseCwd, env) {
|
|
842
|
+
function useProcessManager(platform, baseCwd, env, logSink = null) {
|
|
600
843
|
const [states, setStates] = useState(/* @__PURE__ */ new Map());
|
|
601
844
|
const [logs, setLogs] = useState([]);
|
|
602
845
|
const [stats, setStats] = useState(/* @__PURE__ */ new Map());
|
|
603
846
|
const mgrRef = useRef(null);
|
|
604
847
|
const prevCpu = useRef(/* @__PURE__ */ new Map());
|
|
848
|
+
const pausedRef = useRef(false);
|
|
849
|
+
const pendingLogsRef = useRef([]);
|
|
850
|
+
const sinkRef = useRef(logSink);
|
|
851
|
+
sinkRef.current = logSink;
|
|
605
852
|
useEffect(() => {
|
|
606
853
|
const mgr2 = new ProcessManager({
|
|
607
854
|
baseCwd,
|
|
@@ -609,9 +856,17 @@ function useProcessManager(platform, baseCwd, env) {
|
|
|
609
856
|
platform,
|
|
610
857
|
events: {
|
|
611
858
|
onLog: (svcName, text, colorIdx) => {
|
|
612
|
-
|
|
859
|
+
sinkRef.current?.write(svcName, text);
|
|
860
|
+
const entry = { svcName, text, colorIdx, ts: Date.now() };
|
|
861
|
+
if (pausedRef.current) {
|
|
862
|
+
pendingLogsRef.current.push(entry);
|
|
863
|
+
if (pendingLogsRef.current.length > 5e3) {
|
|
864
|
+
pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
|
|
865
|
+
}
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
613
868
|
setLogs((prev) => {
|
|
614
|
-
const next =
|
|
869
|
+
const next = prev.concat(entry);
|
|
615
870
|
return next.length > 5e3 ? next.slice(-5e3) : next;
|
|
616
871
|
});
|
|
617
872
|
},
|
|
@@ -654,6 +909,21 @@ function useProcessManager(platform, baseCwd, env) {
|
|
|
654
909
|
return () => clearInterval(id);
|
|
655
910
|
}, [platform]);
|
|
656
911
|
const mgr = mgrRef.current;
|
|
912
|
+
const clearLogs = useCallback(() => {
|
|
913
|
+
pendingLogsRef.current = [];
|
|
914
|
+
setLogs([]);
|
|
915
|
+
}, []);
|
|
916
|
+
const setPaused = useCallback((paused) => {
|
|
917
|
+
pausedRef.current = paused;
|
|
918
|
+
if (!paused && pendingLogsRef.current.length) {
|
|
919
|
+
const flush = pendingLogsRef.current;
|
|
920
|
+
pendingLogsRef.current = [];
|
|
921
|
+
setLogs((prev) => {
|
|
922
|
+
const next = prev.concat(flush);
|
|
923
|
+
return next.length > 5e3 ? next.slice(-5e3) : next;
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}, []);
|
|
657
927
|
return {
|
|
658
928
|
states,
|
|
659
929
|
logs,
|
|
@@ -661,8 +931,10 @@ function useProcessManager(platform, baseCwd, env) {
|
|
|
661
931
|
start: useCallback((svc, colorIdx) => mgr?.start(svc, colorIdx), [mgr]),
|
|
662
932
|
stop: useCallback((name) => mgr?.stop(name), [mgr]),
|
|
663
933
|
restart: useCallback((name) => mgr?.restart(name), [mgr]),
|
|
664
|
-
install: useCallback((svc) => mgr?.install(svc), [mgr]),
|
|
934
|
+
install: useCallback((svc, colorIdx) => mgr?.install(svc, colorIdx), [mgr]),
|
|
665
935
|
cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
|
|
936
|
+
clearLogs,
|
|
937
|
+
setPaused,
|
|
666
938
|
manager: mgr
|
|
667
939
|
};
|
|
668
940
|
}
|
|
@@ -671,6 +943,24 @@ function useProcessManager(platform, baseCwd, env) {
|
|
|
671
943
|
import { useInput } from "ink";
|
|
672
944
|
import { useState as useState2, useCallback as useCallback2 } from "react";
|
|
673
945
|
var SORT_MODES = ["name", "mem", "errors"];
|
|
946
|
+
function scrollBy(setState, delta) {
|
|
947
|
+
setState((s) => {
|
|
948
|
+
if (s.panel === "logs") {
|
|
949
|
+
const next2 = s.logsScrollOffset - delta;
|
|
950
|
+
return { ...s, logsScrollOffset: Math.max(0, next2) };
|
|
951
|
+
}
|
|
952
|
+
const next = s.statsScrollOffset + delta;
|
|
953
|
+
return { ...s, statsScrollOffset: Math.max(0, next) };
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
function scrollTo(setState, target) {
|
|
957
|
+
setState((s) => {
|
|
958
|
+
if (s.panel === "logs") {
|
|
959
|
+
return { ...s, logsScrollOffset: target === "top" ? Number.MAX_SAFE_INTEGER : 0 };
|
|
960
|
+
}
|
|
961
|
+
return { ...s, statsScrollOffset: target === "top" ? 0 : Number.MAX_SAFE_INTEGER };
|
|
962
|
+
});
|
|
963
|
+
}
|
|
674
964
|
function useKeyBindings(opts) {
|
|
675
965
|
const [state, setState] = useState2({
|
|
676
966
|
panel: "logs",
|
|
@@ -680,7 +970,9 @@ function useKeyBindings(opts) {
|
|
|
680
970
|
logsPaused: false,
|
|
681
971
|
showTimestamps: false,
|
|
682
972
|
sortIdx: 0,
|
|
683
|
-
proxyEnabled: false
|
|
973
|
+
proxyEnabled: false,
|
|
974
|
+
logsScrollOffset: 0,
|
|
975
|
+
statsScrollOffset: 0
|
|
684
976
|
});
|
|
685
977
|
const setModal = useCallback2((modal) => setState((s) => ({ ...s, modal })), []);
|
|
686
978
|
const setFilter = useCallback2((f) => setState((s) => ({ ...s, logFilter: f, modal: "none" })), []);
|
|
@@ -689,6 +981,14 @@ function useKeyBindings(opts) {
|
|
|
689
981
|
useInput((input, key) => {
|
|
690
982
|
if (state.modal !== "none") return;
|
|
691
983
|
if (input === "q" || key.ctrl && input === "c") opts.onQuit();
|
|
984
|
+
else if (key.ctrl && input === "a") scrollTo(setState, "top");
|
|
985
|
+
else if (key.ctrl && input === "e") scrollTo(setState, "bottom");
|
|
986
|
+
else if (key.ctrl && input === "b") scrollBy(setState, -10);
|
|
987
|
+
else if (key.ctrl && input === "f") scrollBy(setState, 10);
|
|
988
|
+
else if (key.upArrow) scrollBy(setState, -1);
|
|
989
|
+
else if (key.downArrow) scrollBy(setState, 1);
|
|
990
|
+
else if (input === "[") scrollBy(setState, -10);
|
|
991
|
+
else if (input === "]") scrollBy(setState, 10);
|
|
692
992
|
else if (input === "c") opts.onClearLogs();
|
|
693
993
|
else if (key.tab) setState((s) => ({ ...s, panel: s.panel === "logs" ? "stats" : "logs" }));
|
|
694
994
|
else if (input === "f") setModal("filter");
|
|
@@ -704,47 +1004,70 @@ function useKeyBindings(opts) {
|
|
|
704
1004
|
setState((s) => ({ ...s, proxyEnabled: !s.proxyEnabled }));
|
|
705
1005
|
}
|
|
706
1006
|
}, { isActive });
|
|
707
|
-
return {
|
|
1007
|
+
return {
|
|
1008
|
+
...state,
|
|
1009
|
+
setModal,
|
|
1010
|
+
setFilter,
|
|
1011
|
+
setSearch,
|
|
1012
|
+
sortMode: SORT_MODES[state.sortIdx],
|
|
1013
|
+
// Funciones para resetear el scroll cuando cambia el contenido
|
|
1014
|
+
resetLogsScroll: useCallback2(() => setState((s) => ({ ...s, logsScrollOffset: 0 })), []),
|
|
1015
|
+
resetStatsScroll: useCallback2(() => setState((s) => ({ ...s, statsScrollOffset: 0 })), [])
|
|
1016
|
+
};
|
|
708
1017
|
}
|
|
709
1018
|
|
|
710
1019
|
// src/tui/hooks/useProxySync.ts
|
|
711
1020
|
import { useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
712
1021
|
function useProxySync(provider, opts, states, enabled) {
|
|
713
|
-
const
|
|
1022
|
+
const statesRef = useRef2(states);
|
|
1023
|
+
const lastContentRef = useRef2(null);
|
|
1024
|
+
statesRef.current = states;
|
|
714
1025
|
useEffect2(() => {
|
|
715
|
-
if (!provider || !opts || !enabled)
|
|
716
|
-
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
1026
|
+
if (!provider || !opts || !enabled) return;
|
|
719
1027
|
const sync = () => {
|
|
720
1028
|
const svcStates = /* @__PURE__ */ new Map();
|
|
721
|
-
for (const [name, st] of
|
|
1029
|
+
for (const [name, st] of statesRef.current) {
|
|
722
1030
|
svcStates.set(name, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
|
|
723
1031
|
}
|
|
724
1032
|
const content = provider.generate(svcStates, opts);
|
|
1033
|
+
if (content === lastContentRef.current) return;
|
|
1034
|
+
lastContentRef.current = content;
|
|
725
1035
|
provider.write(content, opts);
|
|
726
1036
|
};
|
|
727
1037
|
sync();
|
|
728
|
-
|
|
1038
|
+
const id = setInterval(sync, 3e3);
|
|
729
1039
|
return () => {
|
|
730
|
-
|
|
1040
|
+
clearInterval(id);
|
|
1041
|
+
lastContentRef.current = null;
|
|
731
1042
|
};
|
|
732
|
-
}, [provider, opts, enabled
|
|
1043
|
+
}, [provider, opts, enabled]);
|
|
733
1044
|
}
|
|
734
1045
|
|
|
735
1046
|
// src/tui/LogsPanel.tsx
|
|
1047
|
+
import { useEffect as useEffect3 } from "react";
|
|
736
1048
|
import { Box, Text } from "ink";
|
|
737
1049
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
738
|
-
function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused }) {
|
|
1050
|
+
function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll }) {
|
|
739
1051
|
const filtered = filter ? logs.filter((l) => l.svcName === filter) : logs;
|
|
740
|
-
const contentHeight = height - 2;
|
|
741
|
-
const
|
|
1052
|
+
const contentHeight = Math.max(1, height - 2);
|
|
1053
|
+
const totalLines = filtered.length;
|
|
1054
|
+
const maxOffset = Math.max(0, totalLines - contentHeight);
|
|
1055
|
+
const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxOffset : Math.min(scrollOffset, maxOffset);
|
|
1056
|
+
const startIndex = Math.max(0, totalLines - contentHeight - effectiveOffset);
|
|
1057
|
+
const endIndex = Math.min(startIndex + contentHeight, totalLines);
|
|
1058
|
+
const visible = filtered.slice(startIndex, endIndex);
|
|
1059
|
+
useEffect3(() => {
|
|
1060
|
+
resetScroll();
|
|
1061
|
+
}, [filter, searchTerm, resetScroll]);
|
|
1062
|
+
const scrolled = effectiveOffset > 0;
|
|
742
1063
|
const label = [
|
|
743
1064
|
"Logs",
|
|
744
1065
|
filter ? `[${filter}]` : "",
|
|
745
1066
|
searchTerm ? `/${searchTerm}` : "",
|
|
746
1067
|
paused ? "[PAUSED]" : "",
|
|
747
|
-
|
|
1068
|
+
scrolled ? "[SCROLL]" : "",
|
|
1069
|
+
`${filtered.length} lines`,
|
|
1070
|
+
focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
|
|
748
1071
|
].filter(Boolean).join(" ");
|
|
749
1072
|
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "cyan" : "gray", height, children: [
|
|
750
1073
|
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
|
|
@@ -772,6 +1095,7 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
|
|
|
772
1095
|
}
|
|
773
1096
|
|
|
774
1097
|
// src/tui/StatsPanel.tsx
|
|
1098
|
+
import { useEffect as useEffect4 } from "react";
|
|
775
1099
|
import { Box as Box2, Text as Text2 } from "ink";
|
|
776
1100
|
import os from "os";
|
|
777
1101
|
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
@@ -822,7 +1146,7 @@ function ColHeader({ ml }) {
|
|
|
822
1146
|
"Up".padStart(6)
|
|
823
1147
|
] });
|
|
824
1148
|
}
|
|
825
|
-
function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
|
|
1149
|
+
function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scrollOffset, resetScroll }) {
|
|
826
1150
|
const names = [...states.keys()];
|
|
827
1151
|
const stObj = Object.fromEntries([...states].map(([k, v]) => [k, { errors: v.errors }]));
|
|
828
1152
|
const statsObj = Object.fromEntries([...stats].map(([k, v]) => [k, v]));
|
|
@@ -846,12 +1170,29 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
|
|
|
846
1170
|
}
|
|
847
1171
|
const stackMem = totalMemMB >= 1024 ? (totalMemMB / 1024).toFixed(2) + " GB" : totalMemMB.toFixed(1) + " MB";
|
|
848
1172
|
const ml = maxNameLen;
|
|
849
|
-
const contentHeight = height - 2;
|
|
1173
|
+
const contentHeight = Math.max(1, height - 2);
|
|
1174
|
+
const rowsPerCol = Math.max(1, contentHeight - 2);
|
|
1175
|
+
const maxRows = Math.max(0, Math.max(apis.length, webs.length) - rowsPerCol);
|
|
1176
|
+
const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxRows : Math.min(scrollOffset, maxRows);
|
|
1177
|
+
const apiStartIndex = Math.min(effectiveOffset, Math.max(0, apis.length - rowsPerCol));
|
|
1178
|
+
const webStartIndex = Math.min(effectiveOffset, Math.max(0, webs.length - rowsPerCol));
|
|
1179
|
+
const visibleApis = apis.slice(apiStartIndex, apiStartIndex + rowsPerCol);
|
|
1180
|
+
const visibleWebs = webs.slice(webStartIndex, webStartIndex + rowsPerCol);
|
|
1181
|
+
useEffect4(() => {
|
|
1182
|
+
resetScroll();
|
|
1183
|
+
}, [sortMode, resetScroll]);
|
|
1184
|
+
const totalRowsLong = Math.max(apis.length, webs.length);
|
|
1185
|
+
const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
|
|
1186
|
+
const scrolled = effectiveOffset > 0;
|
|
850
1187
|
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
|
|
851
1188
|
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
852
|
-
/* @__PURE__ */
|
|
1189
|
+
/* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
|
|
1190
|
+
" Stats ",
|
|
1191
|
+
positionInfo
|
|
1192
|
+
] }),
|
|
1193
|
+
scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
|
|
853
1194
|
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
854
|
-
"System: ",
|
|
1195
|
+
" System: ",
|
|
855
1196
|
cpus,
|
|
856
1197
|
"c Load ",
|
|
857
1198
|
load,
|
|
@@ -887,7 +1228,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
|
|
|
887
1228
|
")"
|
|
888
1229
|
] }),
|
|
889
1230
|
/* @__PURE__ */ jsx2(ColHeader, { ml }),
|
|
890
|
-
|
|
1231
|
+
visibleApis.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
|
|
891
1232
|
] }),
|
|
892
1233
|
/* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2502" }, i)) }),
|
|
893
1234
|
/* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
|
|
@@ -897,7 +1238,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
|
|
|
897
1238
|
")"
|
|
898
1239
|
] }),
|
|
899
1240
|
/* @__PURE__ */ jsx2(ColHeader, { ml }),
|
|
900
|
-
|
|
1241
|
+
visibleWebs.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
|
|
901
1242
|
] })
|
|
902
1243
|
] })
|
|
903
1244
|
] });
|
|
@@ -912,6 +1253,12 @@ function StatusBar() {
|
|
|
912
1253
|
" Quit ",
|
|
913
1254
|
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "Tab" }),
|
|
914
1255
|
" Switch ",
|
|
1256
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u2191\u2193" }),
|
|
1257
|
+
" Scroll ",
|
|
1258
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "PgUp/PgDn" }),
|
|
1259
|
+
" Page ",
|
|
1260
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "Ctrl+A/E" }),
|
|
1261
|
+
" Home/End ",
|
|
915
1262
|
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "c" }),
|
|
916
1263
|
" Clear ",
|
|
917
1264
|
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
|
|
@@ -985,59 +1332,58 @@ function SearchInput({ onSubmit, onClose }) {
|
|
|
985
1332
|
] });
|
|
986
1333
|
}
|
|
987
1334
|
|
|
988
|
-
// src/lazy/classifier.ts
|
|
989
|
-
var LAZY_PORT_OFFSET = 1e4;
|
|
990
|
-
function classifyServices(services, config) {
|
|
991
|
-
const alwaysOnSet = new Set(config?.alwaysOn ?? []);
|
|
992
|
-
const alwaysOn = [];
|
|
993
|
-
const lazy = [];
|
|
994
|
-
for (const svc of services) {
|
|
995
|
-
if (alwaysOnSet.has(svc.name)) alwaysOn.push(svc);
|
|
996
|
-
else lazy.push(svc);
|
|
997
|
-
}
|
|
998
|
-
return { alwaysOn, lazy };
|
|
999
|
-
}
|
|
1000
|
-
function getLazyRealPort(originalPort) {
|
|
1001
|
-
return originalPort + LAZY_PORT_OFFSET;
|
|
1002
|
-
}
|
|
1003
|
-
function rewriteServicePort(svc) {
|
|
1004
|
-
const realPort = getLazyRealPort(svc.port);
|
|
1005
|
-
const args = svc.args.map((a) => a === String(svc.port) ? String(realPort) : a);
|
|
1006
|
-
const extraEnv = { ...svc.extraEnv, PORT_OVERRIDE: String(realPort) };
|
|
1007
|
-
return { ...svc, port: realPort, args, extraEnv, realPort, originalPort: svc.port };
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
1335
|
// src/lazy/proxy.ts
|
|
1011
1336
|
import net2 from "net";
|
|
1012
1337
|
function createLazyProxy(opts) {
|
|
1013
1338
|
const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
|
|
1014
1339
|
let idleTimer = null;
|
|
1340
|
+
let lastActivity = Date.now();
|
|
1015
1341
|
let starting = false;
|
|
1016
1342
|
let serviceReady = false;
|
|
1017
1343
|
let pendingConns = [];
|
|
1018
|
-
|
|
1344
|
+
const activeConns = /* @__PURE__ */ new Set();
|
|
1345
|
+
function bumpActivity() {
|
|
1346
|
+
lastActivity = Date.now();
|
|
1347
|
+
}
|
|
1348
|
+
function scheduleIdleCheck() {
|
|
1019
1349
|
if (idleTimer) clearTimeout(idleTimer);
|
|
1020
|
-
if (timeoutMin
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1350
|
+
if (timeoutMin <= 0) return;
|
|
1351
|
+
const periodMs = timeoutMin * 6e4;
|
|
1352
|
+
idleTimer = setTimeout(() => {
|
|
1353
|
+
const elapsed = Date.now() - lastActivity;
|
|
1354
|
+
if (activeConns.size > 0 || elapsed < periodMs) {
|
|
1355
|
+
scheduleIdleCheck();
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
serviceReady = false;
|
|
1359
|
+
onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
|
|
1360
|
+
onIdleStop();
|
|
1361
|
+
}, periodMs);
|
|
1027
1362
|
}
|
|
1028
1363
|
function pipeToTarget(client) {
|
|
1029
1364
|
const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
|
|
1365
|
+
activeConns.add(client);
|
|
1366
|
+
const cleanup = () => {
|
|
1367
|
+
activeConns.delete(client);
|
|
1368
|
+
bumpActivity();
|
|
1369
|
+
};
|
|
1030
1370
|
target.on("error", () => {
|
|
1031
1371
|
client.destroy();
|
|
1372
|
+
cleanup();
|
|
1032
1373
|
});
|
|
1033
1374
|
client.on("error", () => {
|
|
1034
1375
|
target.destroy();
|
|
1376
|
+
cleanup();
|
|
1035
1377
|
});
|
|
1378
|
+
client.on("close", cleanup);
|
|
1379
|
+
target.on("close", cleanup);
|
|
1036
1380
|
target.on("connect", () => {
|
|
1037
1381
|
target.on("data", (chunk) => {
|
|
1382
|
+
bumpActivity();
|
|
1038
1383
|
if (!client.destroyed) client.write(chunk);
|
|
1039
1384
|
});
|
|
1040
1385
|
client.on("data", (chunk) => {
|
|
1386
|
+
bumpActivity();
|
|
1041
1387
|
if (!target.destroyed) target.write(chunk);
|
|
1042
1388
|
});
|
|
1043
1389
|
target.on("end", () => {
|
|
@@ -1049,7 +1395,7 @@ function createLazyProxy(opts) {
|
|
|
1049
1395
|
});
|
|
1050
1396
|
}
|
|
1051
1397
|
async function handleConnection(client) {
|
|
1052
|
-
|
|
1398
|
+
bumpActivity();
|
|
1053
1399
|
client.on("error", () => {
|
|
1054
1400
|
});
|
|
1055
1401
|
if (serviceReady && isAlive()) {
|
|
@@ -1063,9 +1409,10 @@ function createLazyProxy(opts) {
|
|
|
1063
1409
|
if (starting) return;
|
|
1064
1410
|
starting = true;
|
|
1065
1411
|
onLog?.("\u26A1 on-demand start");
|
|
1412
|
+
let ok = false;
|
|
1066
1413
|
try {
|
|
1067
1414
|
await onDemandStart();
|
|
1068
|
-
|
|
1415
|
+
ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
|
|
1069
1416
|
if (ok) serviceReady = true;
|
|
1070
1417
|
else onLog?.("\u26A0 timeout waiting for service");
|
|
1071
1418
|
} catch (e) {
|
|
@@ -1073,19 +1420,26 @@ function createLazyProxy(opts) {
|
|
|
1073
1420
|
}
|
|
1074
1421
|
starting = false;
|
|
1075
1422
|
const conns = pendingConns.splice(0);
|
|
1423
|
+
if (!ok) {
|
|
1424
|
+
for (const conn of conns) {
|
|
1425
|
+
if (!conn.destroyed) conn.destroy();
|
|
1426
|
+
}
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1076
1429
|
for (const conn of conns) {
|
|
1077
1430
|
if (!conn.destroyed) pipeToTarget(conn);
|
|
1078
1431
|
}
|
|
1079
1432
|
}
|
|
1080
1433
|
const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
|
|
1081
1434
|
server.listen(listenPort, "0.0.0.0");
|
|
1082
|
-
|
|
1435
|
+
scheduleIdleCheck();
|
|
1083
1436
|
return {
|
|
1084
1437
|
server,
|
|
1085
|
-
resetTimer,
|
|
1438
|
+
resetTimer: bumpActivity,
|
|
1086
1439
|
destroy: () => {
|
|
1087
1440
|
if (idleTimer) clearTimeout(idleTimer);
|
|
1088
1441
|
pendingConns.forEach((s) => s.destroy());
|
|
1442
|
+
activeConns.forEach((s) => s.destroy());
|
|
1089
1443
|
server.close();
|
|
1090
1444
|
}
|
|
1091
1445
|
};
|
|
@@ -1093,28 +1447,42 @@ function createLazyProxy(opts) {
|
|
|
1093
1447
|
|
|
1094
1448
|
// src/tui/App.tsx
|
|
1095
1449
|
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1096
|
-
function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts }) {
|
|
1450
|
+
function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
|
|
1097
1451
|
const { stdout } = useStdout();
|
|
1098
|
-
const rows = stdout?.rows ?? 40;
|
|
1452
|
+
const [rows, setRows] = useState5(stdout?.rows ?? 40);
|
|
1453
|
+
useEffect5(() => {
|
|
1454
|
+
if (!stdout) return;
|
|
1455
|
+
const onResize = () => setRows(stdout.rows ?? 40);
|
|
1456
|
+
stdout.on("resize", onResize);
|
|
1457
|
+
return () => {
|
|
1458
|
+
stdout.off("resize", onResize);
|
|
1459
|
+
};
|
|
1460
|
+
}, [stdout]);
|
|
1099
1461
|
const logsHeight = Math.floor(rows * 0.65);
|
|
1100
1462
|
const statsHeight = rows - logsHeight - 2;
|
|
1101
1463
|
const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
|
|
1102
|
-
const pm = useProcessManager(platform, baseCwd, env);
|
|
1464
|
+
const pm = useProcessManager(platform, baseCwd, env, logSink);
|
|
1103
1465
|
const [booted, setBooted] = useState5(false);
|
|
1104
1466
|
const lazyProxies = useRef3(/* @__PURE__ */ new Map());
|
|
1105
1467
|
const kb = useKeyBindings({
|
|
1106
1468
|
onQuit: () => {
|
|
1107
|
-
|
|
1108
|
-
pm.cleanup();
|
|
1109
|
-
process.exit(0);
|
|
1110
|
-
},
|
|
1111
|
-
onClearLogs: () => {
|
|
1469
|
+
void shutdown();
|
|
1112
1470
|
},
|
|
1471
|
+
onClearLogs: pm.clearLogs,
|
|
1113
1472
|
onToggleProxy: () => {
|
|
1114
1473
|
}
|
|
1115
1474
|
});
|
|
1475
|
+
const shutdown = useCallback3(async () => {
|
|
1476
|
+
lazyProxies.current.forEach((p) => p.destroy());
|
|
1477
|
+
await pm.cleanup();
|
|
1478
|
+
await logSink?.close();
|
|
1479
|
+
process.exit(0);
|
|
1480
|
+
}, [pm, logSink]);
|
|
1481
|
+
useEffect5(() => {
|
|
1482
|
+
pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
|
|
1483
|
+
}, [kb.logsPaused, kb.logsScrollOffset, pm]);
|
|
1116
1484
|
useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
|
|
1117
|
-
|
|
1485
|
+
useEffect5(() => {
|
|
1118
1486
|
if (booted || !pm.manager) return;
|
|
1119
1487
|
setBooted(true);
|
|
1120
1488
|
const mgr = pm.manager;
|
|
@@ -1128,8 +1496,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1128
1496
|
for (const num of Object.keys(aoPhases).map(Number).sort((a, b) => a - b)) {
|
|
1129
1497
|
const svcs = aoPhases[num];
|
|
1130
1498
|
for (const svc of svcs) {
|
|
1131
|
-
|
|
1132
|
-
await mgr.
|
|
1499
|
+
const ci = colorIdx++;
|
|
1500
|
+
await mgr.install(svc, ci);
|
|
1501
|
+
await mgr.start(svc, ci);
|
|
1133
1502
|
}
|
|
1134
1503
|
const apis = svcs.filter((s) => s.type === "api");
|
|
1135
1504
|
if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
|
|
@@ -1159,7 +1528,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1159
1528
|
targetPort: rewritten.realPort,
|
|
1160
1529
|
timeoutMin: lazyTimeout,
|
|
1161
1530
|
onDemandStart: async () => {
|
|
1162
|
-
await mgr.install(rewritten);
|
|
1531
|
+
await mgr.install(rewritten, ci);
|
|
1163
1532
|
await mgr.start(rewritten, ci);
|
|
1164
1533
|
const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
|
|
1165
1534
|
const st = mgr.state.get(svc.name);
|
|
@@ -1192,8 +1561,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1192
1561
|
for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
|
|
1193
1562
|
const svcs = phases[num];
|
|
1194
1563
|
for (const svc of svcs) {
|
|
1195
|
-
|
|
1196
|
-
await mgr.
|
|
1564
|
+
const ci = colorIdx++;
|
|
1565
|
+
await mgr.install(svc, ci);
|
|
1566
|
+
await mgr.start(svc, ci);
|
|
1197
1567
|
}
|
|
1198
1568
|
const apis = svcs.filter((s) => s.type === "api");
|
|
1199
1569
|
if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
|
|
@@ -1239,7 +1609,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1239
1609
|
showTimestamps: kb.showTimestamps,
|
|
1240
1610
|
maxNameLen,
|
|
1241
1611
|
height: logsHeight,
|
|
1242
|
-
focused: kb.panel === "logs"
|
|
1612
|
+
focused: kb.panel === "logs",
|
|
1613
|
+
scrollOffset: kb.logsScrollOffset,
|
|
1614
|
+
resetScroll: kb.resetLogsScroll
|
|
1243
1615
|
}
|
|
1244
1616
|
),
|
|
1245
1617
|
/* @__PURE__ */ jsx6(
|
|
@@ -1250,7 +1622,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1250
1622
|
sortMode: kb.sortMode,
|
|
1251
1623
|
maxNameLen,
|
|
1252
1624
|
height: statsHeight,
|
|
1253
|
-
focused: kb.panel === "stats"
|
|
1625
|
+
focused: kb.panel === "stats",
|
|
1626
|
+
scrollOffset: kb.statsScrollOffset,
|
|
1627
|
+
resetScroll: kb.resetStatsScroll
|
|
1254
1628
|
}
|
|
1255
1629
|
),
|
|
1256
1630
|
kb.modal === "filter" && /* @__PURE__ */ jsx6(ServiceList, { title: "Filter by service", services: pm.states, onSelect: handleFilterSelect, onClose: () => kb.setModal("none") }),
|
|
@@ -1261,6 +1635,189 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
|
|
|
1261
1635
|
] });
|
|
1262
1636
|
}
|
|
1263
1637
|
|
|
1638
|
+
// src/process/log-sink.ts
|
|
1639
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
|
|
1640
|
+
import { join as join4, dirname as dirname4 } from "path";
|
|
1641
|
+
import { homedir } from "os";
|
|
1642
|
+
var LogSink = class {
|
|
1643
|
+
dir;
|
|
1644
|
+
rotateOnStart;
|
|
1645
|
+
streams = /* @__PURE__ */ new Map();
|
|
1646
|
+
seen = /* @__PURE__ */ new Set();
|
|
1647
|
+
constructor(opts) {
|
|
1648
|
+
const root = opts.rootDir ?? join4(homedir(), ".devup", "logs");
|
|
1649
|
+
this.dir = join4(root, sanitize(opts.projectName));
|
|
1650
|
+
this.rotateOnStart = opts.rotateOnStart ?? true;
|
|
1651
|
+
mkdirSync4(this.dir, { recursive: true });
|
|
1652
|
+
}
|
|
1653
|
+
/** Returns the file path for a service log (useful for tests / UI). */
|
|
1654
|
+
pathFor(svcName) {
|
|
1655
|
+
return join4(this.dir, `${sanitize(svcName)}.log`);
|
|
1656
|
+
}
|
|
1657
|
+
write(svcName, line) {
|
|
1658
|
+
const stream = this.streamFor(svcName);
|
|
1659
|
+
stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
1660
|
+
`);
|
|
1661
|
+
}
|
|
1662
|
+
async close() {
|
|
1663
|
+
const closes = [...this.streams.values()].map(
|
|
1664
|
+
(s) => new Promise((r) => s.end(() => r()))
|
|
1665
|
+
);
|
|
1666
|
+
this.streams.clear();
|
|
1667
|
+
this.seen.clear();
|
|
1668
|
+
await Promise.all(closes);
|
|
1669
|
+
}
|
|
1670
|
+
streamFor(svcName) {
|
|
1671
|
+
let s = this.streams.get(svcName);
|
|
1672
|
+
if (s) return s;
|
|
1673
|
+
const file = this.pathFor(svcName);
|
|
1674
|
+
if (this.rotateOnStart && !this.seen.has(svcName) && existsSync8(file)) {
|
|
1675
|
+
try {
|
|
1676
|
+
mkdirSync4(dirname4(file), { recursive: true });
|
|
1677
|
+
renameSync(file, file + ".prev");
|
|
1678
|
+
} catch {
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
this.seen.add(svcName);
|
|
1682
|
+
s = createWriteStream(file, { flags: "a" });
|
|
1683
|
+
s.on("error", () => {
|
|
1684
|
+
});
|
|
1685
|
+
this.streams.set(svcName, s);
|
|
1686
|
+
return s;
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
function sanitize(name) {
|
|
1690
|
+
return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// src/orchestrator/dry-run.ts
|
|
1694
|
+
function renderDryRun(opts) {
|
|
1695
|
+
const { config, services, cliArgs, env, proxyProvider, proxyOpts } = opts;
|
|
1696
|
+
const lines = [];
|
|
1697
|
+
lines.push(`Project: ${config.icon ?? "\u{1F4E6}"} ${config.name}`);
|
|
1698
|
+
lines.push(`Mode: ${cliArgs.lazy && config.lazy ? "lazy" : "normal"}`);
|
|
1699
|
+
lines.push(`Services: ${services.length}`);
|
|
1700
|
+
lines.push("");
|
|
1701
|
+
const lazyMode = cliArgs.lazy && !!config.lazy;
|
|
1702
|
+
let alwaysOn = services;
|
|
1703
|
+
let lazy = [];
|
|
1704
|
+
if (lazyMode) {
|
|
1705
|
+
const c = classifyServices(services, config.lazy);
|
|
1706
|
+
alwaysOn = c.alwaysOn;
|
|
1707
|
+
lazy = c.lazy;
|
|
1708
|
+
}
|
|
1709
|
+
const phases = groupByPhase(alwaysOn);
|
|
1710
|
+
const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
|
|
1711
|
+
for (const num of phaseNums) {
|
|
1712
|
+
lines.push(`Phase ${num}:`);
|
|
1713
|
+
for (const svc of phases[num]) {
|
|
1714
|
+
lines.push(formatService(svc, env, false));
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
if (lazy.length) {
|
|
1718
|
+
lines.push("");
|
|
1719
|
+
lines.push("Lazy (on-demand):");
|
|
1720
|
+
for (const svc of lazy) {
|
|
1721
|
+
const rewritten = rewriteServicePort(svc);
|
|
1722
|
+
lines.push(formatService(rewritten, env, true));
|
|
1723
|
+
lines.push(` proxy :${svc.port} \u2192 :${getLazyRealPort(svc.port)} (idle timeout ${cliArgs.lazyTimeout}m)`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (proxyProvider && proxyOpts) {
|
|
1727
|
+
lines.push("");
|
|
1728
|
+
lines.push(`Proxy: ${proxyProvider.name} \u2192 ${proxyOpts.confPath}`);
|
|
1729
|
+
const svcStates = /* @__PURE__ */ new Map();
|
|
1730
|
+
for (const svc of services) {
|
|
1731
|
+
const real = lazyMode && !alwaysOn.includes(svc) ? getLazyRealPort(svc.port) : void 0;
|
|
1732
|
+
svcStates.set(svc.name, { port: svc.port, health: "up", realPort: real });
|
|
1733
|
+
}
|
|
1734
|
+
const content = proxyProvider.generate(svcStates, proxyOpts);
|
|
1735
|
+
lines.push("");
|
|
1736
|
+
lines.push("--- generated config ---");
|
|
1737
|
+
lines.push(content);
|
|
1738
|
+
}
|
|
1739
|
+
return lines.join("\n");
|
|
1740
|
+
}
|
|
1741
|
+
function formatService(svc, env, isLazy) {
|
|
1742
|
+
const args = buildProcessArgs(svc);
|
|
1743
|
+
const cmdLine = [svc.cmd, ...args].join(" ");
|
|
1744
|
+
const built = buildProcessEnv(svc, env);
|
|
1745
|
+
const extraEnv = Object.keys(svc.extraEnv ?? {}).length ? " env=" + Object.entries(svc.extraEnv).map(([k, v]) => `${k}=${v}`).join(" ") : "";
|
|
1746
|
+
const memTag = svc.maxMem ? ` mem=${svc.maxMem}MB` : "";
|
|
1747
|
+
const hc = svc.healthCheck;
|
|
1748
|
+
const hcTag = hc?.type === "http" ? ` health=http ${hc.path ?? "/"}` : "";
|
|
1749
|
+
const lazyTag = isLazy ? " [lazy]" : "";
|
|
1750
|
+
void built;
|
|
1751
|
+
return ` - ${svc.name.padEnd(20)} (${svc.type}) :${svc.port} ${cmdLine}${memTag}${hcTag}${lazyTag}${extraEnv}`;
|
|
1752
|
+
}
|
|
1753
|
+
function runDryRun(opts) {
|
|
1754
|
+
console.log(renderDryRun(opts));
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// src/orchestrator/once.ts
|
|
1758
|
+
async function runOnce(opts) {
|
|
1759
|
+
const out = opts.out ?? ((l) => console.log(l));
|
|
1760
|
+
const { config, services, cliArgs, platform, env, baseCwd, logSink } = opts;
|
|
1761
|
+
const mgr = new ProcessManager({
|
|
1762
|
+
baseCwd,
|
|
1763
|
+
env,
|
|
1764
|
+
platform,
|
|
1765
|
+
events: {
|
|
1766
|
+
onLog: (svc, text) => {
|
|
1767
|
+
logSink?.write(svc, text);
|
|
1768
|
+
out(`[${svc}] ${text}`);
|
|
1769
|
+
},
|
|
1770
|
+
onStateChange: () => {
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
const phases = groupByPhase(services);
|
|
1775
|
+
const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
|
|
1776
|
+
const apiNames = services.filter((s) => s.type === "api").map((s) => s.name);
|
|
1777
|
+
const deadline = Date.now() + cliArgs.onceTimeout * 1e3;
|
|
1778
|
+
let colorIdx = 0;
|
|
1779
|
+
for (const num of phaseNums) {
|
|
1780
|
+
out(`\u25B6 phase ${num}`);
|
|
1781
|
+
for (const svc of phases[num]) {
|
|
1782
|
+
const ci = colorIdx++;
|
|
1783
|
+
const installed = await mgr.install(svc, ci);
|
|
1784
|
+
if (!installed) {
|
|
1785
|
+
out(`\u2717 install failed for ${svc.name}`);
|
|
1786
|
+
await mgr.cleanup();
|
|
1787
|
+
return 1;
|
|
1788
|
+
}
|
|
1789
|
+
await mgr.start(svc, ci);
|
|
1790
|
+
}
|
|
1791
|
+
const apis = phases[num].filter((s) => s.type === "api");
|
|
1792
|
+
for (const api of apis) {
|
|
1793
|
+
const ok = await waitHealthy(api, deadline);
|
|
1794
|
+
if (!ok) {
|
|
1795
|
+
out(`\u2717 ${api.name} did not become healthy within ${cliArgs.onceTimeout}s`);
|
|
1796
|
+
await mgr.cleanup();
|
|
1797
|
+
return 1;
|
|
1798
|
+
}
|
|
1799
|
+
out(`\u2713 ${api.name} ready`);
|
|
1800
|
+
const st = mgr.state.get(api.name);
|
|
1801
|
+
if (st) {
|
|
1802
|
+
st.status = "running";
|
|
1803
|
+
st.health = "up";
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
const summary = `ready: ${apiNames.length} APIs in ${((cliArgs.onceTimeout * 1e3 - (deadline - Date.now())) / 1e3).toFixed(1)}s`;
|
|
1808
|
+
out(summary);
|
|
1809
|
+
await mgr.cleanup();
|
|
1810
|
+
return 0;
|
|
1811
|
+
}
|
|
1812
|
+
async function waitHealthy(svc, deadline) {
|
|
1813
|
+
while (Date.now() < deadline) {
|
|
1814
|
+
const ok = await checkHealth(svc.port, svc.healthCheck);
|
|
1815
|
+
if (ok) return true;
|
|
1816
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1817
|
+
}
|
|
1818
|
+
return false;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1264
1821
|
// src/config/types.ts
|
|
1265
1822
|
function defineConfig(config) {
|
|
1266
1823
|
return config;
|
|
@@ -1290,7 +1847,7 @@ ${formatValidationErrors(errors)}`);
|
|
|
1290
1847
|
process.exit(1);
|
|
1291
1848
|
}
|
|
1292
1849
|
const platform = await detectPlatform();
|
|
1293
|
-
const envFile = config.envFile ?
|
|
1850
|
+
const envFile = config.envFile ? join5(cwd, config.envFile) : join5(cwd, ".env");
|
|
1294
1851
|
const env = parseEnvFile(envFile, process.env);
|
|
1295
1852
|
if (config.env) {
|
|
1296
1853
|
for (const [k, v] of Object.entries(config.env)) {
|
|
@@ -1307,12 +1864,33 @@ ${formatValidationErrors(errors)}`);
|
|
|
1307
1864
|
routes: config.proxy.routes,
|
|
1308
1865
|
tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
|
|
1309
1866
|
entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
|
|
1310
|
-
confPath: cliArgs.proxyConf ?? config.proxy.confPath ??
|
|
1867
|
+
confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join5(homedir2(), ".traefik", "traefik_conf.yaml")
|
|
1311
1868
|
};
|
|
1312
1869
|
}
|
|
1870
|
+
if (cliArgs.dryRun) {
|
|
1871
|
+
runDryRun({ config, services, cliArgs, env, baseCwd: cwd, proxyProvider, proxyOpts });
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
let logSink = null;
|
|
1875
|
+
if (cliArgs.logFile) {
|
|
1876
|
+
logSink = new LogSink({ projectName: config.name, rootDir: cliArgs.logDir });
|
|
1877
|
+
}
|
|
1878
|
+
if (cliArgs.once) {
|
|
1879
|
+
const code = await runOnce({
|
|
1880
|
+
config,
|
|
1881
|
+
services,
|
|
1882
|
+
cliArgs,
|
|
1883
|
+
platform,
|
|
1884
|
+
env,
|
|
1885
|
+
baseCwd: cwd,
|
|
1886
|
+
logSink
|
|
1887
|
+
});
|
|
1888
|
+
await logSink?.close();
|
|
1889
|
+
process.exit(code);
|
|
1890
|
+
}
|
|
1313
1891
|
const isInteractive = process.stdin.isTTY ?? false;
|
|
1314
1892
|
const { waitUntilExit } = render(
|
|
1315
|
-
|
|
1893
|
+
React7.createElement(App, {
|
|
1316
1894
|
config,
|
|
1317
1895
|
services,
|
|
1318
1896
|
cliArgs,
|
|
@@ -1320,7 +1898,8 @@ ${formatValidationErrors(errors)}`);
|
|
|
1320
1898
|
env,
|
|
1321
1899
|
baseCwd: cwd,
|
|
1322
1900
|
proxyProvider,
|
|
1323
|
-
proxyOpts
|
|
1901
|
+
proxyOpts,
|
|
1902
|
+
logSink
|
|
1324
1903
|
}),
|
|
1325
1904
|
{ exitOnCtrlC: false, patchConsole: isInteractive, interactive: isInteractive }
|
|
1326
1905
|
);
|