@gachlab/devup 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) 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 +693 -114
  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 +3 -1
  32. package/dist/tui/LogsPanel.d.ts.map +1 -1
  33. package/dist/tui/StatsPanel.d.ts +3 -1
  34. package/dist/tui/StatsPanel.d.ts.map +1 -1
  35. package/dist/tui/hooks/useKeyBindings.d.ts +6 -0
  36. package/dist/tui/hooks/useKeyBindings.d.ts.map +1 -1
  37. package/dist/tui/hooks/useProcessManager.d.ts +6 -3
  38. package/dist/tui/hooks/useProcessManager.d.ts.map +1 -1
  39. package/dist/tui/hooks/useProxySync.d.ts.map +1 -1
  40. package/dist/utils.d.ts +0 -5
  41. package/dist/utils.d.ts.map +1 -1
  42. package/package.json +19 -8
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import React4 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
  }
@@ -384,7 +541,7 @@ var tagColors = [
384
541
  ];
385
542
 
386
543
  // src/tui/App.tsx
387
- import { useEffect as useEffect3, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
544
+ import { useEffect as useEffect5, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
388
545
  import { Box as Box6, Text as Text6, useStdout } from "ink";
389
546
 
390
547
  // src/tui/hooks/useProcessManager.ts
@@ -396,10 +553,11 @@ import { join as join3 } from "path";
396
553
 
397
554
  // src/process/health.ts
398
555
  import net from "net";
399
- 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
  }
@@ -449,7 +640,8 @@ function installService(cwd, env, onLog) {
449
640
  }
450
641
  onLog?.("\u{1F4E6} npm install...");
451
642
  return new Promise((resolve3) => {
452
- const proc = spawn("npm", ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
643
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
644
+ const proc = spawn(command, ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
453
645
  let stderr = "";
454
646
  proc.stderr?.on("data", (d) => {
455
647
  stderr += d.toString();
@@ -464,12 +656,36 @@ function installService(cwd, env, onLog) {
464
656
  resolve3(true);
465
657
  }
466
658
  });
659
+ proc.on("error", (err) => {
660
+ onLog?.(`\u26A0 spawn error: ${err.message}`);
661
+ resolve3(false);
662
+ });
467
663
  });
468
664
  }
469
665
 
470
666
  // src/process/manager.ts
471
667
  var MAX_RESTARTS = 3;
472
668
  var BACKOFF_BASE_MS = 2e3;
669
+ function lineBuffer(onLine) {
670
+ let buf = "";
671
+ return {
672
+ push(chunk) {
673
+ buf += chunk.toString();
674
+ let idx;
675
+ while ((idx = buf.indexOf("\n")) !== -1) {
676
+ const line = buf.slice(0, idx).replace(/\r$/, "");
677
+ buf = buf.slice(idx + 1);
678
+ if (line.length) onLine(line);
679
+ }
680
+ },
681
+ flush() {
682
+ if (buf.length) {
683
+ onLine(buf);
684
+ buf = "";
685
+ }
686
+ }
687
+ };
688
+ }
473
689
  var ProcessManager = class {
474
690
  state = /* @__PURE__ */ new Map();
475
691
  procs = /* @__PURE__ */ new Set();
@@ -483,9 +699,10 @@ var ProcessManager = class {
483
699
  this.platform = opts.platform;
484
700
  this.events = opts.events;
485
701
  }
486
- async install(svc) {
702
+ async install(svc, colorIdx) {
487
703
  const cwd = join3(this.baseCwd, svc.cwd);
488
- 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));
489
706
  }
490
707
  async start(svc, colorIdx, isRestart = false) {
491
708
  const cwd = join3(this.baseCwd, svc.cwd);
@@ -515,11 +732,15 @@ var ProcessManager = class {
515
732
  this.state.set(svc.name, state);
516
733
  this.procs.add(proc);
517
734
  this.events.onStateChange(svc.name, state);
518
- proc.stdout?.on("data", (d) => this.log(svc.name, d.toString(), colorIdx));
519
- proc.stderr?.on("data", (d) => {
520
- state.errors += d.toString().split("\n").filter(Boolean).length;
521
- 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);
522
739
  });
740
+ proc.stdout?.on("data", (d) => stdoutBuf.push(d));
741
+ proc.stderr?.on("data", (d) => stderrBuf.push(d));
742
+ proc.stdout?.on("end", () => stdoutBuf.flush());
743
+ proc.stderr?.on("end", () => stderrBuf.flush());
523
744
  proc.on("close", (code) => {
524
745
  this.procs.delete(proc);
525
746
  if (state.intentionalStop) {
@@ -557,7 +778,7 @@ var ProcessManager = class {
557
778
  const st = this.state.get(name);
558
779
  if (!st) return;
559
780
  this.stop(name);
560
- st.restarts++;
781
+ st.restarts = 0;
561
782
  const delay = st.proc ? 1500 : 100;
562
783
  await new Promise((r) => setTimeout(r, delay));
563
784
  await this.start(st.svc, st.colorIdx, true);
@@ -569,39 +790,65 @@ var ProcessManager = class {
569
790
  st.health = st.status === "idle" ? "idle" : "down";
570
791
  continue;
571
792
  }
572
- const port = st.svc.port;
573
- const isUp = await checkPort(port);
793
+ const isUp = await checkHealth(st.svc.port, st.svc.healthCheck);
574
794
  const prev = st.health;
575
795
  st.health = deriveHealth(isUp, st.status);
576
796
  if (st.health === "up" && st.status === "starting") st.status = "running";
577
797
  if (prev !== st.health) this.events.onStateChange(name, st);
578
798
  }
579
799
  }
580
- cleanup() {
581
- 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;
582
807
  if (proc.pid) this.platform.killTree(proc.pid);
583
808
  }
584
- setTimeout(() => {
585
- for (const proc of this.procs) {
586
- 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
+ }
587
825
  }
588
- }, 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;
589
835
  }
590
836
  log(name, text, colorIdx) {
591
837
  this.events.onLog(name, text, colorIdx);
592
838
  }
593
- getColorIdx(name) {
594
- return this.state.get(name)?.colorIdx ?? 0;
595
- }
596
839
  };
597
840
 
598
841
  // src/tui/hooks/useProcessManager.ts
599
- function useProcessManager(platform, baseCwd, env) {
842
+ function useProcessManager(platform, baseCwd, env, logSink = null) {
600
843
  const [states, setStates] = useState(/* @__PURE__ */ new Map());
601
844
  const [logs, setLogs] = useState([]);
602
845
  const [stats, setStats] = useState(/* @__PURE__ */ new Map());
603
846
  const mgrRef = useRef(null);
604
847
  const prevCpu = useRef(/* @__PURE__ */ new Map());
848
+ const pausedRef = useRef(false);
849
+ const pendingLogsRef = useRef([]);
850
+ const sinkRef = useRef(logSink);
851
+ sinkRef.current = logSink;
605
852
  useEffect(() => {
606
853
  const mgr2 = new ProcessManager({
607
854
  baseCwd,
@@ -609,9 +856,17 @@ function useProcessManager(platform, baseCwd, env) {
609
856
  platform,
610
857
  events: {
611
858
  onLog: (svcName, text, colorIdx) => {
612
- 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
+ }
613
868
  setLogs((prev) => {
614
- const next = [...prev, ...lines.map((l) => ({ svcName, text: l, colorIdx, ts: Date.now() }))];
869
+ const next = prev.concat(entry);
615
870
  return next.length > 5e3 ? next.slice(-5e3) : next;
616
871
  });
617
872
  },
@@ -654,6 +909,21 @@ function useProcessManager(platform, baseCwd, env) {
654
909
  return () => clearInterval(id);
655
910
  }, [platform]);
656
911
  const mgr = mgrRef.current;
912
+ const clearLogs = useCallback(() => {
913
+ pendingLogsRef.current = [];
914
+ setLogs([]);
915
+ }, []);
916
+ const setPaused = useCallback((paused) => {
917
+ pausedRef.current = paused;
918
+ if (!paused && pendingLogsRef.current.length) {
919
+ const flush = pendingLogsRef.current;
920
+ pendingLogsRef.current = [];
921
+ setLogs((prev) => {
922
+ const next = prev.concat(flush);
923
+ return next.length > 5e3 ? next.slice(-5e3) : next;
924
+ });
925
+ }
926
+ }, []);
657
927
  return {
658
928
  states,
659
929
  logs,
@@ -661,8 +931,10 @@ function useProcessManager(platform, baseCwd, env) {
661
931
  start: useCallback((svc, colorIdx) => mgr?.start(svc, colorIdx), [mgr]),
662
932
  stop: useCallback((name) => mgr?.stop(name), [mgr]),
663
933
  restart: useCallback((name) => mgr?.restart(name), [mgr]),
664
- install: useCallback((svc) => mgr?.install(svc), [mgr]),
934
+ install: useCallback((svc, colorIdx) => mgr?.install(svc, colorIdx), [mgr]),
665
935
  cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
936
+ clearLogs,
937
+ setPaused,
666
938
  manager: mgr
667
939
  };
668
940
  }
@@ -671,6 +943,24 @@ function useProcessManager(platform, baseCwd, env) {
671
943
  import { useInput } from "ink";
672
944
  import { useState as useState2, useCallback as useCallback2 } from "react";
673
945
  var SORT_MODES = ["name", "mem", "errors"];
946
+ function scrollBy(setState, delta) {
947
+ setState((s) => {
948
+ if (s.panel === "logs") {
949
+ const next2 = s.logsScrollOffset - delta;
950
+ return { ...s, logsScrollOffset: Math.max(0, next2) };
951
+ }
952
+ const next = s.statsScrollOffset + delta;
953
+ return { ...s, statsScrollOffset: Math.max(0, next) };
954
+ });
955
+ }
956
+ function scrollTo(setState, target) {
957
+ setState((s) => {
958
+ if (s.panel === "logs") {
959
+ return { ...s, logsScrollOffset: target === "top" ? Number.MAX_SAFE_INTEGER : 0 };
960
+ }
961
+ return { ...s, statsScrollOffset: target === "top" ? 0 : Number.MAX_SAFE_INTEGER };
962
+ });
963
+ }
674
964
  function useKeyBindings(opts) {
675
965
  const [state, setState] = useState2({
676
966
  panel: "logs",
@@ -680,7 +970,9 @@ function useKeyBindings(opts) {
680
970
  logsPaused: false,
681
971
  showTimestamps: false,
682
972
  sortIdx: 0,
683
- proxyEnabled: false
973
+ proxyEnabled: false,
974
+ logsScrollOffset: 0,
975
+ statsScrollOffset: 0
684
976
  });
685
977
  const setModal = useCallback2((modal) => setState((s) => ({ ...s, modal })), []);
686
978
  const setFilter = useCallback2((f) => setState((s) => ({ ...s, logFilter: f, modal: "none" })), []);
@@ -689,6 +981,14 @@ function useKeyBindings(opts) {
689
981
  useInput((input, key) => {
690
982
  if (state.modal !== "none") return;
691
983
  if (input === "q" || key.ctrl && input === "c") opts.onQuit();
984
+ else if (key.ctrl && input === "a") scrollTo(setState, "top");
985
+ else if (key.ctrl && input === "e") scrollTo(setState, "bottom");
986
+ else if (key.ctrl && input === "b") scrollBy(setState, -10);
987
+ else if (key.ctrl && input === "f") scrollBy(setState, 10);
988
+ else if (key.upArrow) scrollBy(setState, -1);
989
+ else if (key.downArrow) scrollBy(setState, 1);
990
+ else if (input === "[") scrollBy(setState, -10);
991
+ else if (input === "]") scrollBy(setState, 10);
692
992
  else if (input === "c") opts.onClearLogs();
693
993
  else if (key.tab) setState((s) => ({ ...s, panel: s.panel === "logs" ? "stats" : "logs" }));
694
994
  else if (input === "f") setModal("filter");
@@ -704,47 +1004,70 @@ function useKeyBindings(opts) {
704
1004
  setState((s) => ({ ...s, proxyEnabled: !s.proxyEnabled }));
705
1005
  }
706
1006
  }, { isActive });
707
- return { ...state, setModal, setFilter, setSearch, sortMode: SORT_MODES[state.sortIdx] };
1007
+ return {
1008
+ ...state,
1009
+ setModal,
1010
+ setFilter,
1011
+ setSearch,
1012
+ sortMode: SORT_MODES[state.sortIdx],
1013
+ // Funciones para resetear el scroll cuando cambia el contenido
1014
+ resetLogsScroll: useCallback2(() => setState((s) => ({ ...s, logsScrollOffset: 0 })), []),
1015
+ resetStatsScroll: useCallback2(() => setState((s) => ({ ...s, statsScrollOffset: 0 })), [])
1016
+ };
708
1017
  }
709
1018
 
710
1019
  // src/tui/hooks/useProxySync.ts
711
1020
  import { useEffect as useEffect2, useRef as useRef2 } from "react";
712
1021
  function useProxySync(provider, opts, states, enabled) {
713
- const intervalRef = useRef2(null);
1022
+ const statesRef = useRef2(states);
1023
+ const lastContentRef = useRef2(null);
1024
+ statesRef.current = states;
714
1025
  useEffect2(() => {
715
- if (!provider || !opts || !enabled) {
716
- if (intervalRef.current) clearInterval(intervalRef.current);
717
- return;
718
- }
1026
+ if (!provider || !opts || !enabled) return;
719
1027
  const sync = () => {
720
1028
  const svcStates = /* @__PURE__ */ new Map();
721
- for (const [name, st] of states) {
1029
+ for (const [name, st] of statesRef.current) {
722
1030
  svcStates.set(name, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
723
1031
  }
724
1032
  const content = provider.generate(svcStates, opts);
1033
+ if (content === lastContentRef.current) return;
1034
+ lastContentRef.current = content;
725
1035
  provider.write(content, opts);
726
1036
  };
727
1037
  sync();
728
- intervalRef.current = setInterval(sync, 3e3);
1038
+ const id = setInterval(sync, 3e3);
729
1039
  return () => {
730
- if (intervalRef.current) clearInterval(intervalRef.current);
1040
+ clearInterval(id);
1041
+ lastContentRef.current = null;
731
1042
  };
732
- }, [provider, opts, enabled, states]);
1043
+ }, [provider, opts, enabled]);
733
1044
  }
734
1045
 
735
1046
  // src/tui/LogsPanel.tsx
1047
+ import { useEffect as useEffect3 } from "react";
736
1048
  import { Box, Text } from "ink";
737
1049
  import { jsx, jsxs } from "react/jsx-runtime";
738
- function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused }) {
1050
+ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll }) {
739
1051
  const filtered = filter ? logs.filter((l) => l.svcName === filter) : logs;
740
- const contentHeight = height - 2;
741
- const visible = filtered.slice(-contentHeight);
1052
+ const contentHeight = Math.max(1, height - 2);
1053
+ const totalLines = filtered.length;
1054
+ const maxOffset = Math.max(0, totalLines - contentHeight);
1055
+ const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxOffset : Math.min(scrollOffset, maxOffset);
1056
+ const startIndex = Math.max(0, totalLines - contentHeight - effectiveOffset);
1057
+ const endIndex = Math.min(startIndex + contentHeight, totalLines);
1058
+ const visible = filtered.slice(startIndex, endIndex);
1059
+ useEffect3(() => {
1060
+ resetScroll();
1061
+ }, [filter, searchTerm, resetScroll]);
1062
+ const scrolled = effectiveOffset > 0;
742
1063
  const label = [
743
1064
  "Logs",
744
1065
  filter ? `[${filter}]` : "",
745
1066
  searchTerm ? `/${searchTerm}` : "",
746
1067
  paused ? "[PAUSED]" : "",
747
- `${filtered.length} lines`
1068
+ scrolled ? "[SCROLL]" : "",
1069
+ `${filtered.length} lines`,
1070
+ focused && totalLines > 0 ? `(${startIndex + 1}-${endIndex}/${totalLines})` : ""
748
1071
  ].filter(Boolean).join(" ");
749
1072
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "cyan" : "gray", height, children: [
750
1073
  /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
@@ -772,6 +1095,7 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
772
1095
  }
773
1096
 
774
1097
  // src/tui/StatsPanel.tsx
1098
+ import { useEffect as useEffect4 } from "react";
775
1099
  import { Box as Box2, Text as Text2 } from "ink";
776
1100
  import os from "os";
777
1101
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -822,7 +1146,7 @@ function ColHeader({ ml }) {
822
1146
  "Up".padStart(6)
823
1147
  ] });
824
1148
  }
825
- function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
1149
+ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scrollOffset, resetScroll }) {
826
1150
  const names = [...states.keys()];
827
1151
  const stObj = Object.fromEntries([...states].map(([k, v]) => [k, { errors: v.errors }]));
828
1152
  const statsObj = Object.fromEntries([...stats].map(([k, v]) => [k, v]));
@@ -846,12 +1170,29 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
846
1170
  }
847
1171
  const stackMem = totalMemMB >= 1024 ? (totalMemMB / 1024).toFixed(2) + " GB" : totalMemMB.toFixed(1) + " MB";
848
1172
  const ml = maxNameLen;
849
- const contentHeight = height - 2;
1173
+ const contentHeight = Math.max(1, height - 2);
1174
+ const rowsPerCol = Math.max(1, contentHeight - 2);
1175
+ const maxRows = Math.max(0, Math.max(apis.length, webs.length) - rowsPerCol);
1176
+ const effectiveOffset = scrollOffset === Number.MAX_SAFE_INTEGER ? maxRows : Math.min(scrollOffset, maxRows);
1177
+ const apiStartIndex = Math.min(effectiveOffset, Math.max(0, apis.length - rowsPerCol));
1178
+ const webStartIndex = Math.min(effectiveOffset, Math.max(0, webs.length - rowsPerCol));
1179
+ const visibleApis = apis.slice(apiStartIndex, apiStartIndex + rowsPerCol);
1180
+ const visibleWebs = webs.slice(webStartIndex, webStartIndex + rowsPerCol);
1181
+ useEffect4(() => {
1182
+ resetScroll();
1183
+ }, [sortMode, resetScroll]);
1184
+ const totalRowsLong = Math.max(apis.length, webs.length);
1185
+ const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
1186
+ const scrolled = effectiveOffset > 0;
850
1187
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
851
1188
  /* @__PURE__ */ jsxs2(Box2, { children: [
852
- /* @__PURE__ */ jsx2(Text2, { bold: true, color: "green", children: " Stats " }),
1189
+ /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
1190
+ " Stats ",
1191
+ positionInfo
1192
+ ] }),
1193
+ scrolled && /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: " [SCROLL]" }),
853
1194
  /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
854
- "System: ",
1195
+ " System: ",
855
1196
  cpus,
856
1197
  "c Load ",
857
1198
  load,
@@ -887,7 +1228,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
887
1228
  ")"
888
1229
  ] }),
889
1230
  /* @__PURE__ */ jsx2(ColHeader, { ml }),
890
- apis.slice(0, contentHeight - 2).map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
1231
+ visibleApis.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
891
1232
  ] }),
892
1233
  /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2502" }, i)) }),
893
1234
  /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
@@ -897,7 +1238,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
897
1238
  ")"
898
1239
  ] }),
899
1240
  /* @__PURE__ */ jsx2(ColHeader, { ml }),
900
- webs.slice(0, contentHeight - 2).map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
1241
+ visibleWebs.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
901
1242
  ] })
902
1243
  ] })
903
1244
  ] });
@@ -912,6 +1253,12 @@ function StatusBar() {
912
1253
  " Quit ",
913
1254
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Tab" }),
914
1255
  " Switch ",
1256
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u2191\u2193" }),
1257
+ " Scroll ",
1258
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "PgUp/PgDn" }),
1259
+ " Page ",
1260
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Ctrl+A/E" }),
1261
+ " Home/End ",
915
1262
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "c" }),
916
1263
  " Clear ",
917
1264
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
@@ -985,59 +1332,58 @@ function SearchInput({ onSubmit, onClose }) {
985
1332
  ] });
986
1333
  }
987
1334
 
988
- // src/lazy/classifier.ts
989
- var LAZY_PORT_OFFSET = 1e4;
990
- function classifyServices(services, config) {
991
- const alwaysOnSet = new Set(config?.alwaysOn ?? []);
992
- const alwaysOn = [];
993
- const lazy = [];
994
- for (const svc of services) {
995
- if (alwaysOnSet.has(svc.name)) alwaysOn.push(svc);
996
- else lazy.push(svc);
997
- }
998
- return { alwaysOn, lazy };
999
- }
1000
- function getLazyRealPort(originalPort) {
1001
- return originalPort + LAZY_PORT_OFFSET;
1002
- }
1003
- function rewriteServicePort(svc) {
1004
- const realPort = getLazyRealPort(svc.port);
1005
- const args = svc.args.map((a) => a === String(svc.port) ? String(realPort) : a);
1006
- const extraEnv = { ...svc.extraEnv, PORT_OVERRIDE: String(realPort) };
1007
- return { ...svc, port: realPort, args, extraEnv, realPort, originalPort: svc.port };
1008
- }
1009
-
1010
1335
  // src/lazy/proxy.ts
1011
1336
  import net2 from "net";
1012
1337
  function createLazyProxy(opts) {
1013
1338
  const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
1014
1339
  let idleTimer = null;
1340
+ let lastActivity = Date.now();
1015
1341
  let starting = false;
1016
1342
  let serviceReady = false;
1017
1343
  let pendingConns = [];
1018
- function resetTimer() {
1344
+ const activeConns = /* @__PURE__ */ new Set();
1345
+ function bumpActivity() {
1346
+ lastActivity = Date.now();
1347
+ }
1348
+ function scheduleIdleCheck() {
1019
1349
  if (idleTimer) clearTimeout(idleTimer);
1020
- if (timeoutMin > 0) {
1021
- idleTimer = setTimeout(() => {
1022
- serviceReady = false;
1023
- onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
1024
- onIdleStop();
1025
- }, timeoutMin * 6e4);
1026
- }
1350
+ if (timeoutMin <= 0) return;
1351
+ const periodMs = timeoutMin * 6e4;
1352
+ idleTimer = setTimeout(() => {
1353
+ const elapsed = Date.now() - lastActivity;
1354
+ if (activeConns.size > 0 || elapsed < periodMs) {
1355
+ scheduleIdleCheck();
1356
+ return;
1357
+ }
1358
+ serviceReady = false;
1359
+ onLog?.(`\u{1F4A4} idle ${timeoutMin}min \u2014 stopping`);
1360
+ onIdleStop();
1361
+ }, periodMs);
1027
1362
  }
1028
1363
  function pipeToTarget(client) {
1029
1364
  const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
1365
+ activeConns.add(client);
1366
+ const cleanup = () => {
1367
+ activeConns.delete(client);
1368
+ bumpActivity();
1369
+ };
1030
1370
  target.on("error", () => {
1031
1371
  client.destroy();
1372
+ cleanup();
1032
1373
  });
1033
1374
  client.on("error", () => {
1034
1375
  target.destroy();
1376
+ cleanup();
1035
1377
  });
1378
+ client.on("close", cleanup);
1379
+ target.on("close", cleanup);
1036
1380
  target.on("connect", () => {
1037
1381
  target.on("data", (chunk) => {
1382
+ bumpActivity();
1038
1383
  if (!client.destroyed) client.write(chunk);
1039
1384
  });
1040
1385
  client.on("data", (chunk) => {
1386
+ bumpActivity();
1041
1387
  if (!target.destroyed) target.write(chunk);
1042
1388
  });
1043
1389
  target.on("end", () => {
@@ -1049,7 +1395,7 @@ function createLazyProxy(opts) {
1049
1395
  });
1050
1396
  }
1051
1397
  async function handleConnection(client) {
1052
- resetTimer();
1398
+ bumpActivity();
1053
1399
  client.on("error", () => {
1054
1400
  });
1055
1401
  if (serviceReady && isAlive()) {
@@ -1063,9 +1409,10 @@ function createLazyProxy(opts) {
1063
1409
  if (starting) return;
1064
1410
  starting = true;
1065
1411
  onLog?.("\u26A1 on-demand start");
1412
+ let ok = false;
1066
1413
  try {
1067
1414
  await onDemandStart();
1068
- const ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1415
+ ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1069
1416
  if (ok) serviceReady = true;
1070
1417
  else onLog?.("\u26A0 timeout waiting for service");
1071
1418
  } catch (e) {
@@ -1073,19 +1420,26 @@ function createLazyProxy(opts) {
1073
1420
  }
1074
1421
  starting = false;
1075
1422
  const conns = pendingConns.splice(0);
1423
+ if (!ok) {
1424
+ for (const conn of conns) {
1425
+ if (!conn.destroyed) conn.destroy();
1426
+ }
1427
+ return;
1428
+ }
1076
1429
  for (const conn of conns) {
1077
1430
  if (!conn.destroyed) pipeToTarget(conn);
1078
1431
  }
1079
1432
  }
1080
1433
  const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
1081
1434
  server.listen(listenPort, "0.0.0.0");
1082
- resetTimer();
1435
+ scheduleIdleCheck();
1083
1436
  return {
1084
1437
  server,
1085
- resetTimer,
1438
+ resetTimer: bumpActivity,
1086
1439
  destroy: () => {
1087
1440
  if (idleTimer) clearTimeout(idleTimer);
1088
1441
  pendingConns.forEach((s) => s.destroy());
1442
+ activeConns.forEach((s) => s.destroy());
1089
1443
  server.close();
1090
1444
  }
1091
1445
  };
@@ -1093,28 +1447,42 @@ function createLazyProxy(opts) {
1093
1447
 
1094
1448
  // src/tui/App.tsx
1095
1449
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1096
- function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts }) {
1450
+ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
1097
1451
  const { stdout } = useStdout();
1098
- const rows = stdout?.rows ?? 40;
1452
+ const [rows, setRows] = useState5(stdout?.rows ?? 40);
1453
+ useEffect5(() => {
1454
+ if (!stdout) return;
1455
+ const onResize = () => setRows(stdout.rows ?? 40);
1456
+ stdout.on("resize", onResize);
1457
+ return () => {
1458
+ stdout.off("resize", onResize);
1459
+ };
1460
+ }, [stdout]);
1099
1461
  const logsHeight = Math.floor(rows * 0.65);
1100
1462
  const statsHeight = rows - logsHeight - 2;
1101
1463
  const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
1102
- const pm = useProcessManager(platform, baseCwd, env);
1464
+ const pm = useProcessManager(platform, baseCwd, env, logSink);
1103
1465
  const [booted, setBooted] = useState5(false);
1104
1466
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
1105
1467
  const kb = useKeyBindings({
1106
1468
  onQuit: () => {
1107
- lazyProxies.current.forEach((p) => p.destroy());
1108
- pm.cleanup();
1109
- process.exit(0);
1110
- },
1111
- onClearLogs: () => {
1469
+ void shutdown();
1112
1470
  },
1471
+ onClearLogs: pm.clearLogs,
1113
1472
  onToggleProxy: () => {
1114
1473
  }
1115
1474
  });
1475
+ const shutdown = useCallback3(async () => {
1476
+ lazyProxies.current.forEach((p) => p.destroy());
1477
+ await pm.cleanup();
1478
+ await logSink?.close();
1479
+ process.exit(0);
1480
+ }, [pm, logSink]);
1481
+ useEffect5(() => {
1482
+ pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
1483
+ }, [kb.logsPaused, kb.logsScrollOffset, pm]);
1116
1484
  useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
1117
- useEffect3(() => {
1485
+ useEffect5(() => {
1118
1486
  if (booted || !pm.manager) return;
1119
1487
  setBooted(true);
1120
1488
  const mgr = pm.manager;
@@ -1128,8 +1496,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1128
1496
  for (const num of Object.keys(aoPhases).map(Number).sort((a, b) => a - b)) {
1129
1497
  const svcs = aoPhases[num];
1130
1498
  for (const svc of svcs) {
1131
- await mgr.install(svc);
1132
- await mgr.start(svc, colorIdx++);
1499
+ const ci = colorIdx++;
1500
+ await mgr.install(svc, ci);
1501
+ await mgr.start(svc, ci);
1133
1502
  }
1134
1503
  const apis = svcs.filter((s) => s.type === "api");
1135
1504
  if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
@@ -1159,7 +1528,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1159
1528
  targetPort: rewritten.realPort,
1160
1529
  timeoutMin: lazyTimeout,
1161
1530
  onDemandStart: async () => {
1162
- await mgr.install(rewritten);
1531
+ await mgr.install(rewritten, ci);
1163
1532
  await mgr.start(rewritten, ci);
1164
1533
  const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
1165
1534
  const st = mgr.state.get(svc.name);
@@ -1192,8 +1561,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1192
1561
  for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
1193
1562
  const svcs = phases[num];
1194
1563
  for (const svc of svcs) {
1195
- await mgr.install(svc);
1196
- await mgr.start(svc, colorIdx++);
1564
+ const ci = colorIdx++;
1565
+ await mgr.install(svc, ci);
1566
+ await mgr.start(svc, ci);
1197
1567
  }
1198
1568
  const apis = svcs.filter((s) => s.type === "api");
1199
1569
  if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
@@ -1239,7 +1609,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1239
1609
  showTimestamps: kb.showTimestamps,
1240
1610
  maxNameLen,
1241
1611
  height: logsHeight,
1242
- focused: kb.panel === "logs"
1612
+ focused: kb.panel === "logs",
1613
+ scrollOffset: kb.logsScrollOffset,
1614
+ resetScroll: kb.resetLogsScroll
1243
1615
  }
1244
1616
  ),
1245
1617
  /* @__PURE__ */ jsx6(
@@ -1250,7 +1622,9 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1250
1622
  sortMode: kb.sortMode,
1251
1623
  maxNameLen,
1252
1624
  height: statsHeight,
1253
- focused: kb.panel === "stats"
1625
+ focused: kb.panel === "stats",
1626
+ scrollOffset: kb.statsScrollOffset,
1627
+ resetScroll: kb.resetStatsScroll
1254
1628
  }
1255
1629
  ),
1256
1630
  kb.modal === "filter" && /* @__PURE__ */ jsx6(ServiceList, { title: "Filter by service", services: pm.states, onSelect: handleFilterSelect, onClose: () => kb.setModal("none") }),
@@ -1261,6 +1635,189 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1261
1635
  ] });
1262
1636
  }
1263
1637
 
1638
+ // src/process/log-sink.ts
1639
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
1640
+ import { join as join4, dirname as dirname4 } from "path";
1641
+ import { homedir } from "os";
1642
+ var LogSink = class {
1643
+ dir;
1644
+ rotateOnStart;
1645
+ streams = /* @__PURE__ */ new Map();
1646
+ seen = /* @__PURE__ */ new Set();
1647
+ constructor(opts) {
1648
+ const root = opts.rootDir ?? join4(homedir(), ".devup", "logs");
1649
+ this.dir = join4(root, sanitize(opts.projectName));
1650
+ this.rotateOnStart = opts.rotateOnStart ?? true;
1651
+ mkdirSync4(this.dir, { recursive: true });
1652
+ }
1653
+ /** Returns the file path for a service log (useful for tests / UI). */
1654
+ pathFor(svcName) {
1655
+ return join4(this.dir, `${sanitize(svcName)}.log`);
1656
+ }
1657
+ write(svcName, line) {
1658
+ const stream = this.streamFor(svcName);
1659
+ stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1660
+ `);
1661
+ }
1662
+ async close() {
1663
+ const closes = [...this.streams.values()].map(
1664
+ (s) => new Promise((r) => s.end(() => r()))
1665
+ );
1666
+ this.streams.clear();
1667
+ this.seen.clear();
1668
+ await Promise.all(closes);
1669
+ }
1670
+ streamFor(svcName) {
1671
+ let s = this.streams.get(svcName);
1672
+ if (s) return s;
1673
+ const file = this.pathFor(svcName);
1674
+ if (this.rotateOnStart && !this.seen.has(svcName) && existsSync8(file)) {
1675
+ try {
1676
+ mkdirSync4(dirname4(file), { recursive: true });
1677
+ renameSync(file, file + ".prev");
1678
+ } catch {
1679
+ }
1680
+ }
1681
+ this.seen.add(svcName);
1682
+ s = createWriteStream(file, { flags: "a" });
1683
+ s.on("error", () => {
1684
+ });
1685
+ this.streams.set(svcName, s);
1686
+ return s;
1687
+ }
1688
+ };
1689
+ function sanitize(name) {
1690
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "devup";
1691
+ }
1692
+
1693
+ // src/orchestrator/dry-run.ts
1694
+ function renderDryRun(opts) {
1695
+ const { config, services, cliArgs, env, proxyProvider, proxyOpts } = opts;
1696
+ const lines = [];
1697
+ lines.push(`Project: ${config.icon ?? "\u{1F4E6}"} ${config.name}`);
1698
+ lines.push(`Mode: ${cliArgs.lazy && config.lazy ? "lazy" : "normal"}`);
1699
+ lines.push(`Services: ${services.length}`);
1700
+ lines.push("");
1701
+ const lazyMode = cliArgs.lazy && !!config.lazy;
1702
+ let alwaysOn = services;
1703
+ let lazy = [];
1704
+ if (lazyMode) {
1705
+ const c = classifyServices(services, config.lazy);
1706
+ alwaysOn = c.alwaysOn;
1707
+ lazy = c.lazy;
1708
+ }
1709
+ const phases = groupByPhase(alwaysOn);
1710
+ const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
1711
+ for (const num of phaseNums) {
1712
+ lines.push(`Phase ${num}:`);
1713
+ for (const svc of phases[num]) {
1714
+ lines.push(formatService(svc, env, false));
1715
+ }
1716
+ }
1717
+ if (lazy.length) {
1718
+ lines.push("");
1719
+ lines.push("Lazy (on-demand):");
1720
+ for (const svc of lazy) {
1721
+ const rewritten = rewriteServicePort(svc);
1722
+ lines.push(formatService(rewritten, env, true));
1723
+ lines.push(` proxy :${svc.port} \u2192 :${getLazyRealPort(svc.port)} (idle timeout ${cliArgs.lazyTimeout}m)`);
1724
+ }
1725
+ }
1726
+ if (proxyProvider && proxyOpts) {
1727
+ lines.push("");
1728
+ lines.push(`Proxy: ${proxyProvider.name} \u2192 ${proxyOpts.confPath}`);
1729
+ const svcStates = /* @__PURE__ */ new Map();
1730
+ for (const svc of services) {
1731
+ const real = lazyMode && !alwaysOn.includes(svc) ? getLazyRealPort(svc.port) : void 0;
1732
+ svcStates.set(svc.name, { port: svc.port, health: "up", realPort: real });
1733
+ }
1734
+ const content = proxyProvider.generate(svcStates, proxyOpts);
1735
+ lines.push("");
1736
+ lines.push("--- generated config ---");
1737
+ lines.push(content);
1738
+ }
1739
+ return lines.join("\n");
1740
+ }
1741
+ function formatService(svc, env, isLazy) {
1742
+ const args = buildProcessArgs(svc);
1743
+ const cmdLine = [svc.cmd, ...args].join(" ");
1744
+ const built = buildProcessEnv(svc, env);
1745
+ const extraEnv = Object.keys(svc.extraEnv ?? {}).length ? " env=" + Object.entries(svc.extraEnv).map(([k, v]) => `${k}=${v}`).join(" ") : "";
1746
+ const memTag = svc.maxMem ? ` mem=${svc.maxMem}MB` : "";
1747
+ const hc = svc.healthCheck;
1748
+ const hcTag = hc?.type === "http" ? ` health=http ${hc.path ?? "/"}` : "";
1749
+ const lazyTag = isLazy ? " [lazy]" : "";
1750
+ void built;
1751
+ return ` - ${svc.name.padEnd(20)} (${svc.type}) :${svc.port} ${cmdLine}${memTag}${hcTag}${lazyTag}${extraEnv}`;
1752
+ }
1753
+ function runDryRun(opts) {
1754
+ console.log(renderDryRun(opts));
1755
+ }
1756
+
1757
+ // src/orchestrator/once.ts
1758
+ async function runOnce(opts) {
1759
+ const out = opts.out ?? ((l) => console.log(l));
1760
+ const { config, services, cliArgs, platform, env, baseCwd, logSink } = opts;
1761
+ const mgr = new ProcessManager({
1762
+ baseCwd,
1763
+ env,
1764
+ platform,
1765
+ events: {
1766
+ onLog: (svc, text) => {
1767
+ logSink?.write(svc, text);
1768
+ out(`[${svc}] ${text}`);
1769
+ },
1770
+ onStateChange: () => {
1771
+ }
1772
+ }
1773
+ });
1774
+ const phases = groupByPhase(services);
1775
+ const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
1776
+ const apiNames = services.filter((s) => s.type === "api").map((s) => s.name);
1777
+ const deadline = Date.now() + cliArgs.onceTimeout * 1e3;
1778
+ let colorIdx = 0;
1779
+ for (const num of phaseNums) {
1780
+ out(`\u25B6 phase ${num}`);
1781
+ for (const svc of phases[num]) {
1782
+ const ci = colorIdx++;
1783
+ const installed = await mgr.install(svc, ci);
1784
+ if (!installed) {
1785
+ out(`\u2717 install failed for ${svc.name}`);
1786
+ await mgr.cleanup();
1787
+ return 1;
1788
+ }
1789
+ await mgr.start(svc, ci);
1790
+ }
1791
+ const apis = phases[num].filter((s) => s.type === "api");
1792
+ for (const api of apis) {
1793
+ const ok = await waitHealthy(api, deadline);
1794
+ if (!ok) {
1795
+ out(`\u2717 ${api.name} did not become healthy within ${cliArgs.onceTimeout}s`);
1796
+ await mgr.cleanup();
1797
+ return 1;
1798
+ }
1799
+ out(`\u2713 ${api.name} ready`);
1800
+ const st = mgr.state.get(api.name);
1801
+ if (st) {
1802
+ st.status = "running";
1803
+ st.health = "up";
1804
+ }
1805
+ }
1806
+ }
1807
+ const summary = `ready: ${apiNames.length} APIs in ${((cliArgs.onceTimeout * 1e3 - (deadline - Date.now())) / 1e3).toFixed(1)}s`;
1808
+ out(summary);
1809
+ await mgr.cleanup();
1810
+ return 0;
1811
+ }
1812
+ async function waitHealthy(svc, deadline) {
1813
+ while (Date.now() < deadline) {
1814
+ const ok = await checkHealth(svc.port, svc.healthCheck);
1815
+ if (ok) return true;
1816
+ await new Promise((r) => setTimeout(r, 500));
1817
+ }
1818
+ return false;
1819
+ }
1820
+
1264
1821
  // src/config/types.ts
1265
1822
  function defineConfig(config) {
1266
1823
  return config;
@@ -1290,7 +1847,7 @@ ${formatValidationErrors(errors)}`);
1290
1847
  process.exit(1);
1291
1848
  }
1292
1849
  const platform = await detectPlatform();
1293
- const envFile = config.envFile ? join4(cwd, config.envFile) : join4(cwd, ".env");
1850
+ const envFile = config.envFile ? join5(cwd, config.envFile) : join5(cwd, ".env");
1294
1851
  const env = parseEnvFile(envFile, process.env);
1295
1852
  if (config.env) {
1296
1853
  for (const [k, v] of Object.entries(config.env)) {
@@ -1307,12 +1864,33 @@ ${formatValidationErrors(errors)}`);
1307
1864
  routes: config.proxy.routes,
1308
1865
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
1309
1866
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
1310
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join4(homedir(), ".traefik", "traefik_conf.yaml")
1867
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join5(homedir2(), ".traefik", "traefik_conf.yaml")
1311
1868
  };
1312
1869
  }
1870
+ if (cliArgs.dryRun) {
1871
+ runDryRun({ config, services, cliArgs, env, baseCwd: cwd, proxyProvider, proxyOpts });
1872
+ return;
1873
+ }
1874
+ let logSink = null;
1875
+ if (cliArgs.logFile) {
1876
+ logSink = new LogSink({ projectName: config.name, rootDir: cliArgs.logDir });
1877
+ }
1878
+ if (cliArgs.once) {
1879
+ const code = await runOnce({
1880
+ config,
1881
+ services,
1882
+ cliArgs,
1883
+ platform,
1884
+ env,
1885
+ baseCwd: cwd,
1886
+ logSink
1887
+ });
1888
+ await logSink?.close();
1889
+ process.exit(code);
1890
+ }
1313
1891
  const isInteractive = process.stdin.isTTY ?? false;
1314
1892
  const { waitUntilExit } = render(
1315
- React4.createElement(App, {
1893
+ React7.createElement(App, {
1316
1894
  config,
1317
1895
  services,
1318
1896
  cliArgs,
@@ -1320,7 +1898,8 @@ ${formatValidationErrors(errors)}`);
1320
1898
  env,
1321
1899
  baseCwd: cwd,
1322
1900
  proxyProvider,
1323
- proxyOpts
1901
+ proxyOpts,
1902
+ logSink
1324
1903
  }),
1325
1904
  { exitOnCtrlC: false, patchConsole: isInteractive, interactive: isInteractive }
1326
1905
  );