@gachlab/devup 0.1.1 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +87 -11
  3. package/dist/config/cli.d.ts +5 -0
  4. package/dist/config/cli.d.ts.map +1 -1
  5. package/dist/config/types.d.ts +13 -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 +642 -179
  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/health.d.ts +10 -1
  17. package/dist/process/health.d.ts.map +1 -1
  18. package/dist/process/installer.d.ts +0 -10
  19. package/dist/process/installer.d.ts.map +1 -1
  20. package/dist/process/log-sink.d.ts +23 -0
  21. package/dist/process/log-sink.d.ts.map +1 -0
  22. package/dist/process/manager.d.ts +5 -3
  23. package/dist/process/manager.d.ts.map +1 -1
  24. package/dist/proxy-config/caddy.d.ts +10 -0
  25. package/dist/proxy-config/caddy.d.ts.map +1 -0
  26. package/dist/proxy-config/detect.d.ts.map +1 -1
  27. package/dist/proxy-config/nginx.d.ts +10 -0
  28. package/dist/proxy-config/nginx.d.ts.map +1 -0
  29. package/dist/tui/App.d.ts +3 -1
  30. package/dist/tui/App.d.ts.map +1 -1
  31. package/dist/tui/LogsPanel.d.ts.map +1 -1
  32. package/dist/tui/StatsPanel.d.ts.map +1 -1
  33. package/dist/tui/hooks/useKeyBindings.d.ts.map +1 -1
  34. package/dist/tui/hooks/useProcessManager.d.ts +6 -3
  35. package/dist/tui/hooks/useProcessManager.d.ts.map +1 -1
  36. package/dist/tui/hooks/useProxySync.d.ts.map +1 -1
  37. package/dist/utils.d.ts +0 -5
  38. package/dist/utils.d.ts.map +1 -1
  39. 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 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 existsSync4, readFileSync, writeFileSync as writeFileSync2 } from "fs";
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 (!existsSync4(filePath)) return env;
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 (!existsSync4(nm)) return true;
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 (existsSync4(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
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
- writeFileSync2(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
485
+ writeFileSync4(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
329
486
  } catch {
330
487
  }
331
488
  }
@@ -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
- function checkPort(port, host = "127.0.0.1") {
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(2e3);
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 existsSync5 } from "fs";
631
+ import { existsSync as existsSync7 } from "fs";
441
632
  function installService(cwd, env, onLog) {
442
- if (!existsSync5(cwd)) {
633
+ if (!existsSync7(cwd)) {
443
634
  onLog?.(`\u26A0 directory not found: ${cwd}`);
444
635
  return Promise.resolve(false);
445
636
  }
@@ -475,6 +666,26 @@ function installService(cwd, env, onLog) {
475
666
  // src/process/manager.ts
476
667
  var MAX_RESTARTS = 3;
477
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
+ }
478
689
  var ProcessManager = class {
479
690
  state = /* @__PURE__ */ new Map();
480
691
  procs = /* @__PURE__ */ new Set();
@@ -488,9 +699,10 @@ var ProcessManager = class {
488
699
  this.platform = opts.platform;
489
700
  this.events = opts.events;
490
701
  }
491
- async install(svc) {
702
+ async install(svc, colorIdx) {
492
703
  const cwd = join3(this.baseCwd, svc.cwd);
493
- return installService(cwd, this.env, (msg) => this.log(svc.name, msg, this.getColorIdx(svc.name)));
704
+ const idx = colorIdx ?? this.state.get(svc.name)?.colorIdx ?? 0;
705
+ return installService(cwd, this.env, (msg) => this.log(svc.name, msg, idx));
494
706
  }
495
707
  async start(svc, colorIdx, isRestart = false) {
496
708
  const cwd = join3(this.baseCwd, svc.cwd);
@@ -520,11 +732,15 @@ var ProcessManager = class {
520
732
  this.state.set(svc.name, state);
521
733
  this.procs.add(proc);
522
734
  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);
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);
527
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());
528
744
  proc.on("close", (code) => {
529
745
  this.procs.delete(proc);
530
746
  if (state.intentionalStop) {
@@ -562,7 +778,7 @@ var ProcessManager = class {
562
778
  const st = this.state.get(name);
563
779
  if (!st) return;
564
780
  this.stop(name);
565
- st.restarts++;
781
+ st.restarts = 0;
566
782
  const delay = st.proc ? 1500 : 100;
567
783
  await new Promise((r) => setTimeout(r, delay));
568
784
  await this.start(st.svc, st.colorIdx, true);
@@ -574,39 +790,65 @@ var ProcessManager = class {
574
790
  st.health = st.status === "idle" ? "idle" : "down";
575
791
  continue;
576
792
  }
577
- const port = st.svc.port;
578
- const isUp = await checkPort(port);
793
+ const isUp = await checkHealth(st.svc.port, st.svc.healthCheck);
579
794
  const prev = st.health;
580
795
  st.health = deriveHealth(isUp, st.status);
581
796
  if (st.health === "up" && st.status === "starting") st.status = "running";
582
797
  if (prev !== st.health) this.events.onStateChange(name, st);
583
798
  }
584
799
  }
585
- cleanup() {
586
- for (const proc of this.procs) {
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;
587
807
  if (proc.pid) this.platform.killTree(proc.pid);
588
808
  }
589
- setTimeout(() => {
590
- for (const proc of this.procs) {
591
- if (proc.pid) this.platform.killTree(proc.pid, "SIGKILL");
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
+ }
592
825
  }
593
- }, 3e3);
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;
594
835
  }
595
836
  log(name, text, colorIdx) {
596
837
  this.events.onLog(name, text, colorIdx);
597
838
  }
598
- getColorIdx(name) {
599
- return this.state.get(name)?.colorIdx ?? 0;
600
- }
601
839
  };
602
840
 
603
841
  // src/tui/hooks/useProcessManager.ts
604
- function useProcessManager(platform, baseCwd, env) {
842
+ function useProcessManager(platform, baseCwd, env, logSink = null) {
605
843
  const [states, setStates] = useState(/* @__PURE__ */ new Map());
606
844
  const [logs, setLogs] = useState([]);
607
845
  const [stats, setStats] = useState(/* @__PURE__ */ new Map());
608
846
  const mgrRef = useRef(null);
609
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;
610
852
  useEffect(() => {
611
853
  const mgr2 = new ProcessManager({
612
854
  baseCwd,
@@ -614,9 +856,17 @@ function useProcessManager(platform, baseCwd, env) {
614
856
  platform,
615
857
  events: {
616
858
  onLog: (svcName, text, colorIdx) => {
617
- const lines = text.split("\n").filter(Boolean);
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
+ }
618
868
  setLogs((prev) => {
619
- const next = [...prev, ...lines.map((l) => ({ svcName, text: l, colorIdx, ts: Date.now() }))];
869
+ const next = prev.concat(entry);
620
870
  return next.length > 5e3 ? next.slice(-5e3) : next;
621
871
  });
622
872
  },
@@ -659,6 +909,21 @@ function useProcessManager(platform, baseCwd, env) {
659
909
  return () => clearInterval(id);
660
910
  }, [platform]);
661
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
+ }, []);
662
927
  return {
663
928
  states,
664
929
  logs,
@@ -666,8 +931,10 @@ function useProcessManager(platform, baseCwd, env) {
666
931
  start: useCallback((svc, colorIdx) => mgr?.start(svc, colorIdx), [mgr]),
667
932
  stop: useCallback((name) => mgr?.stop(name), [mgr]),
668
933
  restart: useCallback((name) => mgr?.restart(name), [mgr]),
669
- install: useCallback((svc) => mgr?.install(svc), [mgr]),
934
+ install: useCallback((svc, colorIdx) => mgr?.install(svc, colorIdx), [mgr]),
670
935
  cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
936
+ clearLogs,
937
+ setPaused,
671
938
  manager: mgr
672
939
  };
673
940
  }
@@ -676,6 +943,24 @@ function useProcessManager(platform, baseCwd, env) {
676
943
  import { useInput } from "ink";
677
944
  import { useState as useState2, useCallback as useCallback2 } from "react";
678
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
+ }
679
964
  function useKeyBindings(opts) {
680
965
  const [state, setState] = useState2({
681
966
  panel: "logs",
@@ -696,6 +981,14 @@ function useKeyBindings(opts) {
696
981
  useInput((input, key) => {
697
982
  if (state.modal !== "none") return;
698
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);
699
992
  else if (input === "c") opts.onClearLogs();
700
993
  else if (key.tab) setState((s) => ({ ...s, panel: s.panel === "logs" ? "stats" : "logs" }));
701
994
  else if (input === "f") setModal("filter");
@@ -709,60 +1002,6 @@ function useKeyBindings(opts) {
709
1002
  else if (input === "T") {
710
1003
  opts.onToggleProxy();
711
1004
  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
1005
  }
767
1006
  }, { isActive });
768
1007
  return {
@@ -780,26 +1019,28 @@ function useKeyBindings(opts) {
780
1019
  // src/tui/hooks/useProxySync.ts
781
1020
  import { useEffect as useEffect2, useRef as useRef2 } from "react";
782
1021
  function useProxySync(provider, opts, states, enabled) {
783
- const intervalRef = useRef2(null);
1022
+ const statesRef = useRef2(states);
1023
+ const lastContentRef = useRef2(null);
1024
+ statesRef.current = states;
784
1025
  useEffect2(() => {
785
- if (!provider || !opts || !enabled) {
786
- if (intervalRef.current) clearInterval(intervalRef.current);
787
- return;
788
- }
1026
+ if (!provider || !opts || !enabled) return;
789
1027
  const sync = () => {
790
1028
  const svcStates = /* @__PURE__ */ new Map();
791
- for (const [name, st] of states) {
1029
+ for (const [name, st] of statesRef.current) {
792
1030
  svcStates.set(name, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
793
1031
  }
794
1032
  const content = provider.generate(svcStates, opts);
1033
+ if (content === lastContentRef.current) return;
1034
+ lastContentRef.current = content;
795
1035
  provider.write(content, opts);
796
1036
  };
797
1037
  sync();
798
- intervalRef.current = setInterval(sync, 3e3);
1038
+ const id = setInterval(sync, 3e3);
799
1039
  return () => {
800
- if (intervalRef.current) clearInterval(intervalRef.current);
1040
+ clearInterval(id);
1041
+ lastContentRef.current = null;
801
1042
  };
802
- }, [provider, opts, enabled, states]);
1043
+ }, [provider, opts, enabled]);
803
1044
  }
804
1045
 
805
1046
  // src/tui/LogsPanel.tsx
@@ -808,27 +1049,25 @@ import { Box, Text } from "ink";
808
1049
  import { jsx, jsxs } from "react/jsx-runtime";
809
1050
  function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll }) {
810
1051
  const filtered = filter ? logs.filter((l) => l.svcName === filter) : logs;
811
- const contentHeight = height - 2;
1052
+ const contentHeight = Math.max(1, height - 2);
812
1053
  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);
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);
822
1059
  useEffect3(() => {
823
1060
  resetScroll();
824
1061
  }, [filter, searchTerm, resetScroll]);
1062
+ const scrolled = effectiveOffset > 0;
825
1063
  const label = [
826
1064
  "Logs",
827
1065
  filter ? `[${filter}]` : "",
828
1066
  searchTerm ? `/${searchTerm}` : "",
829
1067
  paused ? "[PAUSED]" : "",
1068
+ scrolled ? "[SCROLL]" : "",
830
1069
  `${filtered.length} lines`,
831
- focused ? `(${startIndex + 1}-${Math.min(startIndex + contentHeight, totalLines)}/${totalLines})` : ""
1070
+ focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
832
1071
  ].filter(Boolean).join(" ");
833
1072
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "cyan" : "gray", height, children: [
834
1073
  /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
@@ -931,33 +1170,29 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
931
1170
  }
932
1171
  const stackMem = totalMemMB >= 1024 ? (totalMemMB / 1024).toFixed(2) + " GB" : totalMemMB.toFixed(1) + " MB";
933
1172
  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);
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);
948
1181
  useEffect4(() => {
949
1182
  resetScroll();
950
1183
  }, [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})` : "";
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;
953
1187
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
954
1188
  /* @__PURE__ */ jsxs2(Box2, { children: [
955
1189
  /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
956
1190
  " Stats ",
957
1191
  positionInfo
958
1192
  ] }),
1193
+ scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
959
1194
  /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
960
- "System: ",
1195
+ " System: ",
961
1196
  cpus,
962
1197
  "c Load ",
963
1198
  load,
@@ -1097,59 +1332,58 @@ function SearchInput({ onSubmit, onClose }) {
1097
1332
  ] });
1098
1333
  }
1099
1334
 
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
1335
  // src/lazy/proxy.ts
1123
1336
  import net2 from "net";
1124
1337
  function createLazyProxy(opts) {
1125
1338
  const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
1126
1339
  let idleTimer = null;
1340
+ let lastActivity = Date.now();
1127
1341
  let starting = false;
1128
1342
  let serviceReady = false;
1129
1343
  let pendingConns = [];
1130
- function resetTimer() {
1344
+ const activeConns = /* @__PURE__ */ new Set();
1345
+ function bumpActivity() {
1346
+ lastActivity = Date.now();
1347
+ }
1348
+ function scheduleIdleCheck() {
1131
1349
  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
- }
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);
1139
1362
  }
1140
1363
  function pipeToTarget(client) {
1141
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
+ };
1142
1370
  target.on("error", () => {
1143
1371
  client.destroy();
1372
+ cleanup();
1144
1373
  });
1145
1374
  client.on("error", () => {
1146
1375
  target.destroy();
1376
+ cleanup();
1147
1377
  });
1378
+ client.on("close", cleanup);
1379
+ target.on("close", cleanup);
1148
1380
  target.on("connect", () => {
1149
1381
  target.on("data", (chunk) => {
1382
+ bumpActivity();
1150
1383
  if (!client.destroyed) client.write(chunk);
1151
1384
  });
1152
1385
  client.on("data", (chunk) => {
1386
+ bumpActivity();
1153
1387
  if (!target.destroyed) target.write(chunk);
1154
1388
  });
1155
1389
  target.on("end", () => {
@@ -1161,7 +1395,7 @@ function createLazyProxy(opts) {
1161
1395
  });
1162
1396
  }
1163
1397
  async function handleConnection(client) {
1164
- resetTimer();
1398
+ bumpActivity();
1165
1399
  client.on("error", () => {
1166
1400
  });
1167
1401
  if (serviceReady && isAlive()) {
@@ -1175,9 +1409,10 @@ function createLazyProxy(opts) {
1175
1409
  if (starting) return;
1176
1410
  starting = true;
1177
1411
  onLog?.("\u26A1 on-demand start");
1412
+ let ok = false;
1178
1413
  try {
1179
1414
  await onDemandStart();
1180
- const ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1415
+ ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1181
1416
  if (ok) serviceReady = true;
1182
1417
  else onLog?.("\u26A0 timeout waiting for service");
1183
1418
  } catch (e) {
@@ -1185,19 +1420,26 @@ function createLazyProxy(opts) {
1185
1420
  }
1186
1421
  starting = false;
1187
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
+ }
1188
1429
  for (const conn of conns) {
1189
1430
  if (!conn.destroyed) pipeToTarget(conn);
1190
1431
  }
1191
1432
  }
1192
1433
  const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
1193
1434
  server.listen(listenPort, "0.0.0.0");
1194
- resetTimer();
1435
+ scheduleIdleCheck();
1195
1436
  return {
1196
1437
  server,
1197
- resetTimer,
1438
+ resetTimer: bumpActivity,
1198
1439
  destroy: () => {
1199
1440
  if (idleTimer) clearTimeout(idleTimer);
1200
1441
  pendingConns.forEach((s) => s.destroy());
1442
+ activeConns.forEach((s) => s.destroy());
1201
1443
  server.close();
1202
1444
  }
1203
1445
  };
@@ -1205,26 +1447,40 @@ function createLazyProxy(opts) {
1205
1447
 
1206
1448
  // src/tui/App.tsx
1207
1449
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1208
- function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts }) {
1450
+ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
1209
1451
  const { stdout } = useStdout();
1210
- 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]);
1211
1461
  const logsHeight = Math.floor(rows * 0.65);
1212
1462
  const statsHeight = rows - logsHeight - 2;
1213
1463
  const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
1214
- const pm = useProcessManager(platform, baseCwd, env);
1464
+ const pm = useProcessManager(platform, baseCwd, env, logSink);
1215
1465
  const [booted, setBooted] = useState5(false);
1216
1466
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
1217
1467
  const kb = useKeyBindings({
1218
1468
  onQuit: () => {
1219
- lazyProxies.current.forEach((p) => p.destroy());
1220
- pm.cleanup();
1221
- process.exit(0);
1222
- },
1223
- onClearLogs: () => {
1469
+ void shutdown();
1224
1470
  },
1471
+ onClearLogs: pm.clearLogs,
1225
1472
  onToggleProxy: () => {
1226
1473
  }
1227
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]);
1228
1484
  useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
1229
1485
  useEffect5(() => {
1230
1486
  if (booted || !pm.manager) return;
@@ -1240,8 +1496,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1240
1496
  for (const num of Object.keys(aoPhases).map(Number).sort((a, b) => a - b)) {
1241
1497
  const svcs = aoPhases[num];
1242
1498
  for (const svc of svcs) {
1243
- await mgr.install(svc);
1244
- await mgr.start(svc, colorIdx++);
1499
+ const ci = colorIdx++;
1500
+ await mgr.install(svc, ci);
1501
+ await mgr.start(svc, ci);
1245
1502
  }
1246
1503
  const apis = svcs.filter((s) => s.type === "api");
1247
1504
  if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
@@ -1271,7 +1528,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1271
1528
  targetPort: rewritten.realPort,
1272
1529
  timeoutMin: lazyTimeout,
1273
1530
  onDemandStart: async () => {
1274
- await mgr.install(rewritten);
1531
+ await mgr.install(rewritten, ci);
1275
1532
  await mgr.start(rewritten, ci);
1276
1533
  const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
1277
1534
  const st = mgr.state.get(svc.name);
@@ -1304,8 +1561,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1304
1561
  for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
1305
1562
  const svcs = phases[num];
1306
1563
  for (const svc of svcs) {
1307
- await mgr.install(svc);
1308
- await mgr.start(svc, colorIdx++);
1564
+ const ci = colorIdx++;
1565
+ await mgr.install(svc, ci);
1566
+ await mgr.start(svc, ci);
1309
1567
  }
1310
1568
  const apis = svcs.filter((s) => s.type === "api");
1311
1569
  if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
@@ -1377,6 +1635,189 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1377
1635
  ] });
1378
1636
  }
1379
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
+
1380
1821
  // src/config/types.ts
1381
1822
  function defineConfig(config) {
1382
1823
  return config;
@@ -1406,7 +1847,7 @@ ${formatValidationErrors(errors)}`);
1406
1847
  process.exit(1);
1407
1848
  }
1408
1849
  const platform = await detectPlatform();
1409
- const envFile = config.envFile ? join4(cwd, config.envFile) : join4(cwd, ".env");
1850
+ const envFile = config.envFile ? join5(cwd, config.envFile) : join5(cwd, ".env");
1410
1851
  const env = parseEnvFile(envFile, process.env);
1411
1852
  if (config.env) {
1412
1853
  for (const [k, v] of Object.entries(config.env)) {
@@ -1423,12 +1864,33 @@ ${formatValidationErrors(errors)}`);
1423
1864
  routes: config.proxy.routes,
1424
1865
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
1425
1866
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
1426
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join4(homedir(), ".traefik", "traefik_conf.yaml")
1867
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join5(homedir2(), ".traefik", "traefik_conf.yaml")
1427
1868
  };
1428
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
+ }
1429
1891
  const isInteractive = process.stdin.isTTY ?? false;
1430
1892
  const { waitUntilExit } = render(
1431
- React6.createElement(App, {
1893
+ React7.createElement(App, {
1432
1894
  config,
1433
1895
  services,
1434
1896
  cliArgs,
@@ -1436,7 +1898,8 @@ ${formatValidationErrors(errors)}`);
1436
1898
  env,
1437
1899
  baseCwd: cwd,
1438
1900
  proxyProvider,
1439
- proxyOpts
1901
+ proxyOpts,
1902
+ logSink
1440
1903
  }),
1441
1904
  { exitOnCtrlC: false, patchConsole: isInteractive, interactive: isInteractive }
1442
1905
  );