@gachlab/devup 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +185 -13
  3. package/dist/config/cli.d.ts +8 -2
  4. package/dist/config/cli.d.ts.map +1 -1
  5. package/dist/config/types.d.ts +40 -0
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/config/validator.d.ts.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +987 -182
  10. package/dist/index.js.map +1 -1
  11. package/dist/lazy/proxy.d.ts.map +1 -1
  12. package/dist/orchestrator/dry-run.d.ts +15 -0
  13. package/dist/orchestrator/dry-run.d.ts.map +1 -0
  14. package/dist/orchestrator/once.d.ts +19 -0
  15. package/dist/orchestrator/once.d.ts.map +1 -0
  16. package/dist/process/external.d.ts +30 -0
  17. package/dist/process/external.d.ts.map +1 -0
  18. package/dist/process/health.d.ts +10 -1
  19. package/dist/process/health.d.ts.map +1 -1
  20. package/dist/process/installer.d.ts +0 -10
  21. package/dist/process/installer.d.ts.map +1 -1
  22. package/dist/process/log-sink.d.ts +23 -0
  23. package/dist/process/log-sink.d.ts.map +1 -0
  24. package/dist/process/manager.d.ts +12 -3
  25. package/dist/process/manager.d.ts.map +1 -1
  26. package/dist/process/types.d.ts +2 -0
  27. package/dist/process/types.d.ts.map +1 -1
  28. package/dist/proxy-config/caddy.d.ts +10 -0
  29. package/dist/proxy-config/caddy.d.ts.map +1 -0
  30. package/dist/proxy-config/detect.d.ts.map +1 -1
  31. package/dist/proxy-config/nginx.d.ts +10 -0
  32. package/dist/proxy-config/nginx.d.ts.map +1 -0
  33. package/dist/tui/App.d.ts +3 -1
  34. package/dist/tui/App.d.ts.map +1 -1
  35. package/dist/tui/LogsPanel.d.ts.map +1 -1
  36. package/dist/tui/StatsPanel.d.ts.map +1 -1
  37. package/dist/tui/hooks/useKeyBindings.d.ts.map +1 -1
  38. package/dist/tui/hooks/useProcessManager.d.ts +7 -3
  39. package/dist/tui/hooks/useProcessManager.d.ts.map +1 -1
  40. package/dist/tui/hooks/useProxySync.d.ts.map +1 -1
  41. package/dist/utils.d.ts +0 -5
  42. package/dist/utils.d.ts.map +1 -1
  43. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import React6 from "react";
4
+ import React7 from "react";
5
5
  import { render } from "ink";
6
- import { join as join4 } from "path";
7
- import { homedir } from "os";
6
+ import { join as join6 } 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,34 @@ 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.readyPattern !== void 0) {
116
+ if (typeof svc.readyPattern !== "string" || !svc.readyPattern.length) {
117
+ errors.push({ field: `services[${svc.name}].readyPattern`, message: `readyPattern must be a non-empty string` });
118
+ } else {
119
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(svc.readyPattern);
120
+ try {
121
+ if (slashed) new RegExp(slashed[1], slashed[2] || "i");
122
+ else new RegExp(svc.readyPattern, "i");
123
+ } catch (e) {
124
+ errors.push({ field: `services[${svc.name}].readyPattern`, message: `Invalid regex: ${e.message}` });
125
+ }
126
+ }
127
+ }
128
+ if (svc.preBuild !== void 0 && (typeof svc.preBuild !== "string" || !svc.preBuild.trim())) {
129
+ errors.push({ field: `services[${svc.name}].preBuild`, message: `preBuild must be a non-empty string` });
130
+ }
131
+ if (svc.watchBuild !== void 0 && (typeof svc.watchBuild !== "string" || !svc.watchBuild.trim())) {
132
+ errors.push({ field: `services[${svc.name}].watchBuild`, message: `watchBuild must be a non-empty string` });
133
+ }
134
+ if (svc.healthCheck) {
135
+ const hc = svc.healthCheck;
136
+ if (hc.type !== "tcp" && hc.type !== "http") {
137
+ errors.push({ field: `services[${svc.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type} (must be "tcp" or "http")` });
138
+ }
139
+ if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
140
+ errors.push({ field: `services[${svc.name}].healthCheck.path`, message: `healthCheck.path must start with "/": got "${hc.path}"` });
141
+ }
142
+ }
91
143
  }
92
144
  if (config.lazy?.alwaysOn) {
93
145
  for (const ref of config.lazy.alwaysOn) {
@@ -96,6 +148,50 @@ function validateConfig(config, cwd) {
96
148
  }
97
149
  }
98
150
  }
151
+ if (config.lazy) {
152
+ const alwaysOn = new Set(config.lazy.alwaysOn ?? []);
153
+ const portToSvc = /* @__PURE__ */ new Map();
154
+ for (const svc of config.services) portToSvc.set(svc.port, svc.name);
155
+ for (const svc of config.services) {
156
+ if (alwaysOn.has(svc.name)) continue;
157
+ const realPort = svc.port + LAZY_PORT_OFFSET;
158
+ const conflict = portToSvc.get(realPort);
159
+ if (conflict && conflict !== svc.name) {
160
+ errors.push({
161
+ field: `services[${svc.name}].port`,
162
+ message: `Lazy real port ${realPort} (= ${svc.port}+${LAZY_PORT_OFFSET}) collides with service ${conflict}`
163
+ });
164
+ }
165
+ }
166
+ }
167
+ if (config.external) {
168
+ const extNames = /* @__PURE__ */ new Set();
169
+ for (const ext of config.external) {
170
+ if (!ext.name?.trim()) {
171
+ errors.push({ field: "external[].name", message: "External service name is required" });
172
+ continue;
173
+ }
174
+ if (extNames.has(ext.name)) {
175
+ errors.push({ field: `external[${ext.name}].name`, message: `Duplicate external name: ${ext.name}` });
176
+ }
177
+ extNames.add(ext.name);
178
+ if (!ext.cmd?.trim()) {
179
+ errors.push({ field: `external[${ext.name}].cmd`, message: "cmd is required" });
180
+ }
181
+ if (ext.healthCheck) {
182
+ const hc = ext.healthCheck;
183
+ if (hc.type !== "tcp" && hc.type !== "http") {
184
+ errors.push({ field: `external[${ext.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type}` });
185
+ }
186
+ if ((hc.type === "tcp" || hc.type === "http") && (typeof ext.port !== "number" || ext.port <= 0)) {
187
+ errors.push({ field: `external[${ext.name}].port`, message: `port is required when healthCheck is set (got ${ext.port})` });
188
+ }
189
+ if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
190
+ errors.push({ field: `external[${ext.name}].healthCheck.path`, message: `must start with "/"` });
191
+ }
192
+ }
193
+ }
194
+ }
99
195
  if (config.proxy?.routes) {
100
196
  for (const ref of Object.keys(config.proxy.routes)) {
101
197
  if (!names.has(ref)) {
@@ -103,6 +199,19 @@ function validateConfig(config, cwd) {
103
199
  }
104
200
  }
105
201
  }
202
+ if (config.profiles) {
203
+ for (const [profile, svcNames] of Object.entries(config.profiles)) {
204
+ if (!Array.isArray(svcNames) || !svcNames.length) {
205
+ errors.push({ field: `profiles.${profile}`, message: `Profile "${profile}" must be a non-empty array of service names` });
206
+ continue;
207
+ }
208
+ for (const ref of svcNames) {
209
+ if (!names.has(ref)) {
210
+ errors.push({ field: `profiles.${profile}`, message: `Unknown service: ${ref}` });
211
+ }
212
+ }
213
+ }
214
+ }
106
215
  return errors;
107
216
  }
108
217
  function formatValidationErrors(errors) {
@@ -111,6 +220,7 @@ function formatValidationErrors(errors) {
111
220
 
112
221
  // src/config/cli.ts
113
222
  var DEFAULT_LAZY_TIMEOUT = 10;
223
+ var DEFAULT_ONCE_TIMEOUT = 90;
114
224
  function parseCliArgs(argv) {
115
225
  const args = {
116
226
  skip: [],
@@ -118,7 +228,11 @@ function parseCliArgs(argv) {
118
228
  lazyTimeout: DEFAULT_LAZY_TIMEOUT,
119
229
  proxy: false,
120
230
  proxyTls: true,
121
- proxyEntrypoint: "websecure"
231
+ proxyEntrypoint: "websecure",
232
+ dryRun: false,
233
+ once: false,
234
+ onceTimeout: DEFAULT_ONCE_TIMEOUT,
235
+ logFile: true
122
236
  };
123
237
  for (let i = 0; i < argv.length; i++) {
124
238
  const arg = argv[i];
@@ -140,6 +254,10 @@ function parseCliArgs(argv) {
140
254
  args.services = next?.split(",");
141
255
  i++;
142
256
  break;
257
+ case "--profile":
258
+ args.profile = next;
259
+ i++;
260
+ break;
143
261
  case "--lazy":
144
262
  args.lazy = true;
145
263
  break;
@@ -171,13 +289,39 @@ function parseCliArgs(argv) {
171
289
  args.proxyEntrypoint = next ?? "websecure";
172
290
  i++;
173
291
  break;
292
+ case "--dry-run":
293
+ args.dryRun = true;
294
+ break;
295
+ case "--once":
296
+ args.once = true;
297
+ break;
298
+ case "--once-timeout":
299
+ args.onceTimeout = parseInt(next ?? "", 10) || DEFAULT_ONCE_TIMEOUT;
300
+ i++;
301
+ break;
302
+ case "--no-log-file":
303
+ args.logFile = false;
304
+ break;
305
+ case "--log-dir":
306
+ args.logDir = next;
307
+ i++;
308
+ break;
174
309
  }
175
310
  }
176
311
  return args;
177
312
  }
178
- function filterServices(services, args) {
313
+ function filterServices(services, args, config) {
179
314
  let result = services;
180
- if (args.services) {
315
+ if (args.profile) {
316
+ const profileNames = config?.profiles?.[args.profile];
317
+ if (!profileNames) {
318
+ const available = Object.keys(config?.profiles ?? {});
319
+ const hint = available.length ? `Available: ${available.join(", ")}` : "No profiles defined in config.";
320
+ throw new Error(`Unknown profile: "${args.profile}". ${hint}`);
321
+ }
322
+ const set = new Set(profileNames);
323
+ result = result.filter((s) => set.has(s.name));
324
+ } else if (args.services) {
181
325
  const explicit = new Set(args.services);
182
326
  result = result.filter((s) => explicit.has(s.name));
183
327
  } else if (args.only) {
@@ -268,9 +412,93 @@ ${svcs.join("\n")}
268
412
  }
269
413
  };
270
414
 
415
+ // src/proxy-config/nginx.ts
416
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
417
+ import { dirname as dirname2 } from "path";
418
+ var EMPTY_CONFIG2 = "# devup: no healthy services\n";
419
+ var NginxProvider = class {
420
+ name = "nginx";
421
+ generate(services, opts) {
422
+ const blocks = [];
423
+ for (const [name, st] of services) {
424
+ if (st.health !== "up") continue;
425
+ const sub = opts.routes[name];
426
+ if (sub === void 0) continue;
427
+ const serverName = sub ? `${sub}.${opts.domain}` : opts.domain;
428
+ const port = st.realPort ?? st.port;
429
+ const listen = opts.tls ? "443 ssl" : "80";
430
+ const tlsBlock = opts.tls ? ` ssl_certificate /etc/nginx/certs/${serverName}.crt;
431
+ ssl_certificate_key /etc/nginx/certs/${serverName}.key;
432
+ ` : "";
433
+ blocks.push(
434
+ `server {
435
+ listen ${listen};
436
+ server_name ${serverName};
437
+ ` + tlsBlock + ` location / {
438
+ proxy_pass http://${opts.host}:${port};
439
+ proxy_set_header Host $host;
440
+ proxy_set_header X-Real-IP $remote_addr;
441
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
442
+ proxy_set_header X-Forwarded-Proto $scheme;
443
+ proxy_http_version 1.1;
444
+ proxy_set_header Upgrade $http_upgrade;
445
+ proxy_set_header Connection "upgrade";
446
+ }
447
+ }`
448
+ );
449
+ }
450
+ if (!blocks.length) return EMPTY_CONFIG2;
451
+ return blocks.join("\n\n") + "\n";
452
+ }
453
+ write(content, opts) {
454
+ const dir = dirname2(opts.confPath);
455
+ if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
456
+ writeFileSync2(opts.confPath, content);
457
+ }
458
+ clear(opts) {
459
+ this.write(EMPTY_CONFIG2, opts);
460
+ }
461
+ };
462
+
463
+ // src/proxy-config/caddy.ts
464
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
465
+ import { dirname as dirname3 } from "path";
466
+ var EMPTY_CONFIG3 = "# devup: no healthy services\n";
467
+ var CaddyProvider = class {
468
+ name = "caddy";
469
+ generate(services, opts) {
470
+ const blocks = [];
471
+ for (const [name, st] of services) {
472
+ if (st.health !== "up") continue;
473
+ const sub = opts.routes[name];
474
+ if (sub === void 0) continue;
475
+ const host = sub ? `${sub}.${opts.domain}` : opts.domain;
476
+ const port = st.realPort ?? st.port;
477
+ const siteAddr = opts.tls ? host : `http://${host}`;
478
+ blocks.push(
479
+ `${siteAddr} {
480
+ reverse_proxy ${opts.host}:${port}
481
+ }`
482
+ );
483
+ }
484
+ if (!blocks.length) return EMPTY_CONFIG3;
485
+ return blocks.join("\n\n") + "\n";
486
+ }
487
+ write(content, opts) {
488
+ const dir = dirname3(opts.confPath);
489
+ if (!existsSync5(dir)) mkdirSync3(dir, { recursive: true });
490
+ writeFileSync3(opts.confPath, content);
491
+ }
492
+ clear(opts) {
493
+ this.write(EMPTY_CONFIG3, opts);
494
+ }
495
+ };
496
+
271
497
  // src/proxy-config/detect.ts
272
498
  var providers = {
273
- traefik: () => new TraefikProvider()
499
+ traefik: () => new TraefikProvider(),
500
+ nginx: () => new NginxProvider(),
501
+ caddy: () => new CaddyProvider()
274
502
  };
275
503
  function detectProxyProvider(name) {
276
504
  const factory = providers[name];
@@ -282,12 +510,12 @@ function detectProxyProvider(name) {
282
510
  }
283
511
 
284
512
  // src/utils.ts
285
- import { existsSync as existsSync4, readFileSync, writeFileSync as writeFileSync2 } from "fs";
513
+ import { existsSync as existsSync6, readFileSync, writeFileSync as writeFileSync4 } from "fs";
286
514
  import { createHash } from "crypto";
287
515
  import { join as join2 } from "path";
288
516
  function parseEnvFile(filePath, baseEnv = {}) {
289
517
  const env = { ...baseEnv };
290
- if (!existsSync4(filePath)) return env;
518
+ if (!existsSync6(filePath)) return env;
291
519
  for (const line of readFileSync(filePath, "utf8").split("\n")) {
292
520
  const trimmed = line.trim();
293
521
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -309,15 +537,17 @@ function fmtUptime(ms) {
309
537
  const m = Math.floor(s / 60);
310
538
  if (m < 60) return `${m}m${s % 60}s`;
311
539
  const h = Math.floor(m / 60);
312
- return `${h}h${m % 60}m`;
540
+ if (h < 24) return `${h}h${m % 60}m`;
541
+ const d = Math.floor(h / 24);
542
+ return `${d}d${h % 24}h`;
313
543
  }
314
544
  function needsInstall(fullCwd) {
315
545
  const nm = join2(fullCwd, "node_modules");
316
- if (!existsSync4(nm)) return true;
546
+ if (!existsSync6(nm)) return true;
317
547
  try {
318
548
  const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
319
549
  const stampFile = join2(nm, ".install-stamp");
320
- if (existsSync4(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
550
+ if (existsSync6(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
321
551
  } catch {
322
552
  }
323
553
  return true;
@@ -325,7 +555,7 @@ function needsInstall(fullCwd) {
325
555
  function writeInstallStamp(fullCwd) {
326
556
  try {
327
557
  const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
328
- writeFileSync2(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
558
+ writeFileSync4(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
329
559
  } catch {
330
560
  }
331
561
  }
@@ -396,10 +626,11 @@ import { join as join3 } from "path";
396
626
 
397
627
  // src/process/health.ts
398
628
  import net from "net";
399
- function checkPort(port, host = "127.0.0.1") {
629
+ import http from "http";
630
+ function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
400
631
  return new Promise((resolve3) => {
401
632
  const socket = new net.Socket();
402
- socket.setTimeout(2e3);
633
+ socket.setTimeout(timeoutMs);
403
634
  socket.once("connect", () => {
404
635
  socket.destroy();
405
636
  resolve3(true);
@@ -415,6 +646,39 @@ function checkPort(port, host = "127.0.0.1") {
415
646
  socket.connect(port, host);
416
647
  });
417
648
  }
649
+ function checkHttp(port, opts = {}) {
650
+ const path = opts.path ?? "/";
651
+ const host = opts.host ?? "127.0.0.1";
652
+ const timeoutMs = opts.timeoutMs ?? 2e3;
653
+ const accept = (code) => {
654
+ if (opts.expect === void 0) return code >= 200 && code < 300;
655
+ if (Array.isArray(opts.expect)) return opts.expect.includes(code);
656
+ return code === opts.expect;
657
+ };
658
+ return new Promise((resolve3) => {
659
+ const req = http.get({ host, port, path, timeout: timeoutMs }, (res) => {
660
+ const ok = typeof res.statusCode === "number" && accept(res.statusCode);
661
+ res.resume();
662
+ resolve3(ok);
663
+ });
664
+ req.on("error", () => resolve3(false));
665
+ req.on("timeout", () => {
666
+ req.destroy();
667
+ resolve3(false);
668
+ });
669
+ });
670
+ }
671
+ function checkHealth(port, hc) {
672
+ if (hc?.type === "http") {
673
+ return checkHttp(port, {
674
+ path: hc.path,
675
+ expect: hc.expect,
676
+ host: hc.host,
677
+ timeoutMs: hc.timeoutMs
678
+ });
679
+ }
680
+ return checkPort(port, "127.0.0.1", hc?.timeoutMs);
681
+ }
418
682
  function waitForPort(port, opts = {}) {
419
683
  const { timeout = 45e3, interval = 1e3 } = opts;
420
684
  return new Promise((resolve3) => {
@@ -437,9 +701,9 @@ function deriveHealth(isUp, currentStatus) {
437
701
 
438
702
  // src/process/installer.ts
439
703
  import { spawn } from "child_process";
440
- import { existsSync as existsSync5 } from "fs";
704
+ import { existsSync as existsSync7 } from "fs";
441
705
  function installService(cwd, env, onLog) {
442
- if (!existsSync5(cwd)) {
706
+ if (!existsSync7(cwd)) {
443
707
  onLog?.(`\u26A0 directory not found: ${cwd}`);
444
708
  return Promise.resolve(false);
445
709
  }
@@ -475,6 +739,36 @@ function installService(cwd, env, onLog) {
475
739
  // src/process/manager.ts
476
740
  var MAX_RESTARTS = 3;
477
741
  var BACKOFF_BASE_MS = 2e3;
742
+ function compileReadyPattern(pattern) {
743
+ if (!pattern) return null;
744
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
745
+ try {
746
+ if (slashed) return new RegExp(slashed[1], slashed[2] || "i");
747
+ return new RegExp(pattern, "i");
748
+ } catch {
749
+ return null;
750
+ }
751
+ }
752
+ function lineBuffer(onLine) {
753
+ let buf = "";
754
+ return {
755
+ push(chunk) {
756
+ buf += chunk.toString();
757
+ let idx;
758
+ while ((idx = buf.indexOf("\n")) !== -1) {
759
+ const line = buf.slice(0, idx).replace(/\r$/, "");
760
+ buf = buf.slice(idx + 1);
761
+ if (line.length) onLine(line);
762
+ }
763
+ },
764
+ flush() {
765
+ if (buf.length) {
766
+ onLine(buf);
767
+ buf = "";
768
+ }
769
+ }
770
+ };
771
+ }
478
772
  var ProcessManager = class {
479
773
  state = /* @__PURE__ */ new Map();
480
774
  procs = /* @__PURE__ */ new Set();
@@ -488,9 +782,10 @@ var ProcessManager = class {
488
782
  this.platform = opts.platform;
489
783
  this.events = opts.events;
490
784
  }
491
- async install(svc) {
785
+ async install(svc, colorIdx) {
492
786
  const cwd = join3(this.baseCwd, svc.cwd);
493
- return installService(cwd, this.env, (msg) => this.log(svc.name, msg, this.getColorIdx(svc.name)));
787
+ const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
788
+ return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
494
789
  }
495
790
  async start(svc, colorIdx, isRestart = false) {
496
791
  const cwd = join3(this.baseCwd, svc.cwd);
@@ -501,6 +796,13 @@ var ProcessManager = class {
501
796
  return;
502
797
  }
503
798
  }
799
+ if (svc.preBuild) {
800
+ const built = await this.runPreBuild(svc, cwd, colorIdx);
801
+ if (!built) {
802
+ this.recordCrashedState(svc, colorIdx);
803
+ return;
804
+ }
805
+ }
504
806
  const args = buildProcessArgs(svc);
505
807
  const env = buildProcessEnv(svc, this.env);
506
808
  const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
@@ -520,13 +822,31 @@ var ProcessManager = class {
520
822
  this.state.set(svc.name, state);
521
823
  this.procs.add(proc);
522
824
  this.events.onStateChange(svc.name, state);
523
- proc.stdout?.on("data", (d) => this.log(svc.name, d.toString(), colorIdx));
524
- proc.stderr?.on("data", (d) => {
525
- state.errors += d.toString().split("\n").filter(Boolean).length;
526
- this.log(svc.name, d.toString(), colorIdx);
825
+ const readyRegex = compileReadyPattern(svc.readyPattern);
826
+ const markReadyIfMatch = (line) => {
827
+ if (!readyRegex || state.health === "up") return;
828
+ if (readyRegex.test(line)) {
829
+ state.health = "up";
830
+ if (state.status === "starting") state.status = "running";
831
+ this.events.onStateChange(svc.name, state);
832
+ }
833
+ };
834
+ const stdoutBuf = lineBuffer((line) => {
835
+ markReadyIfMatch(line);
836
+ this.log(svc.name, line, colorIdx);
837
+ });
838
+ const stderrBuf = lineBuffer((line) => {
839
+ state.errors += 1;
840
+ markReadyIfMatch(line);
841
+ this.log(svc.name, line, colorIdx);
527
842
  });
843
+ proc.stdout?.on("data", (d) => stdoutBuf.push(d));
844
+ proc.stderr?.on("data", (d) => stderrBuf.push(d));
845
+ proc.stdout?.on("end", () => stdoutBuf.flush());
846
+ proc.stderr?.on("end", () => stderrBuf.flush());
528
847
  proc.on("close", (code) => {
529
848
  this.procs.delete(proc);
849
+ this.stopWatchProc(state);
530
850
  if (state.intentionalStop) {
531
851
  state.intentionalStop = false;
532
852
  return;
@@ -550,19 +870,96 @@ var ProcessManager = class {
550
870
  this.log(svc.name, "\u26D4 max restarts reached", colorIdx);
551
871
  }
552
872
  });
873
+ if (svc.watchBuild) {
874
+ state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
875
+ }
553
876
  this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
554
877
  }
878
+ runPreBuild(svc, cwd, colorIdx) {
879
+ this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
880
+ return new Promise((resolve3) => {
881
+ const isWin = process.platform === "win32";
882
+ const shell = isWin ? "cmd.exe" : "sh";
883
+ const shellFlag = isWin ? "/c" : "-c";
884
+ const env = buildProcessEnv(svc, this.env);
885
+ const child = spawn2(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
886
+ const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
887
+ const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
888
+ child.stdout?.on("data", (d) => outBuf.push(d));
889
+ child.stderr?.on("data", (d) => errBuf.push(d));
890
+ child.on("error", (err) => {
891
+ this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
892
+ resolve3(false);
893
+ });
894
+ child.on("close", (code) => {
895
+ outBuf.flush();
896
+ errBuf.flush();
897
+ if (code === 0) {
898
+ this.log(svc.name, `[build] \u2705 done`, colorIdx);
899
+ resolve3(true);
900
+ } else {
901
+ this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
902
+ resolve3(false);
903
+ }
904
+ });
905
+ });
906
+ }
907
+ spawnWatchBuild(svc, cwd, env, colorIdx) {
908
+ this.log(svc.name, `\u{1F440} watchBuild: ${svc.watchBuild}`, colorIdx);
909
+ const isWin = process.platform === "win32";
910
+ const shell = isWin ? "cmd.exe" : "sh";
911
+ const shellFlag = isWin ? "/c" : "-c";
912
+ const child = spawn2(shell, [shellFlag, svc.watchBuild], {
913
+ cwd,
914
+ env,
915
+ detached: true,
916
+ stdio: ["ignore", "pipe", "pipe"]
917
+ });
918
+ const outBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
919
+ const errBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
920
+ child.stdout?.on("data", (d) => outBuf.push(d));
921
+ child.stderr?.on("data", (d) => errBuf.push(d));
922
+ child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
923
+ return child;
924
+ }
925
+ /** Create a state entry in 'crashed' status without spawning a process (used when preBuild fails). */
926
+ recordCrashedState(svc, colorIdx) {
927
+ const prev = this.state.get(svc.name);
928
+ this.state.set(svc.name, {
929
+ svc,
930
+ proc: null,
931
+ pid: null,
932
+ status: "crashed",
933
+ health: "down",
934
+ errors: prev?.errors ?? 0,
935
+ restarts: prev?.restarts ?? 0,
936
+ startedAt: null,
937
+ intentionalStop: false,
938
+ colorIdx
939
+ });
940
+ this.events.onStateChange(svc.name, this.state.get(svc.name));
941
+ }
555
942
  stop(name) {
556
943
  const st = this.state.get(name);
557
944
  if (!st?.proc || !st.pid) return;
558
945
  st.intentionalStop = true;
559
946
  this.platform.killTree(st.pid);
947
+ this.stopWatchProc(st);
948
+ }
949
+ stopWatchProc(state) {
950
+ const wp = state.watchProc;
951
+ if (!wp || !wp.pid) return;
952
+ try {
953
+ this.platform.killTree(wp.pid);
954
+ } catch {
955
+ }
956
+ state.watchProc = null;
560
957
  }
561
958
  async restart(name) {
562
959
  const st = this.state.get(name);
563
960
  if (!st) return;
564
961
  this.stop(name);
565
- st.restarts++;
962
+ st.restarts = 0;
566
963
  const delay = st.proc ? 1500 : 100;
567
964
  await new Promise((r) => setTimeout(r, delay));
568
965
  await this.start(st.svc, st.colorIdx, true);
@@ -574,39 +971,69 @@ var ProcessManager = class {
574
971
  st.health = st.status === "idle" ? "idle" : "down";
575
972
  continue;
576
973
  }
577
- const port = st.svc.port;
578
- const isUp = await checkPort(port);
974
+ const isUp = await checkHealth(st.svc.port, st.svc.healthCheck);
579
975
  const prev = st.health;
580
976
  st.health = deriveHealth(isUp, st.status);
581
977
  if (st.health === "up" && st.status === "starting") st.status = "running";
582
978
  if (prev !== st.health) this.events.onStateChange(name, st);
583
979
  }
584
980
  }
585
- cleanup() {
586
- for (const proc of this.procs) {
981
+ async cleanup(opts = {}) {
982
+ const grace = opts.gracePeriodMs ?? 3e3;
983
+ const procs = [...this.procs];
984
+ if (!procs.length) return;
985
+ for (const proc of procs) {
986
+ const st = this.findStateByProc(proc);
987
+ if (st) {
988
+ st.intentionalStop = true;
989
+ this.stopWatchProc(st);
990
+ }
587
991
  if (proc.pid) this.platform.killTree(proc.pid);
588
992
  }
589
- setTimeout(() => {
590
- for (const proc of this.procs) {
591
- if (proc.pid) this.platform.killTree(proc.pid, "SIGKILL");
993
+ for (const st of this.state.values()) this.stopWatchProc(st);
994
+ const waits = procs.map(
995
+ (p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve3) => p.once("close", () => resolve3()))
996
+ );
997
+ let timedOut = false;
998
+ await Promise.race([
999
+ Promise.all(waits),
1000
+ new Promise((resolve3) => setTimeout(() => {
1001
+ timedOut = true;
1002
+ resolve3();
1003
+ }, grace))
1004
+ ]);
1005
+ if (timedOut) {
1006
+ for (const proc of procs) {
1007
+ if (proc.pid && proc.exitCode === null && proc.signalCode === null) {
1008
+ this.platform.killTree(proc.pid, "SIGKILL");
1009
+ }
592
1010
  }
593
- }, 3e3);
1011
+ await Promise.race([
1012
+ Promise.all(waits),
1013
+ new Promise((resolve3) => setTimeout(resolve3, 1e3))
1014
+ ]);
1015
+ }
1016
+ }
1017
+ findStateByProc(proc) {
1018
+ for (const st of this.state.values()) if (st.proc === proc) return st;
1019
+ return void 0;
594
1020
  }
595
1021
  log(name, text, colorIdx) {
596
1022
  this.events.onLog(name, text, colorIdx);
597
1023
  }
598
- getColorIdx(name) {
599
- return this.state.get(name)?.colorIdx ?? 0;
600
- }
601
1024
  };
602
1025
 
603
1026
  // src/tui/hooks/useProcessManager.ts
604
- function useProcessManager(platform, baseCwd, env) {
1027
+ function useProcessManager(platform, baseCwd, env, logSink = null) {
605
1028
  const [states, setStates] = useState(/* @__PURE__ */ new Map());
606
1029
  const [logs, setLogs] = useState([]);
607
1030
  const [stats, setStats] = useState(/* @__PURE__ */ new Map());
608
1031
  const mgrRef = useRef(null);
609
1032
  const prevCpu = useRef(/* @__PURE__ */ new Map());
1033
+ const pausedRef = useRef(false);
1034
+ const pendingLogsRef = useRef([]);
1035
+ const sinkRef = useRef(logSink);
1036
+ sinkRef.current = logSink;
610
1037
  useEffect(() => {
611
1038
  const mgr2 = new ProcessManager({
612
1039
  baseCwd,
@@ -614,9 +1041,17 @@ function useProcessManager(platform, baseCwd, env) {
614
1041
  platform,
615
1042
  events: {
616
1043
  onLog: (svcName, text, colorIdx) => {
617
- const lines = text.split("\n").filter(Boolean);
1044
+ sinkRef.current?.write(svcName, text);
1045
+ const entry = { svcName, text, colorIdx, ts: Date.now() };
1046
+ if (pausedRef.current) {
1047
+ pendingLogsRef.current.push(entry);
1048
+ if (pendingLogsRef.current.length > 5e3) {
1049
+ pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
1050
+ }
1051
+ return;
1052
+ }
618
1053
  setLogs((prev) => {
619
- const next = [...prev, ...lines.map((l) => ({ svcName, text: l, colorIdx, ts: Date.now() }))];
1054
+ const next = prev.concat(entry);
620
1055
  return next.length > 5e3 ? next.slice(-5e3) : next;
621
1056
  });
622
1057
  },
@@ -659,6 +1094,36 @@ function useProcessManager(platform, baseCwd, env) {
659
1094
  return () => clearInterval(id);
660
1095
  }, [platform]);
661
1096
  const mgr = mgrRef.current;
1097
+ const clearLogs = useCallback(() => {
1098
+ pendingLogsRef.current = [];
1099
+ setLogs([]);
1100
+ }, []);
1101
+ const pushLog = useCallback((svcName, text, colorIdx = 0) => {
1102
+ sinkRef.current?.write(svcName, text);
1103
+ const entry = { svcName, text, colorIdx, ts: Date.now() };
1104
+ if (pausedRef.current) {
1105
+ pendingLogsRef.current.push(entry);
1106
+ if (pendingLogsRef.current.length > 5e3) {
1107
+ pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
1108
+ }
1109
+ return;
1110
+ }
1111
+ setLogs((prev) => {
1112
+ const next = prev.concat(entry);
1113
+ return next.length > 5e3 ? next.slice(-5e3) : next;
1114
+ });
1115
+ }, []);
1116
+ const setPaused = useCallback((paused) => {
1117
+ pausedRef.current = paused;
1118
+ if (!paused && pendingLogsRef.current.length) {
1119
+ const flush = pendingLogsRef.current;
1120
+ pendingLogsRef.current = [];
1121
+ setLogs((prev) => {
1122
+ const next = prev.concat(flush);
1123
+ return next.length > 5e3 ? next.slice(-5e3) : next;
1124
+ });
1125
+ }
1126
+ }, []);
662
1127
  return {
663
1128
  states,
664
1129
  logs,
@@ -666,8 +1131,11 @@ function useProcessManager(platform, baseCwd, env) {
666
1131
  start: useCallback((svc, colorIdx) => mgr?.start(svc, colorIdx), [mgr]),
667
1132
  stop: useCallback((name) => mgr?.stop(name), [mgr]),
668
1133
  restart: useCallback((name) => mgr?.restart(name), [mgr]),
669
- install: useCallback((svc) => mgr?.install(svc), [mgr]),
1134
+ install: useCallback((svc, colorIdx) => mgr?.install(svc, colorIdx), [mgr]),
670
1135
  cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
1136
+ clearLogs,
1137
+ setPaused,
1138
+ pushLog,
671
1139
  manager: mgr
672
1140
  };
673
1141
  }
@@ -676,6 +1144,24 @@ function useProcessManager(platform, baseCwd, env) {
676
1144
  import { useInput } from "ink";
677
1145
  import { useState as useState2, useCallback as useCallback2 } from "react";
678
1146
  var SORT_MODES = ["name", "mem", "errors"];
1147
+ function scrollBy(setState, delta) {
1148
+ setState((s) => {
1149
+ if (s.panel === "logs") {
1150
+ const next2 = s.logsScrollOffset - delta;
1151
+ return { ...s, logsScrollOffset: Math.max(0, next2) };
1152
+ }
1153
+ const next = s.statsScrollOffset + delta;
1154
+ return { ...s, statsScrollOffset: Math.max(0, next) };
1155
+ });
1156
+ }
1157
+ function scrollTo(setState, target) {
1158
+ setState((s) => {
1159
+ if (s.panel === "logs") {
1160
+ return { ...s, logsScrollOffset: target === "top" ? Number.MAX_SAFE_INTEGER : 0 };
1161
+ }
1162
+ return { ...s, statsScrollOffset: target === "top" ? 0 : Number.MAX_SAFE_INTEGER };
1163
+ });
1164
+ }
679
1165
  function useKeyBindings(opts) {
680
1166
  const [state, setState] = useState2({
681
1167
  panel: "logs",
@@ -696,6 +1182,14 @@ function useKeyBindings(opts) {
696
1182
  useInput((input, key) => {
697
1183
  if (state.modal !== "none") return;
698
1184
  if (input === "q" || key.ctrl && input === "c") opts.onQuit();
1185
+ else if (key.ctrl && input === "a") scrollTo(setState, "top");
1186
+ else if (key.ctrl && input === "e") scrollTo(setState, "bottom");
1187
+ else if (key.ctrl && input === "b") scrollBy(setState, -10);
1188
+ else if (key.ctrl && input === "f") scrollBy(setState, 10);
1189
+ else if (key.upArrow) scrollBy(setState, -1);
1190
+ else if (key.downArrow) scrollBy(setState, 1);
1191
+ else if (input === "[") scrollBy(setState, -10);
1192
+ else if (input === "]") scrollBy(setState, 10);
699
1193
  else if (input === "c") opts.onClearLogs();
700
1194
  else if (key.tab) setState((s) => ({ ...s, panel: s.panel === "logs" ? "stats" : "logs" }));
701
1195
  else if (input === "f") setModal("filter");
@@ -709,60 +1203,6 @@ function useKeyBindings(opts) {
709
1203
  else if (input === "T") {
710
1204
  opts.onToggleProxy();
711
1205
  setState((s) => ({ ...s, proxyEnabled: !s.proxyEnabled }));
712
- } else if (key.upArrow) {
713
- setState((s) => {
714
- if (s.panel === "logs") {
715
- return { ...s, logsScrollOffset: Math.max(0, s.logsScrollOffset - 1) };
716
- } else if (s.panel === "stats") {
717
- return { ...s, statsScrollOffset: Math.max(0, s.statsScrollOffset - 1) };
718
- }
719
- return s;
720
- });
721
- } else if (key.downArrow) {
722
- setState((s) => {
723
- if (s.panel === "logs") {
724
- return { ...s, logsScrollOffset: s.logsScrollOffset + 1 };
725
- } else if (s.panel === "stats") {
726
- return { ...s, statsScrollOffset: s.statsScrollOffset + 1 };
727
- }
728
- return s;
729
- });
730
- } else if (input === "[" || key.ctrl && input === "b") {
731
- setState((s) => {
732
- if (s.panel === "logs") {
733
- return { ...s, logsScrollOffset: Math.max(0, s.logsScrollOffset - 10) };
734
- } else if (s.panel === "stats") {
735
- return { ...s, statsScrollOffset: Math.max(0, s.statsScrollOffset - 10) };
736
- }
737
- return s;
738
- });
739
- } else if (input === "]" || key.ctrl && input === "f") {
740
- setState((s) => {
741
- if (s.panel === "logs") {
742
- return { ...s, logsScrollOffset: s.logsScrollOffset + 10 };
743
- } else if (s.panel === "stats") {
744
- return { ...s, statsScrollOffset: s.statsScrollOffset + 10 };
745
- }
746
- return s;
747
- });
748
- } else if (key.ctrl && input === "a") {
749
- setState((s) => {
750
- if (s.panel === "logs") {
751
- return { ...s, logsScrollOffset: 0 };
752
- } else if (s.panel === "stats") {
753
- return { ...s, statsScrollOffset: 0 };
754
- }
755
- return s;
756
- });
757
- } else if (key.ctrl && input === "e") {
758
- setState((s) => {
759
- if (s.panel === "logs") {
760
- return { ...s, logsScrollOffset: Number.MAX_SAFE_INTEGER };
761
- } else if (s.panel === "stats") {
762
- return { ...s, statsScrollOffset: Number.MAX_SAFE_INTEGER };
763
- }
764
- return s;
765
- });
766
1206
  }
767
1207
  }, { isActive });
768
1208
  return {
@@ -780,26 +1220,28 @@ function useKeyBindings(opts) {
780
1220
  // src/tui/hooks/useProxySync.ts
781
1221
  import { useEffect as useEffect2, useRef as useRef2 } from "react";
782
1222
  function useProxySync(provider, opts, states, enabled) {
783
- const intervalRef = useRef2(null);
1223
+ const statesRef = useRef2(states);
1224
+ const lastContentRef = useRef2(null);
1225
+ statesRef.current = states;
784
1226
  useEffect2(() => {
785
- if (!provider || !opts || !enabled) {
786
- if (intervalRef.current) clearInterval(intervalRef.current);
787
- return;
788
- }
1227
+ if (!provider || !opts || !enabled) return;
789
1228
  const sync = () => {
790
1229
  const svcStates = /* @__PURE__ */ new Map();
791
- for (const [name, st] of states) {
1230
+ for (const [name, st] of statesRef.current) {
792
1231
  svcStates.set(name, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
793
1232
  }
794
1233
  const content = provider.generate(svcStates, opts);
1234
+ if (content === lastContentRef.current) return;
1235
+ lastContentRef.current = content;
795
1236
  provider.write(content, opts);
796
1237
  };
797
1238
  sync();
798
- intervalRef.current = setInterval(sync, 3e3);
1239
+ const id = setInterval(sync, 3e3);
799
1240
  return () => {
800
- if (intervalRef.current) clearInterval(intervalRef.current);
1241
+ clearInterval(id);
1242
+ lastContentRef.current = null;
801
1243
  };
802
- }, [provider, opts, enabled, states]);
1244
+ }, [provider, opts, enabled]);
803
1245
  }
804
1246
 
805
1247
  // src/tui/LogsPanel.tsx
@@ -808,27 +1250,25 @@ import { Box, Text } from "ink";
808
1250
  import { jsx, jsxs } from "react/jsx-runtime";
809
1251
  function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll }) {
810
1252
  const filtered = filter ? logs.filter((l) => l.svcName === filter) : logs;
811
- const contentHeight = height - 2;
1253
+ const contentHeight = Math.max(1, height - 2);
812
1254
  const totalLines = filtered.length;
813
- let startIndex = 0;
814
- if (scrollOffset === Number.MAX_SAFE_INTEGER) {
815
- startIndex = Math.max(0, totalLines - contentHeight);
816
- } else if (scrollOffset > 0) {
817
- startIndex = Math.min(scrollOffset, Math.max(0, totalLines - contentHeight));
818
- } else {
819
- startIndex = Math.max(0, totalLines - contentHeight);
820
- }
821
- const visible = filtered.slice(startIndex, startIndex + contentHeight);
1255
+ const maxOffset = Math.max(0, totalLines - contentHeight);
1256
+ const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxOffset : Math.min(scrollOffset, maxOffset);
1257
+ const startIndex = Math.max(0, totalLines - contentHeight - effectiveOffset);
1258
+ const endIndex = Math.min(startIndex + contentHeight, totalLines);
1259
+ const visible = filtered.slice(startIndex, endIndex);
822
1260
  useEffect3(() => {
823
1261
  resetScroll();
824
1262
  }, [filter, searchTerm, resetScroll]);
1263
+ const scrolled = effectiveOffset > 0;
825
1264
  const label = [
826
1265
  "Logs",
827
1266
  filter ? `[${filter}]` : "",
828
1267
  searchTerm ? `/${searchTerm}` : "",
829
1268
  paused ? "[PAUSED]" : "",
1269
+ scrolled ? "[SCROLL]" : "",
830
1270
  `${filtered.length} lines`,
831
- focused ? `(${startIndex + 1}-${Math.min(startIndex + contentHeight, totalLines)}/${totalLines})` : ""
1271
+ focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
832
1272
  ].filter(Boolean).join(" ");
833
1273
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "cyan" : "gray", height, children: [
834
1274
  /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
@@ -931,33 +1371,29 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
931
1371
  }
932
1372
  const stackMem = totalMemMB >= 1024 ? (totalMemMB / 1024).toFixed(2) + " GB" : totalMemMB.toFixed(1) + " MB";
933
1373
  const ml = maxNameLen;
934
- const contentHeight = height - 2;
935
- const maxApiRows = Math.max(0, apis.length - (contentHeight - 2));
936
- const maxWebRows = Math.max(0, webs.length - (contentHeight - 2));
937
- let apiStartIndex = 0;
938
- let webStartIndex = 0;
939
- if (scrollOffset === Number.MAX_SAFE_INTEGER) {
940
- apiStartIndex = maxApiRows;
941
- webStartIndex = maxWebRows;
942
- } else if (scrollOffset > 0) {
943
- apiStartIndex = Math.min(scrollOffset, maxApiRows);
944
- webStartIndex = Math.min(scrollOffset, maxWebRows);
945
- }
946
- const visibleApis = apis.slice(apiStartIndex, apiStartIndex + contentHeight - 2);
947
- const visibleWebs = webs.slice(webStartIndex, webStartIndex + contentHeight - 2);
1374
+ const contentHeight = Math.max(1, height - 2);
1375
+ const rowsPerCol = Math.max(1, contentHeight - 2);
1376
+ const maxRows = Math.max(0, Math.max(apis.length, webs.length) - rowsPerCol);
1377
+ const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxRows : Math.min(scrollOffset, maxRows);
1378
+ const apiStartIndex = Math.min(effectiveOffset, Math.max(0, apis.length - rowsPerCol));
1379
+ const webStartIndex = Math.min(effectiveOffset, Math.max(0, webs.length - rowsPerCol));
1380
+ const visibleApis = apis.slice(apiStartIndex, apiStartIndex + rowsPerCol);
1381
+ const visibleWebs = webs.slice(webStartIndex, webStartIndex + rowsPerCol);
948
1382
  useEffect4(() => {
949
1383
  resetScroll();
950
1384
  }, [sortMode, resetScroll]);
951
- const totalServices = apis.length + webs.length;
952
- const positionInfo = focused ? `(${Math.max(apiStartIndex, webStartIndex) + 1}-${Math.max(apiStartIndex, webStartIndex) + contentHeight - 2}/${totalServices})` : "";
1385
+ const totalRowsLong = Math.max(apis.length, webs.length);
1386
+ const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
1387
+ const scrolled = effectiveOffset > 0;
953
1388
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
954
1389
  /* @__PURE__ */ jsxs2(Box2, { children: [
955
1390
  /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
956
1391
  " Stats ",
957
1392
  positionInfo
958
1393
  ] }),
1394
+ scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
959
1395
  /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
960
- "System: ",
1396
+ " System: ",
961
1397
  cpus,
962
1398
  "c Load ",
963
1399
  load,
@@ -1097,59 +1533,58 @@ function SearchInput({ onSubmit, onClose }) {
1097
1533
  ] });
1098
1534
  }
1099
1535
 
1100
- // src/lazy/classifier.ts
1101
- var LAZY_PORT_OFFSET = 1e4;
1102
- function classifyServices(services, config) {
1103
- const alwaysOnSet = new Set(config?.alwaysOn ?? []);
1104
- const alwaysOn = [];
1105
- const lazy = [];
1106
- for (const svc of services) {
1107
- if (alwaysOnSet.has(svc.name)) alwaysOn.push(svc);
1108
- else lazy.push(svc);
1109
- }
1110
- return { alwaysOn, lazy };
1111
- }
1112
- function getLazyRealPort(originalPort) {
1113
- return originalPort + LAZY_PORT_OFFSET;
1114
- }
1115
- function rewriteServicePort(svc) {
1116
- const realPort = getLazyRealPort(svc.port);
1117
- const args = svc.args.map((a) => a === String(svc.port) ? String(realPort) : a);
1118
- const extraEnv = { ...svc.extraEnv, PORT_OVERRIDE: String(realPort) };
1119
- return { ...svc, port: realPort, args, extraEnv, realPort, originalPort: svc.port };
1120
- }
1121
-
1122
1536
  // src/lazy/proxy.ts
1123
1537
  import net2 from "net";
1124
1538
  function createLazyProxy(opts) {
1125
1539
  const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
1126
1540
  let idleTimer = null;
1541
+ let lastActivity = Date.now();
1127
1542
  let starting = false;
1128
1543
  let serviceReady = false;
1129
1544
  let pendingConns = [];
1130
- function resetTimer() {
1545
+ const activeConns = /* @__PURE__ */ new Set();
1546
+ function bumpActivity() {
1547
+ lastActivity = Date.now();
1548
+ }
1549
+ function scheduleIdleCheck() {
1131
1550
  if (idleTimer) clearTimeout(idleTimer);
1132
- if (timeoutMin > 0) {
1133
- idleTimer = setTimeout(() => {
1134
- serviceReady = false;
1135
- onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
1136
- onIdleStop();
1137
- }, timeoutMin * 6e4);
1138
- }
1551
+ if (timeoutMin <= 0) return;
1552
+ const periodMs = timeoutMin * 6e4;
1553
+ idleTimer = setTimeout(() => {
1554
+ const elapsed = Date.now() - lastActivity;
1555
+ if (activeConns.size > 0 || elapsed < periodMs) {
1556
+ scheduleIdleCheck();
1557
+ return;
1558
+ }
1559
+ serviceReady = false;
1560
+ onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
1561
+ onIdleStop();
1562
+ }, periodMs);
1139
1563
  }
1140
1564
  function pipeToTarget(client) {
1141
1565
  const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
1566
+ activeConns.add(client);
1567
+ const cleanup = () => {
1568
+ activeConns.delete(client);
1569
+ bumpActivity();
1570
+ };
1142
1571
  target.on("error", () => {
1143
1572
  client.destroy();
1573
+ cleanup();
1144
1574
  });
1145
1575
  client.on("error", () => {
1146
1576
  target.destroy();
1577
+ cleanup();
1147
1578
  });
1579
+ client.on("close", cleanup);
1580
+ target.on("close", cleanup);
1148
1581
  target.on("connect", () => {
1149
1582
  target.on("data", (chunk) => {
1583
+ bumpActivity();
1150
1584
  if (!client.destroyed) client.write(chunk);
1151
1585
  });
1152
1586
  client.on("data", (chunk) => {
1587
+ bumpActivity();
1153
1588
  if (!target.destroyed) target.write(chunk);
1154
1589
  });
1155
1590
  target.on("end", () => {
@@ -1161,7 +1596,7 @@ function createLazyProxy(opts) {
1161
1596
  });
1162
1597
  }
1163
1598
  async function handleConnection(client) {
1164
- resetTimer();
1599
+ bumpActivity();
1165
1600
  client.on("error", () => {
1166
1601
  });
1167
1602
  if (serviceReady && isAlive()) {
@@ -1175,9 +1610,10 @@ function createLazyProxy(opts) {
1175
1610
  if (starting) return;
1176
1611
  starting = true;
1177
1612
  onLog?.("\u26A1 on-demand start");
1613
+ let ok = false;
1178
1614
  try {
1179
1615
  await onDemandStart();
1180
- const ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1616
+ ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1181
1617
  if (ok) serviceReady = true;
1182
1618
  else onLog?.("\u26A0 timeout waiting for service");
1183
1619
  } catch (e) {
@@ -1185,46 +1621,156 @@ function createLazyProxy(opts) {
1185
1621
  }
1186
1622
  starting = false;
1187
1623
  const conns = pendingConns.splice(0);
1624
+ if (!ok) {
1625
+ for (const conn of conns) {
1626
+ if (!conn.destroyed) conn.destroy();
1627
+ }
1628
+ return;
1629
+ }
1188
1630
  for (const conn of conns) {
1189
1631
  if (!conn.destroyed) pipeToTarget(conn);
1190
1632
  }
1191
1633
  }
1192
1634
  const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
1193
1635
  server.listen(listenPort, "0.0.0.0");
1194
- resetTimer();
1636
+ scheduleIdleCheck();
1195
1637
  return {
1196
1638
  server,
1197
- resetTimer,
1639
+ resetTimer: bumpActivity,
1198
1640
  destroy: () => {
1199
1641
  if (idleTimer) clearTimeout(idleTimer);
1200
1642
  pendingConns.forEach((s) => s.destroy());
1643
+ activeConns.forEach((s) => s.destroy());
1201
1644
  server.close();
1202
1645
  }
1203
1646
  };
1204
1647
  }
1205
1648
 
1649
+ // src/process/external.ts
1650
+ import { spawn as spawn3 } from "child_process";
1651
+ import { join as join4 } from "path";
1652
+ var DEFAULT_START_TIMEOUT_S = 60;
1653
+ async function startExternals(externals, opts) {
1654
+ const procs = [];
1655
+ const failed = [];
1656
+ for (const svc of externals) {
1657
+ const proc = spawnExternal(svc, opts);
1658
+ procs.push({ svc, proc, pid: proc.pid ?? null });
1659
+ if (!svc.healthCheck) {
1660
+ opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
1661
+ continue;
1662
+ }
1663
+ if (svc.healthCheck.type === "tcp" && !svc.port) {
1664
+ opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
1665
+ continue;
1666
+ }
1667
+ const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
1668
+ const ok = await waitHealthy(svc, timeoutMs);
1669
+ if (ok) {
1670
+ opts.onLog?.(svc.name, "\u2705 healthy");
1671
+ } else {
1672
+ opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
1673
+ failed.push(svc.name);
1674
+ }
1675
+ }
1676
+ return { procs, allHealthy: failed.length === 0, failed };
1677
+ }
1678
+ async function stopExternals(procs, platform, opts = {}) {
1679
+ for (const { svc, proc, pid } of procs) {
1680
+ try {
1681
+ if (pid) platform.killTree(pid);
1682
+ if (svc.stopCmd) {
1683
+ opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
1684
+ await new Promise((resolve3) => {
1685
+ const isWin = process.platform === "win32";
1686
+ const shell = isWin ? "cmd.exe" : "sh";
1687
+ const flag = isWin ? "/c" : "-c";
1688
+ const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
1689
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1690
+ const child = spawn3(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
1691
+ child.on("close", () => resolve3());
1692
+ child.on("error", () => resolve3());
1693
+ setTimeout(() => resolve3(), 1e4);
1694
+ });
1695
+ }
1696
+ } catch {
1697
+ }
1698
+ void proc;
1699
+ }
1700
+ }
1701
+ function spawnExternal(svc, opts) {
1702
+ const isWin = process.platform === "win32";
1703
+ const shell = isWin ? "cmd.exe" : "sh";
1704
+ const flag = isWin ? "/c" : "-c";
1705
+ const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
1706
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1707
+ opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
1708
+ const child = spawn3(shell, [flag, svc.cmd], {
1709
+ cwd,
1710
+ env,
1711
+ detached: true,
1712
+ stdio: ["ignore", "pipe", "pipe"]
1713
+ });
1714
+ child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
1715
+ child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
1716
+ child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
1717
+ return child;
1718
+ }
1719
+ async function waitHealthy(svc, timeoutMs) {
1720
+ const deadline = Date.now() + timeoutMs;
1721
+ const port = svc.port;
1722
+ while (Date.now() < deadline) {
1723
+ if (await checkHealth(port, svc.healthCheck)) return true;
1724
+ await new Promise((r) => setTimeout(r, 500));
1725
+ }
1726
+ return false;
1727
+ }
1728
+
1206
1729
  // src/tui/App.tsx
1207
1730
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1208
- function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts }) {
1731
+ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
1209
1732
  const { stdout } = useStdout();
1210
- const rows = stdout?.rows ?? 40;
1733
+ const [rows, setRows] = useState5(stdout?.rows ?? 40);
1734
+ useEffect5(() => {
1735
+ if (!stdout) return;
1736
+ const onResize = () => setRows(stdout.rows ?? 40);
1737
+ stdout.on("resize", onResize);
1738
+ return () => {
1739
+ stdout.off("resize", onResize);
1740
+ };
1741
+ }, [stdout]);
1211
1742
  const logsHeight = Math.floor(rows * 0.65);
1212
1743
  const statsHeight = rows - logsHeight - 2;
1213
1744
  const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
1214
- const pm = useProcessManager(platform, baseCwd, env);
1745
+ const pm = useProcessManager(platform, baseCwd, env, logSink);
1215
1746
  const [booted, setBooted] = useState5(false);
1216
1747
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
1748
+ const externals = useRef3([]);
1217
1749
  const kb = useKeyBindings({
1218
1750
  onQuit: () => {
1219
- lazyProxies.current.forEach((p) => p.destroy());
1220
- pm.cleanup();
1221
- process.exit(0);
1222
- },
1223
- onClearLogs: () => {
1751
+ void shutdown();
1224
1752
  },
1753
+ onClearLogs: pm.clearLogs,
1225
1754
  onToggleProxy: () => {
1226
1755
  }
1227
1756
  });
1757
+ const shutdown = useCallback3(async () => {
1758
+ lazyProxies.current.forEach((p) => p.destroy());
1759
+ await pm.cleanup();
1760
+ if (externals.current.length) {
1761
+ await stopExternals(externals.current, platform, {
1762
+ baseCwd,
1763
+ env,
1764
+ onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
1765
+ });
1766
+ externals.current = [];
1767
+ }
1768
+ await logSink?.close();
1769
+ process.exit(0);
1770
+ }, [pm, logSink, platform, baseCwd, env]);
1771
+ useEffect5(() => {
1772
+ pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
1773
+ }, [kb.logsPaused, kb.logsScrollOffset, pm]);
1228
1774
  useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
1229
1775
  useEffect5(() => {
1230
1776
  if (booted || !pm.manager) return;
@@ -1233,6 +1779,19 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1233
1779
  (async () => {
1234
1780
  const lazyMode = cliArgs.lazy;
1235
1781
  const lazyTimeout = cliArgs.lazyTimeout;
1782
+ if (config.external?.length) {
1783
+ const result = await startExternals(config.external, {
1784
+ baseCwd,
1785
+ env,
1786
+ platform,
1787
+ onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
1788
+ });
1789
+ externals.current = result.procs;
1790
+ if (!result.allHealthy) {
1791
+ pm.pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
1792
+ return;
1793
+ }
1794
+ }
1236
1795
  if (lazyMode && config.lazy) {
1237
1796
  const { alwaysOn, lazy } = classifyServices(services, config.lazy);
1238
1797
  const aoPhases = groupByPhase(alwaysOn);
@@ -1240,8 +1799,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1240
1799
  for (const num of Object.keys(aoPhases).map(Number).sort((a, b) => a - b)) {
1241
1800
  const svcs = aoPhases[num];
1242
1801
  for (const svc of svcs) {
1243
- await mgr.install(svc);
1244
- await mgr.start(svc, colorIdx++);
1802
+ const ci = colorIdx++;
1803
+ await mgr.install(svc, ci);
1804
+ await mgr.start(svc, ci);
1245
1805
  }
1246
1806
  const apis = svcs.filter((s) => s.type === "api");
1247
1807
  if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
@@ -1271,7 +1831,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1271
1831
  targetPort: rewritten.realPort,
1272
1832
  timeoutMin: lazyTimeout,
1273
1833
  onDemandStart: async () => {
1274
- await mgr.install(rewritten);
1834
+ await mgr.install(rewritten, ci);
1275
1835
  await mgr.start(rewritten, ci);
1276
1836
  const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
1277
1837
  const st = mgr.state.get(svc.name);
@@ -1304,8 +1864,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1304
1864
  for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
1305
1865
  const svcs = phases[num];
1306
1866
  for (const svc of svcs) {
1307
- await mgr.install(svc);
1308
- await mgr.start(svc, colorIdx++);
1867
+ const ci = colorIdx++;
1868
+ await mgr.install(svc, ci);
1869
+ await mgr.start(svc, ci);
1309
1870
  }
1310
1871
  const apis = svcs.filter((s) => s.type === "api");
1311
1872
  if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
@@ -1377,6 +1938,222 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1377
1938
  ] });
1378
1939
  }
1379
1940
 
1941
+ // src/process/log-sink.ts
1942
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
1943
+ import { join as join5, dirname as dirname4 } from "path";
1944
+ import { homedir } from "os";
1945
+ var LogSink = class {
1946
+ dir;
1947
+ rotateOnStart;
1948
+ streams = /* @__PURE__ */ new Map();
1949
+ seen = /* @__PURE__ */ new Set();
1950
+ constructor(opts) {
1951
+ const root = opts.rootDir ?? join5(homedir(), ".devup", "logs");
1952
+ this.dir = join5(root, sanitize(opts.projectName));
1953
+ this.rotateOnStart = opts.rotateOnStart ?? true;
1954
+ mkdirSync4(this.dir, { recursive: true });
1955
+ }
1956
+ /** Returns the file path for a service log (useful for tests / UI). */
1957
+ pathFor(svcName) {
1958
+ return join5(this.dir, `${sanitize(svcName)}.log`);
1959
+ }
1960
+ write(svcName, line) {
1961
+ const stream = this.streamFor(svcName);
1962
+ stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1963
+ `);
1964
+ }
1965
+ async close() {
1966
+ const closes = [...this.streams.values()].map(
1967
+ (s) => new Promise((r) => s.end(() => r()))
1968
+ );
1969
+ this.streams.clear();
1970
+ this.seen.clear();
1971
+ await Promise.all(closes);
1972
+ }
1973
+ streamFor(svcName) {
1974
+ let s = this.streams.get(svcName);
1975
+ if (s) return s;
1976
+ const file = this.pathFor(svcName);
1977
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync8(file)) {
1978
+ try {
1979
+ mkdirSync4(dirname4(file), { recursive: true });
1980
+ renameSync(file, file + ".prev");
1981
+ } catch {
1982
+ }
1983
+ }
1984
+ this.seen.add(svcName);
1985
+ s = createWriteStream(file, { flags: "a" });
1986
+ s.on("error", () => {
1987
+ });
1988
+ this.streams.set(svcName, s);
1989
+ return s;
1990
+ }
1991
+ };
1992
+ function sanitize(name) {
1993
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
1994
+ }
1995
+
1996
+ // src/orchestrator/dry-run.ts
1997
+ function renderDryRun(opts) {
1998
+ const { config, services, cliArgs, env, proxyProvider, proxyOpts } = opts;
1999
+ const lines = [];
2000
+ lines.push(`Project: ${config.icon ?? "\u{1F4E6}"} ${config.name}`);
2001
+ lines.push(`Mode: ${cliArgs.lazy && config.lazy ? "lazy" : "normal"}`);
2002
+ if (cliArgs.profile) lines.push(`Profile: ${cliArgs.profile}`);
2003
+ lines.push(`Services: ${services.length}`);
2004
+ lines.push("");
2005
+ if (config.external?.length) {
2006
+ lines.push(`Externals (${config.external.length}):`);
2007
+ for (const ext of config.external) {
2008
+ const hc = ext.healthCheck;
2009
+ const hcTag = hc ? ` health=${hc.type}${hc.type === "http" ? " " + (hc.path ?? "/") : ""} :${ext.port ?? "?"}` : "";
2010
+ lines.push(` - ${ext.name.padEnd(20)} ${ext.cmd}${hcTag}`);
2011
+ }
2012
+ lines.push("");
2013
+ }
2014
+ const lazyMode = cliArgs.lazy && !!config.lazy;
2015
+ let alwaysOn = services;
2016
+ let lazy = [];
2017
+ if (lazyMode) {
2018
+ const c = classifyServices(services, config.lazy);
2019
+ alwaysOn = c.alwaysOn;
2020
+ lazy = c.lazy;
2021
+ }
2022
+ const phases = groupByPhase(alwaysOn);
2023
+ const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
2024
+ for (const num of phaseNums) {
2025
+ lines.push(`Phase ${num}:`);
2026
+ for (const svc of phases[num]) {
2027
+ lines.push(formatService(svc, env, false));
2028
+ }
2029
+ }
2030
+ if (lazy.length) {
2031
+ lines.push("");
2032
+ lines.push("Lazy (on-demand):");
2033
+ for (const svc of lazy) {
2034
+ const rewritten = rewriteServicePort(svc);
2035
+ lines.push(formatService(rewritten, env, true));
2036
+ lines.push(` proxy :${svc.port} \u2192 :${getLazyRealPort(svc.port)} (idle timeout ${cliArgs.lazyTimeout}m)`);
2037
+ }
2038
+ }
2039
+ if (proxyProvider && proxyOpts) {
2040
+ lines.push("");
2041
+ lines.push(`Proxy: ${proxyProvider.name} \u2192 ${proxyOpts.confPath}`);
2042
+ const svcStates = /* @__PURE__ */ new Map();
2043
+ for (const svc of services) {
2044
+ const real = lazyMode && !alwaysOn.includes(svc) ? getLazyRealPort(svc.port) : void 0;
2045
+ svcStates.set(svc.name, { port: svc.port, health: "up", realPort: real });
2046
+ }
2047
+ const content = proxyProvider.generate(svcStates, proxyOpts);
2048
+ lines.push("");
2049
+ lines.push("--- generated config ---");
2050
+ lines.push(content);
2051
+ }
2052
+ return lines.join("\n");
2053
+ }
2054
+ function formatService(svc, env, isLazy) {
2055
+ const args = buildProcessArgs(svc);
2056
+ const cmdLine = [svc.cmd, ...args].join(" ");
2057
+ const built = buildProcessEnv(svc, env);
2058
+ const extraEnv = Object.keys(svc.extraEnv ?? {}).length ? " env=" + Object.entries(svc.extraEnv).map(([k, v]) => `${k}=${v}`).join(" ") : "";
2059
+ const memTag = svc.maxMem ? ` mem=${svc.maxMem}MB` : "";
2060
+ const hc = svc.healthCheck;
2061
+ const hcTag = hc?.type === "http" ? ` health=http ${hc.path ?? "/"}` : "";
2062
+ const lazyTag = isLazy ? " [lazy]" : "";
2063
+ void built;
2064
+ return ` - ${svc.name.padEnd(20)} (${svc.type}) :${svc.port} ${cmdLine}${memTag}${hcTag}${lazyTag}${extraEnv}`;
2065
+ }
2066
+ function runDryRun(opts) {
2067
+ console.log(renderDryRun(opts));
2068
+ }
2069
+
2070
+ // src/orchestrator/once.ts
2071
+ async function runOnce(opts) {
2072
+ const out = opts.out ?? ((l) => console.log(l));
2073
+ const { config, services, cliArgs, platform, env, baseCwd, logSink } = opts;
2074
+ const mgr = new ProcessManager({
2075
+ baseCwd,
2076
+ env,
2077
+ platform,
2078
+ events: {
2079
+ onLog: (svc, text) => {
2080
+ logSink?.write(svc, text);
2081
+ out(`[${svc}] ${text}`);
2082
+ },
2083
+ onStateChange: () => {
2084
+ }
2085
+ }
2086
+ });
2087
+ let externals = [];
2088
+ if (config.external?.length) {
2089
+ out(`\u25B6 externals (${config.external.length})`);
2090
+ const result = await startExternals(config.external, {
2091
+ baseCwd,
2092
+ env,
2093
+ platform,
2094
+ onLog: (svc, msg) => {
2095
+ logSink?.write(`ext:${svc}`, msg);
2096
+ out(`[ext:${svc}] ${msg}`);
2097
+ }
2098
+ });
2099
+ externals = result.procs;
2100
+ if (!result.allHealthy) {
2101
+ out(`\u2717 externals failed: ${result.failed.join(", ")}`);
2102
+ await stopExternals(externals, platform, { baseCwd, env });
2103
+ await mgr.cleanup();
2104
+ return 1;
2105
+ }
2106
+ }
2107
+ const phases = groupByPhase(services);
2108
+ const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
2109
+ const apiNames = services.filter((s) => s.type === "api").map((s) => s.name);
2110
+ const deadline = Date.now() + cliArgs.onceTimeout * 1e3;
2111
+ let colorIdx = 0;
2112
+ for (const num of phaseNums) {
2113
+ out(`\u25B6 phase ${num}`);
2114
+ for (const svc of phases[num]) {
2115
+ const ci = colorIdx++;
2116
+ const installed = await mgr.install(svc, ci);
2117
+ if (!installed) {
2118
+ out(`\u2717 install failed for ${svc.name}`);
2119
+ await mgr.cleanup();
2120
+ await stopExternals(externals, platform, { baseCwd, env });
2121
+ return 1;
2122
+ }
2123
+ await mgr.start(svc, ci);
2124
+ }
2125
+ const apis = phases[num].filter((s) => s.type === "api");
2126
+ for (const api of apis) {
2127
+ const ok = await waitHealthy2(api, deadline);
2128
+ if (!ok) {
2129
+ out(`\u2717 ${api.name} did not become healthy within ${cliArgs.onceTimeout}s`);
2130
+ await mgr.cleanup();
2131
+ await stopExternals(externals, platform, { baseCwd, env });
2132
+ return 1;
2133
+ }
2134
+ out(`\u2713 ${api.name} ready`);
2135
+ const st = mgr.state.get(api.name);
2136
+ if (st) {
2137
+ st.status = "running";
2138
+ st.health = "up";
2139
+ }
2140
+ }
2141
+ }
2142
+ const summary = `ready: ${apiNames.length} APIs in ${((cliArgs.onceTimeout * 1e3 - (deadline - Date.now())) / 1e3).toFixed(1)}s`;
2143
+ out(summary);
2144
+ await mgr.cleanup();
2145
+ await stopExternals(externals, platform, { baseCwd, env });
2146
+ return 0;
2147
+ }
2148
+ async function waitHealthy2(svc, deadline) {
2149
+ while (Date.now() < deadline) {
2150
+ const ok = await checkHealth(svc.port, svc.healthCheck);
2151
+ if (ok) return true;
2152
+ await new Promise((r) => setTimeout(r, 500));
2153
+ }
2154
+ return false;
2155
+ }
2156
+
1380
2157
  // src/config/types.ts
1381
2158
  function defineConfig(config) {
1382
2159
  return config;
@@ -1400,13 +2177,19 @@ async function main() {
1400
2177
  ${formatValidationErrors(errors)}`);
1401
2178
  process.exit(1);
1402
2179
  }
1403
- const services = filterServices(config.services, cliArgs);
2180
+ let services;
2181
+ try {
2182
+ services = filterServices(config.services, cliArgs, config);
2183
+ } catch (e) {
2184
+ console.error(`\u274C ${e.message}`);
2185
+ process.exit(1);
2186
+ }
1404
2187
  if (!services.length) {
1405
2188
  console.error("\u274C No services to run after filtering");
1406
2189
  process.exit(1);
1407
2190
  }
1408
2191
  const platform = await detectPlatform();
1409
- const envFile = config.envFile ? join4(cwd, config.envFile) : join4(cwd, ".env");
2192
+ const envFile = config.envFile ? join6(cwd, config.envFile) : join6(cwd, ".env");
1410
2193
  const env = parseEnvFile(envFile, process.env);
1411
2194
  if (config.env) {
1412
2195
  for (const [k, v] of Object.entries(config.env)) {
@@ -1423,12 +2206,33 @@ ${formatValidationErrors(errors)}`);
1423
2206
  routes: config.proxy.routes,
1424
2207
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
1425
2208
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
1426
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join4(homedir(), ".traefik", "traefik_conf.yaml")
2209
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join6(homedir2(), ".traefik", "traefik_conf.yaml")
1427
2210
  };
1428
2211
  }
2212
+ if (cliArgs.dryRun) {
2213
+ runDryRun({ config, services, cliArgs, env, baseCwd: cwd, proxyProvider, proxyOpts });
2214
+ return;
2215
+ }
2216
+ let logSink = null;
2217
+ if (cliArgs.logFile) {
2218
+ logSink = new LogSink({ projectName: config.name, rootDir: cliArgs.logDir });
2219
+ }
2220
+ if (cliArgs.once) {
2221
+ const code = await runOnce({
2222
+ config,
2223
+ services,
2224
+ cliArgs,
2225
+ platform,
2226
+ env,
2227
+ baseCwd: cwd,
2228
+ logSink
2229
+ });
2230
+ await logSink?.close();
2231
+ process.exit(code);
2232
+ }
1429
2233
  const isInteractive = process.stdin.isTTY ?? false;
1430
2234
  const { waitUntilExit } = render(
1431
- React6.createElement(App, {
2235
+ React7.createElement(App, {
1432
2236
  config,
1433
2237
  services,
1434
2238
  cliArgs,
@@ -1436,7 +2240,8 @@ ${formatValidationErrors(errors)}`);
1436
2240
  env,
1437
2241
  baseCwd: cwd,
1438
2242
  proxyProvider,
1439
- proxyOpts
2243
+ proxyOpts,
2244
+ logSink
1440
2245
  }),
1441
2246
  { exitOnCtrlC: false, patchConsole: isInteractive, interactive: isInteractive }
1442
2247
  );