@gachlab/devup 0.1.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -0
  3. package/dist/chunk-LG7UD5ZR.js +54 -0
  4. package/dist/chunk-LG7UD5ZR.js.map +1 -0
  5. package/dist/config/cli.d.ts +17 -0
  6. package/dist/config/cli.d.ts.map +1 -0
  7. package/dist/config/loader.d.ts +4 -0
  8. package/dist/config/loader.d.ts.map +1 -0
  9. package/dist/config/types.d.ts +37 -0
  10. package/dist/config/types.d.ts.map +1 -0
  11. package/dist/config/validator.d.ts +8 -0
  12. package/dist/config/validator.d.ts.map +1 -0
  13. package/dist/darwin-3KJ3IEXN.js +17 -0
  14. package/dist/darwin-3KJ3IEXN.js.map +1 -0
  15. package/dist/index.d.ts +5 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +1336 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/lazy/classifier.d.ts +14 -0
  20. package/dist/lazy/classifier.d.ts.map +1 -0
  21. package/dist/lazy/proxy.d.ts +17 -0
  22. package/dist/lazy/proxy.d.ts.map +1 -0
  23. package/dist/linux-OQ3Q4Z2Z.js +8 -0
  24. package/dist/linux-OQ3Q4Z2Z.js.map +1 -0
  25. package/dist/platform/darwin.d.ts +6 -0
  26. package/dist/platform/darwin.d.ts.map +1 -0
  27. package/dist/platform/detect.d.ts +3 -0
  28. package/dist/platform/detect.d.ts.map +1 -0
  29. package/dist/platform/linux.d.ts +8 -0
  30. package/dist/platform/linux.d.ts.map +1 -0
  31. package/dist/platform/types.d.ts +11 -0
  32. package/dist/platform/types.d.ts.map +1 -0
  33. package/dist/platform/win32.d.ts +8 -0
  34. package/dist/platform/win32.d.ts.map +1 -0
  35. package/dist/process/health.d.ts +8 -0
  36. package/dist/process/health.d.ts.map +1 -0
  37. package/dist/process/installer.d.ts +12 -0
  38. package/dist/process/installer.d.ts.map +1 -0
  39. package/dist/process/manager.d.ts +26 -0
  40. package/dist/process/manager.d.ts.map +1 -0
  41. package/dist/process/types.d.ts +21 -0
  42. package/dist/process/types.d.ts.map +1 -0
  43. package/dist/proxy-config/detect.d.ts +3 -0
  44. package/dist/proxy-config/detect.d.ts.map +1 -0
  45. package/dist/proxy-config/traefik.d.ts +8 -0
  46. package/dist/proxy-config/traefik.d.ts.map +1 -0
  47. package/dist/proxy-config/types.d.ts +21 -0
  48. package/dist/proxy-config/types.d.ts.map +1 -0
  49. package/dist/tui/App.d.ts +17 -0
  50. package/dist/tui/App.d.ts.map +1 -0
  51. package/dist/tui/LogsPanel.d.ts +14 -0
  52. package/dist/tui/LogsPanel.d.ts.map +1 -0
  53. package/dist/tui/SearchInput.d.ts +7 -0
  54. package/dist/tui/SearchInput.d.ts.map +1 -0
  55. package/dist/tui/ServiceList.d.ts +11 -0
  56. package/dist/tui/ServiceList.d.ts.map +1 -0
  57. package/dist/tui/StatsPanel.d.ts +13 -0
  58. package/dist/tui/StatsPanel.d.ts.map +1 -0
  59. package/dist/tui/StatusBar.d.ts +2 -0
  60. package/dist/tui/StatusBar.d.ts.map +1 -0
  61. package/dist/tui/hooks/useKeyBindings.d.ts +31 -0
  62. package/dist/tui/hooks/useKeyBindings.d.ts.map +1 -0
  63. package/dist/tui/hooks/useProcessManager.d.ts +26 -0
  64. package/dist/tui/hooks/useProcessManager.d.ts.map +1 -0
  65. package/dist/tui/hooks/useProxySync.d.ts +4 -0
  66. package/dist/tui/hooks/useProxySync.d.ts.map +1 -0
  67. package/dist/utils.d.ts +22 -0
  68. package/dist/utils.d.ts.map +1 -0
  69. package/dist/win32-3X2OLSI6.js +49 -0
  70. package/dist/win32-3X2OLSI6.js.map +1 -0
  71. package/package.json +67 -0
package/dist/index.js ADDED
@@ -0,0 +1,1336 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import React4 from "react";
5
+ import { render } from "ink";
6
+ import { join as join4 } from "path";
7
+ import { homedir } from "os";
8
+
9
+ // src/config/loader.ts
10
+ import { existsSync } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import { resolve, join } from "path";
13
+ import { pathToFileURL } from "url";
14
+ var CONFIG_NAMES = [
15
+ "devup.config.ts",
16
+ "devup.config.js",
17
+ "devup.config.json"
18
+ ];
19
+ function findConfigFile(cwd, explicit) {
20
+ if (explicit) {
21
+ const full = resolve(cwd, explicit);
22
+ if (!existsSync(full)) throw new Error(`Config not found: ${full}`);
23
+ return full;
24
+ }
25
+ for (const name of CONFIG_NAMES) {
26
+ const full = join(cwd, name);
27
+ if (existsSync(full)) return full;
28
+ }
29
+ throw new Error(
30
+ `No config found. Create one of: ${CONFIG_NAMES.join(", ")}
31
+ Or use --config <path>`
32
+ );
33
+ }
34
+ async function loadConfig(configPath) {
35
+ if (configPath.endsWith(".json")) {
36
+ const raw = await readFile(configPath, "utf8");
37
+ return JSON.parse(raw);
38
+ }
39
+ const url = pathToFileURL(configPath).href;
40
+ const mod = await import(url);
41
+ const config = mod.default ?? mod;
42
+ if (!config || typeof config !== "object" || !Array.isArray(config.services)) {
43
+ throw new Error(`Invalid config: must export a DevStackConfig (use defineConfig() from @gachlab/devup)`);
44
+ }
45
+ return config;
46
+ }
47
+
48
+ // src/config/validator.ts
49
+ import { existsSync as existsSync2 } from "fs";
50
+ import { resolve as resolve2 } from "path";
51
+ function validateConfig(config, cwd) {
52
+ const errors = [];
53
+ if (!config.name?.trim()) {
54
+ errors.push({ field: "name", message: "Project name is required" });
55
+ }
56
+ if (!config.services?.length) {
57
+ errors.push({ field: "services", message: "At least one service is required" });
58
+ return errors;
59
+ }
60
+ const names = /* @__PURE__ */ new Set();
61
+ for (const svc of config.services) {
62
+ if (names.has(svc.name)) {
63
+ errors.push({ field: `services[${svc.name}].name`, message: `Duplicate service name: ${svc.name}` });
64
+ }
65
+ names.add(svc.name);
66
+ }
67
+ const ports = /* @__PURE__ */ new Map();
68
+ for (const svc of config.services) {
69
+ const existing = ports.get(svc.port);
70
+ if (existing) {
71
+ errors.push({ field: `services[${svc.name}].port`, message: `Port ${svc.port} already used by ${existing}` });
72
+ }
73
+ ports.set(svc.port, svc.name);
74
+ }
75
+ for (const svc of config.services) {
76
+ if (!svc.name?.trim()) errors.push({ field: "services[].name", message: "Service name is required" });
77
+ if (!svc.cwd?.trim()) errors.push({ field: `services[${svc.name}].cwd`, message: "cwd is required" });
78
+ if (!svc.cmd?.trim()) errors.push({ field: `services[${svc.name}].cmd`, message: "cmd is required" });
79
+ if (!svc.type || !["api", "web"].includes(svc.type)) {
80
+ errors.push({ field: `services[${svc.name}].type`, message: `Invalid type: ${svc.type} (must be "api" or "web")` });
81
+ }
82
+ if (typeof svc.port !== "number" || svc.port <= 0) {
83
+ errors.push({ field: `services[${svc.name}].port`, message: `Invalid port: ${svc.port}` });
84
+ }
85
+ if (typeof svc.phase !== "number" || svc.phase < 0) {
86
+ errors.push({ field: `services[${svc.name}].phase`, message: `Invalid phase: ${svc.phase}` });
87
+ }
88
+ if (svc.cwd && !existsSync2(resolve2(cwd, svc.cwd))) {
89
+ errors.push({ field: `services[${svc.name}].cwd`, message: `Directory not found: ${svc.cwd}` });
90
+ }
91
+ }
92
+ if (config.lazy?.alwaysOn) {
93
+ for (const ref of config.lazy.alwaysOn) {
94
+ if (!names.has(ref)) {
95
+ errors.push({ field: `lazy.alwaysOn`, message: `Unknown service: ${ref}` });
96
+ }
97
+ }
98
+ }
99
+ if (config.proxy?.routes) {
100
+ for (const ref of Object.keys(config.proxy.routes)) {
101
+ if (!names.has(ref)) {
102
+ errors.push({ field: `proxy.routes`, message: `Unknown service: ${ref}` });
103
+ }
104
+ }
105
+ }
106
+ return errors;
107
+ }
108
+ function formatValidationErrors(errors) {
109
+ return errors.map((e) => ` \u2717 ${e.field}: ${e.message}`).join("\n");
110
+ }
111
+
112
+ // src/config/cli.ts
113
+ var DEFAULT_LAZY_TIMEOUT = 10;
114
+ function parseCliArgs(argv) {
115
+ const args = {
116
+ skip: [],
117
+ lazy: true,
118
+ lazyTimeout: DEFAULT_LAZY_TIMEOUT,
119
+ proxy: false,
120
+ proxyTls: true,
121
+ proxyEntrypoint: "websecure"
122
+ };
123
+ for (let i = 0; i < argv.length; i++) {
124
+ const arg = argv[i];
125
+ const next = argv[i + 1];
126
+ switch (arg) {
127
+ case "--config":
128
+ args.configPath = next;
129
+ i++;
130
+ break;
131
+ case "--only":
132
+ args.only = next;
133
+ i++;
134
+ break;
135
+ case "--skip":
136
+ args.skip = next?.split(",") ?? [];
137
+ i++;
138
+ break;
139
+ case "--services":
140
+ args.services = next?.split(",");
141
+ i++;
142
+ break;
143
+ case "--lazy":
144
+ args.lazy = true;
145
+ break;
146
+ case "--no-lazy":
147
+ args.lazy = false;
148
+ break;
149
+ case "--timeout":
150
+ args.lazyTimeout = parseInt(next ?? "", 10) || DEFAULT_LAZY_TIMEOUT;
151
+ i++;
152
+ break;
153
+ case "--proxy":
154
+ args.proxy = true;
155
+ break;
156
+ case "--proxy-host":
157
+ args.proxyHost = next;
158
+ i++;
159
+ break;
160
+ case "--proxy-conf":
161
+ args.proxyConf = next;
162
+ i++;
163
+ break;
164
+ case "--proxy-tls":
165
+ args.proxyTls = true;
166
+ break;
167
+ case "--no-proxy-tls":
168
+ args.proxyTls = false;
169
+ break;
170
+ case "--proxy-entrypoint":
171
+ args.proxyEntrypoint = next ?? "websecure";
172
+ i++;
173
+ break;
174
+ }
175
+ }
176
+ return args;
177
+ }
178
+ function filterServices(services, args) {
179
+ let result = services;
180
+ if (args.services) {
181
+ const explicit = new Set(args.services);
182
+ result = result.filter((s) => explicit.has(s.name));
183
+ } else if (args.only) {
184
+ switch (args.only) {
185
+ case "apis":
186
+ result = result.filter((s) => s.type === "api");
187
+ break;
188
+ case "webs":
189
+ result = result.filter((s) => s.type === "web");
190
+ break;
191
+ default:
192
+ result = result.filter((s) => s.name.startsWith(args.only));
193
+ break;
194
+ }
195
+ }
196
+ if (args.skip.length) {
197
+ const skipSet = new Set(args.skip);
198
+ result = result.filter((s) => !skipSet.has(s.name));
199
+ }
200
+ return result;
201
+ }
202
+
203
+ // src/platform/detect.ts
204
+ async function detectPlatform() {
205
+ switch (process.platform) {
206
+ case "linux": {
207
+ const { LinuxPlatform } = await import("./linux-OQ3Q4Z2Z.js");
208
+ return new LinuxPlatform();
209
+ }
210
+ case "darwin": {
211
+ const { DarwinPlatform } = await import("./darwin-3KJ3IEXN.js");
212
+ return new DarwinPlatform();
213
+ }
214
+ case "win32": {
215
+ const { Win32Platform } = await import("./win32-3X2OLSI6.js");
216
+ return new Win32Platform();
217
+ }
218
+ default:
219
+ throw new Error(`Unsupported platform: ${process.platform}`);
220
+ }
221
+ }
222
+
223
+ // src/proxy-config/traefik.ts
224
+ import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
225
+ import { dirname } from "path";
226
+ var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
227
+ var TraefikProvider = class {
228
+ name = "traefik";
229
+ generate(services, opts) {
230
+ const routers = [];
231
+ const svcs = [];
232
+ for (const [name, st] of services) {
233
+ if (st.health !== "up") continue;
234
+ const sub = opts.routes[name];
235
+ if (sub === void 0) continue;
236
+ const rule = sub ? `Host(\`${sub}.${opts.domain}\`)` : `Host(\`${opts.domain}\`)`;
237
+ const safe = name.replace(/[^a-z0-9-]/g, "-");
238
+ const port = st.realPort ?? st.port;
239
+ let router = ` ${safe}:
240
+ rule: "${rule}"
241
+ service: ${safe}
242
+ entryPoints:
243
+ - ${opts.entrypoint}`;
244
+ if (opts.tls) router += `
245
+ tls:
246
+ certResolver: le`;
247
+ routers.push(router);
248
+ svcs.push(` ${safe}:
249
+ loadBalancer:
250
+ servers:
251
+ - url: "http://${opts.host}:${port}"`);
252
+ }
253
+ if (!routers.length) return EMPTY_CONFIG;
254
+ return `http:
255
+ routers:
256
+ ${routers.join("\n")}
257
+ services:
258
+ ${svcs.join("\n")}
259
+ `;
260
+ }
261
+ write(content, opts) {
262
+ const dir = dirname(opts.confPath);
263
+ if (!existsSync3(dir)) mkdirSync(dir, { recursive: true });
264
+ writeFileSync(opts.confPath, content);
265
+ }
266
+ clear(opts) {
267
+ this.write(EMPTY_CONFIG, opts);
268
+ }
269
+ };
270
+
271
+ // src/proxy-config/detect.ts
272
+ var providers = {
273
+ traefik: () => new TraefikProvider()
274
+ };
275
+ function detectProxyProvider(name) {
276
+ const factory = providers[name];
277
+ if (!factory) {
278
+ const available = Object.keys(providers).join(", ");
279
+ throw new Error(`Unknown proxy provider: "${name}". Available: ${available}`);
280
+ }
281
+ return factory();
282
+ }
283
+
284
+ // src/utils.ts
285
+ import { existsSync as existsSync4, readFileSync, writeFileSync as writeFileSync2 } from "fs";
286
+ import { createHash } from "crypto";
287
+ import { join as join2 } from "path";
288
+ function parseEnvFile(filePath, baseEnv = {}) {
289
+ const env = { ...baseEnv };
290
+ if (!existsSync4(filePath)) return env;
291
+ for (const line of readFileSync(filePath, "utf8").split("\n")) {
292
+ const trimmed = line.trim();
293
+ if (!trimmed || trimmed.startsWith("#")) continue;
294
+ const eqIdx = trimmed.indexOf("=");
295
+ if (eqIdx === -1) continue;
296
+ const key = trimmed.slice(0, eqIdx).trim();
297
+ let val = trimmed.slice(eqIdx + 1).trim();
298
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
299
+ val = val.slice(1, -1);
300
+ }
301
+ if (!env[key]) env[key] = val;
302
+ }
303
+ return env;
304
+ }
305
+ function fmtUptime(ms) {
306
+ if (!ms || ms < 0) return "-";
307
+ const s = Math.floor(ms / 1e3);
308
+ if (s < 60) return `${s}s`;
309
+ const m = Math.floor(s / 60);
310
+ if (m < 60) return `${m}m${s % 60}s`;
311
+ const h = Math.floor(m / 60);
312
+ return `${h}h${m % 60}m`;
313
+ }
314
+ function needsInstall(fullCwd) {
315
+ const nm = join2(fullCwd, "node_modules");
316
+ if (!existsSync4(nm)) return true;
317
+ try {
318
+ const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
319
+ const stampFile = join2(nm, ".install-stamp");
320
+ if (existsSync4(stampFile) && readFileSync(stampFile, "utf8") === pkgHash) return false;
321
+ } catch {
322
+ }
323
+ return true;
324
+ }
325
+ function writeInstallStamp(fullCwd) {
326
+ try {
327
+ const pkgHash = createHash("md5").update(readFileSync(join2(fullCwd, "package.json"))).digest("hex");
328
+ writeFileSync2(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
329
+ } catch {
330
+ }
331
+ }
332
+ function sortServiceNames(names, sortMode, statsMap, procState) {
333
+ if (sortMode === "name") return names.slice().sort();
334
+ return names.slice().sort((a, b) => {
335
+ if (sortMode === "mem") {
336
+ return (parseFloat(statsMap[b]?.mem ?? "0") || 0) - (parseFloat(statsMap[a]?.mem ?? "0") || 0);
337
+ }
338
+ return (procState[b]?.errors ?? 0) - (procState[a]?.errors ?? 0);
339
+ });
340
+ }
341
+ function groupByPhase(services) {
342
+ const phases = {};
343
+ for (const s of services) {
344
+ (phases[s.phase] ??= []).push(s);
345
+ }
346
+ return phases;
347
+ }
348
+ function buildProcessArgs(svc) {
349
+ const extra = svc.nodeArgs ?? [];
350
+ if (!svc.maxMem) return [...extra, ...svc.args];
351
+ if (svc.cmd === "node") return [`--max-old-space-size=${svc.maxMem}`, ...extra, ...svc.args];
352
+ return [...extra, ...svc.args];
353
+ }
354
+ function buildProcessEnv(svc, baseEnv) {
355
+ const env = { ...baseEnv, ...svc.extraEnv ?? {} };
356
+ if (svc.maxMem && svc.cmd !== "node") {
357
+ const existing = env["NODE_OPTIONS"] ?? "";
358
+ const flag = `--max-old-space-size=${svc.maxMem}`;
359
+ if (!existing.includes("max-old-space-size")) {
360
+ env["NODE_OPTIONS"] = existing ? `${existing} ${flag}` : flag;
361
+ }
362
+ }
363
+ return env;
364
+ }
365
+ function calcCpuPercent(totalCpuSec, prevCpu, prevTime) {
366
+ const elapsed = (Date.now() - prevTime) / 1e3;
367
+ const cpuDelta = totalCpuSec - prevCpu;
368
+ return elapsed > 0 ? cpuDelta / elapsed * 100 : 0;
369
+ }
370
+ var tagColors = [
371
+ "cyan",
372
+ "yellow",
373
+ "green",
374
+ "magenta",
375
+ "blue",
376
+ "red",
377
+ "#5faf5f",
378
+ "#d7af5f",
379
+ "#5f87d7",
380
+ "#af5faf",
381
+ "#5fd7d7",
382
+ "#d75f5f",
383
+ "white"
384
+ ];
385
+
386
+ // src/tui/App.tsx
387
+ import { useEffect as useEffect3, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
388
+ import { Box as Box6, Text as Text6, useStdout } from "ink";
389
+
390
+ // src/tui/hooks/useProcessManager.ts
391
+ import { useState, useEffect, useRef, useCallback } from "react";
392
+
393
+ // src/process/manager.ts
394
+ import { spawn as spawn2 } from "child_process";
395
+ import { join as join3 } from "path";
396
+
397
+ // src/process/health.ts
398
+ import net from "net";
399
+ function checkPort(port, host = "127.0.0.1") {
400
+ return new Promise((resolve3) => {
401
+ const socket = new net.Socket();
402
+ socket.setTimeout(2e3);
403
+ socket.once("connect", () => {
404
+ socket.destroy();
405
+ resolve3(true);
406
+ });
407
+ socket.once("error", () => {
408
+ socket.destroy();
409
+ resolve3(false);
410
+ });
411
+ socket.once("timeout", () => {
412
+ socket.destroy();
413
+ resolve3(false);
414
+ });
415
+ socket.connect(port, host);
416
+ });
417
+ }
418
+ function waitForPort(port, opts = {}) {
419
+ const { timeout = 45e3, interval = 1e3 } = opts;
420
+ return new Promise((resolve3) => {
421
+ const start = Date.now();
422
+ const check = () => {
423
+ checkPort(port).then((ok) => {
424
+ if (ok) return resolve3(true);
425
+ if (Date.now() - start > timeout) return resolve3(false);
426
+ setTimeout(check, interval);
427
+ });
428
+ };
429
+ check();
430
+ });
431
+ }
432
+ function deriveHealth(isUp, currentStatus) {
433
+ if (currentStatus === "idle") return "idle";
434
+ if (isUp) return "up";
435
+ return currentStatus === "starting" ? "wait" : "down";
436
+ }
437
+
438
+ // src/process/installer.ts
439
+ import { spawn } from "child_process";
440
+ import { existsSync as existsSync5 } from "fs";
441
+ function installService(cwd, env, onLog) {
442
+ if (!existsSync5(cwd)) {
443
+ onLog?.(`\u26A0 directory not found: ${cwd}`);
444
+ return Promise.resolve(false);
445
+ }
446
+ if (!needsInstall(cwd)) {
447
+ onLog?.("\u2705 dependencies up to date");
448
+ return Promise.resolve(true);
449
+ }
450
+ onLog?.("\u{1F4E6} npm install...");
451
+ return new Promise((resolve3) => {
452
+ const proc = spawn("npm", ["install"], { cwd, env, stdio: ["ignore", "ignore", "pipe"] });
453
+ let stderr = "";
454
+ proc.stderr?.on("data", (d) => {
455
+ stderr += d.toString();
456
+ });
457
+ proc.on("close", (code) => {
458
+ if (code !== 0) {
459
+ onLog?.(`\u26A0 npm install failed: ${stderr.split("\n")[0]}`);
460
+ resolve3(false);
461
+ } else {
462
+ writeInstallStamp(cwd);
463
+ onLog?.("\u2705 dependencies ready");
464
+ resolve3(true);
465
+ }
466
+ });
467
+ });
468
+ }
469
+
470
+ // src/process/manager.ts
471
+ var MAX_RESTARTS = 3;
472
+ var BACKOFF_BASE_MS = 2e3;
473
+ var ProcessManager = class {
474
+ state = /* @__PURE__ */ new Map();
475
+ procs = /* @__PURE__ */ new Set();
476
+ baseCwd;
477
+ env;
478
+ platform;
479
+ events;
480
+ constructor(opts) {
481
+ this.baseCwd = opts.baseCwd;
482
+ this.env = opts.env;
483
+ this.platform = opts.platform;
484
+ this.events = opts.events;
485
+ }
486
+ async install(svc) {
487
+ const cwd = join3(this.baseCwd, svc.cwd);
488
+ return installService(cwd, this.env, (msg) => this.log(svc.name, msg, this.getColorIdx(svc.name)));
489
+ }
490
+ async start(svc, colorIdx, isRestart = false) {
491
+ const cwd = join3(this.baseCwd, svc.cwd);
492
+ if (svc.type === "api") {
493
+ const occupied = await checkPort(svc.port);
494
+ if (occupied && !isRestart) {
495
+ this.log(svc.name, `\u26A0 port ${svc.port} already in use \u2014 skipping`, colorIdx);
496
+ return;
497
+ }
498
+ }
499
+ const args = buildProcessArgs(svc);
500
+ const env = buildProcessEnv(svc, this.env);
501
+ const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
502
+ const prev = this.state.get(svc.name);
503
+ const state = {
504
+ svc,
505
+ proc,
506
+ pid: proc.pid ?? null,
507
+ status: "starting",
508
+ health: "wait",
509
+ errors: prev?.errors ?? 0,
510
+ restarts: prev?.restarts ?? 0,
511
+ startedAt: Date.now(),
512
+ intentionalStop: false,
513
+ colorIdx
514
+ };
515
+ this.state.set(svc.name, state);
516
+ this.procs.add(proc);
517
+ 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);
522
+ });
523
+ proc.on("close", (code) => {
524
+ this.procs.delete(proc);
525
+ if (state.intentionalStop) {
526
+ state.intentionalStop = false;
527
+ return;
528
+ }
529
+ if (code === 0) {
530
+ state.status = "stopped";
531
+ state.health = "down";
532
+ this.events.onStateChange(svc.name, state);
533
+ return;
534
+ }
535
+ state.status = "crashed";
536
+ state.health = "down";
537
+ this.log(svc.name, `\u274C exited with code ${code}`, colorIdx);
538
+ this.events.onStateChange(svc.name, state);
539
+ if (state.restarts < MAX_RESTARTS) {
540
+ state.restarts++;
541
+ const delay = BACKOFF_BASE_MS * Math.pow(2, state.restarts - 1);
542
+ this.log(svc.name, `\u{1F504} auto-restart ${state.restarts}/${MAX_RESTARTS} in ${delay}ms...`, colorIdx);
543
+ setTimeout(() => this.start(svc, colorIdx, true), delay);
544
+ } else {
545
+ this.log(svc.name, "\u26D4 max restarts reached", colorIdx);
546
+ }
547
+ });
548
+ this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
549
+ }
550
+ stop(name) {
551
+ const st = this.state.get(name);
552
+ if (!st?.proc || !st.pid) return;
553
+ st.intentionalStop = true;
554
+ this.platform.killTree(st.pid);
555
+ }
556
+ async restart(name) {
557
+ const st = this.state.get(name);
558
+ if (!st) return;
559
+ this.stop(name);
560
+ st.restarts++;
561
+ const delay = st.proc ? 1500 : 100;
562
+ await new Promise((r) => setTimeout(r, delay));
563
+ await this.start(st.svc, st.colorIdx, true);
564
+ this.log(name, "\u{1F504} manual restart", st.colorIdx);
565
+ }
566
+ async checkAllHealth() {
567
+ for (const [name, st] of this.state) {
568
+ if (!st.pid || st.status === "idle") {
569
+ st.health = st.status === "idle" ? "idle" : "down";
570
+ continue;
571
+ }
572
+ const port = st.svc.port;
573
+ const isUp = await checkPort(port);
574
+ const prev = st.health;
575
+ st.health = deriveHealth(isUp, st.status);
576
+ if (st.health === "up" && st.status === "starting") st.status = "running";
577
+ if (prev !== st.health) this.events.onStateChange(name, st);
578
+ }
579
+ }
580
+ cleanup() {
581
+ for (const proc of this.procs) {
582
+ if (proc.pid) this.platform.killTree(proc.pid);
583
+ }
584
+ setTimeout(() => {
585
+ for (const proc of this.procs) {
586
+ if (proc.pid) this.platform.killTree(proc.pid, "SIGKILL");
587
+ }
588
+ }, 3e3);
589
+ }
590
+ log(name, text, colorIdx) {
591
+ this.events.onLog(name, text, colorIdx);
592
+ }
593
+ getColorIdx(name) {
594
+ return this.state.get(name)?.colorIdx ?? 0;
595
+ }
596
+ };
597
+
598
+ // src/tui/hooks/useProcessManager.ts
599
+ function useProcessManager(platform, baseCwd, env) {
600
+ const [states, setStates] = useState(/* @__PURE__ */ new Map());
601
+ const [logs, setLogs] = useState([]);
602
+ const [stats, setStats] = useState(/* @__PURE__ */ new Map());
603
+ const mgrRef = useRef(null);
604
+ const prevCpu = useRef(/* @__PURE__ */ new Map());
605
+ useEffect(() => {
606
+ const mgr2 = new ProcessManager({
607
+ baseCwd,
608
+ env,
609
+ platform,
610
+ events: {
611
+ onLog: (svcName, text, colorIdx) => {
612
+ const lines = text.split("\n").filter(Boolean);
613
+ setLogs((prev) => {
614
+ const next = [...prev, ...lines.map((l) => ({ svcName, text: l, colorIdx, ts: Date.now() }))];
615
+ return next.length > 5e3 ? next.slice(-5e3) : next;
616
+ });
617
+ },
618
+ onStateChange: () => setStates(new Map(mgr2.state))
619
+ }
620
+ });
621
+ mgrRef.current = mgr2;
622
+ return () => {
623
+ mgr2.cleanup();
624
+ };
625
+ }, [baseCwd, env, platform]);
626
+ useEffect(() => {
627
+ const id = setInterval(async () => {
628
+ const mgr2 = mgrRef.current;
629
+ if (!mgr2) return;
630
+ await mgr2.checkAllHealth();
631
+ setStates(new Map(mgr2.state));
632
+ const pids = [];
633
+ const pidMap = /* @__PURE__ */ new Map();
634
+ for (const [name, st] of mgr2.state) {
635
+ if (st.pid) {
636
+ pids.push(st.pid);
637
+ pidMap.set(st.pid, name);
638
+ }
639
+ }
640
+ if (pids.length) {
641
+ const raw = await platform.getProcessStats(pids);
642
+ const next = /* @__PURE__ */ new Map();
643
+ for (const [pid, data] of raw) {
644
+ const name = pidMap.get(pid);
645
+ if (!name) continue;
646
+ const prev = prevCpu.current.get(name) ?? { time: Date.now(), cpu: 0 };
647
+ const cpuPct = calcCpuPercent(data.cpuSeconds, prev.cpu, prev.time);
648
+ prevCpu.current.set(name, { time: Date.now(), cpu: data.cpuSeconds });
649
+ next.set(name, { cpu: cpuPct.toFixed(1) + "%", mem: (data.rss / 1024).toFixed(1) + " MB" });
650
+ }
651
+ setStats(next);
652
+ }
653
+ }, 3e3);
654
+ return () => clearInterval(id);
655
+ }, [platform]);
656
+ const mgr = mgrRef.current;
657
+ return {
658
+ states,
659
+ logs,
660
+ stats,
661
+ start: useCallback((svc, colorIdx) => mgr?.start(svc, colorIdx), [mgr]),
662
+ stop: useCallback((name) => mgr?.stop(name), [mgr]),
663
+ restart: useCallback((name) => mgr?.restart(name), [mgr]),
664
+ install: useCallback((svc) => mgr?.install(svc), [mgr]),
665
+ cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
666
+ manager: mgr
667
+ };
668
+ }
669
+
670
+ // src/tui/hooks/useKeyBindings.ts
671
+ import { useInput } from "ink";
672
+ import { useState as useState2, useCallback as useCallback2 } from "react";
673
+ var SORT_MODES = ["name", "mem", "errors"];
674
+ function useKeyBindings(opts) {
675
+ const [state, setState] = useState2({
676
+ panel: "logs",
677
+ modal: "none",
678
+ logFilter: null,
679
+ searchTerm: null,
680
+ logsPaused: false,
681
+ showTimestamps: false,
682
+ sortIdx: 0,
683
+ proxyEnabled: false
684
+ });
685
+ const setModal = useCallback2((modal) => setState((s) => ({ ...s, modal })), []);
686
+ const setFilter = useCallback2((f) => setState((s) => ({ ...s, logFilter: f, modal: "none" })), []);
687
+ const setSearch = useCallback2((t) => setState((s) => ({ ...s, searchTerm: t, modal: "none" })), []);
688
+ const isActive = process.stdin.isTTY ?? false;
689
+ useInput((input, key) => {
690
+ if (state.modal !== "none") return;
691
+ if (input === "q" || key.ctrl && input === "c") opts.onQuit();
692
+ else if (input === "c") opts.onClearLogs();
693
+ else if (key.tab) setState((s) => ({ ...s, panel: s.panel === "logs" ? "stats" : "logs" }));
694
+ else if (input === "f") setModal("filter");
695
+ else if (input === "r") setModal("restart");
696
+ else if (input === "o") setModal("open");
697
+ else if (input === "/") setModal("search");
698
+ else if (input === "a") setState((s) => ({ ...s, logFilter: null, searchTerm: null }));
699
+ else if (input === "p") setState((s) => ({ ...s, logsPaused: !s.logsPaused }));
700
+ else if (input === "t") setState((s) => ({ ...s, showTimestamps: !s.showTimestamps }));
701
+ else if (input === "s") setState((s) => ({ ...s, sortIdx: (s.sortIdx + 1) % SORT_MODES.length }));
702
+ else if (input === "T") {
703
+ opts.onToggleProxy();
704
+ setState((s) => ({ ...s, proxyEnabled: !s.proxyEnabled }));
705
+ }
706
+ }, { isActive });
707
+ return { ...state, setModal, setFilter, setSearch, sortMode: SORT_MODES[state.sortIdx] };
708
+ }
709
+
710
+ // src/tui/hooks/useProxySync.ts
711
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
712
+ function useProxySync(provider, opts, states, enabled) {
713
+ const intervalRef = useRef2(null);
714
+ useEffect2(() => {
715
+ if (!provider || !opts || !enabled) {
716
+ if (intervalRef.current) clearInterval(intervalRef.current);
717
+ return;
718
+ }
719
+ const sync = () => {
720
+ const svcStates = /* @__PURE__ */ new Map();
721
+ for (const [name, st] of states) {
722
+ svcStates.set(name, { port: st.svc.port, health: st.health, realPort: st.svc.realPort });
723
+ }
724
+ const content = provider.generate(svcStates, opts);
725
+ provider.write(content, opts);
726
+ };
727
+ sync();
728
+ intervalRef.current = setInterval(sync, 3e3);
729
+ return () => {
730
+ if (intervalRef.current) clearInterval(intervalRef.current);
731
+ };
732
+ }, [provider, opts, enabled, states]);
733
+ }
734
+
735
+ // src/tui/LogsPanel.tsx
736
+ import { Box, Text } from "ink";
737
+ import { jsx, jsxs } from "react/jsx-runtime";
738
+ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused }) {
739
+ const filtered = filter ? logs.filter((l) => l.svcName === filter) : logs;
740
+ const contentHeight = height - 2;
741
+ const visible = filtered.slice(-contentHeight);
742
+ const label = [
743
+ "Logs",
744
+ filter ? `[${filter}]` : "",
745
+ searchTerm ? `/${searchTerm}` : "",
746
+ paused ? "[PAUSED]" : "",
747
+ `${filtered.length} lines`
748
+ ].filter(Boolean).join(" ");
749
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "cyan" : "gray", height, children: [
750
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
751
+ " ",
752
+ label,
753
+ " "
754
+ ] }) }),
755
+ visible.map((entry, i) => {
756
+ const color = tagColors[entry.colorIdx % tagColors.length];
757
+ const ts = showTimestamps ? new Date(entry.ts).toLocaleTimeString("en-GB") + " " : "";
758
+ const line = entry.text;
759
+ const isMatch = searchTerm && line.toLowerCase().includes(searchTerm.toLowerCase());
760
+ return /* @__PURE__ */ jsxs(Box, { children: [
761
+ showTimestamps && /* @__PURE__ */ jsx(Text, { dimColor: true, children: ts }),
762
+ /* @__PURE__ */ jsxs(Text, { color, children: [
763
+ "[",
764
+ entry.svcName.padEnd(maxNameLen),
765
+ "]"
766
+ ] }),
767
+ /* @__PURE__ */ jsx(Text, { children: " " }),
768
+ isMatch ? /* @__PURE__ */ jsx(Text, { backgroundColor: "yellow", color: "black", children: line }) : /* @__PURE__ */ jsx(Text, { children: line })
769
+ ] }, i);
770
+ })
771
+ ] });
772
+ }
773
+
774
+ // src/tui/StatsPanel.tsx
775
+ import { Box as Box2, Text as Text2 } from "ink";
776
+ import os from "os";
777
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
778
+ var H = {
779
+ up: { c: "\u25CF", color: "green" },
780
+ wait: { c: "\u25CF", color: "yellow" },
781
+ down: { c: "\u25CF", color: "red" },
782
+ idle: { c: "\u25CB", color: "blue" }
783
+ };
784
+ function Row({ name, st, stat, ml }) {
785
+ const h = H[st.health] ?? H["down"];
786
+ const color = tagColors[st.colorIdx % tagColors.length];
787
+ const sc = st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
788
+ const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
789
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
790
+ /* @__PURE__ */ jsx2(Text2, { color: h.color, children: h.c }),
791
+ " ",
792
+ /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
793
+ " ",
794
+ String(st.svc.port).padStart(5),
795
+ " ",
796
+ /* @__PURE__ */ jsx2(Text2, { color: sc, children: st.status.padEnd(8) }),
797
+ " ",
798
+ (stat?.cpu ?? "-").padStart(6),
799
+ " ",
800
+ (stat?.mem ?? "-").padStart(8),
801
+ " ",
802
+ String(st.errors).padStart(3),
803
+ " ",
804
+ String(st.restarts).padStart(3),
805
+ " ",
806
+ up.padStart(6)
807
+ ] });
808
+ }
809
+ function ColHeader({ ml }) {
810
+ return /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
811
+ "H ",
812
+ "Service".padEnd(ml),
813
+ " ",
814
+ "Port".padStart(5),
815
+ " ",
816
+ "Status".padEnd(8),
817
+ " ",
818
+ "CPU".padStart(6),
819
+ " ",
820
+ "Mem".padStart(8),
821
+ " Err Rst ",
822
+ "Up".padStart(6)
823
+ ] });
824
+ }
825
+ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused }) {
826
+ const names = [...states.keys()];
827
+ const stObj = Object.fromEntries([...states].map(([k, v]) => [k, { errors: v.errors }]));
828
+ const statsObj = Object.fromEntries([...stats].map(([k, v]) => [k, v]));
829
+ const apis = sortServiceNames(names.filter((n) => states.get(n).svc.type === "api"), sortMode, statsObj, stObj);
830
+ const webs = sortServiceNames(names.filter((n) => states.get(n).svc.type === "web"), sortMode, statsObj, stObj);
831
+ const cpus = os.cpus().length;
832
+ const totalGB = (os.totalmem() / 1024 / 1024 / 1024).toFixed(1);
833
+ const usedGB = (parseFloat(totalGB) - os.freemem() / 1024 / 1024 / 1024).toFixed(1);
834
+ const load = os.loadavg()[0].toFixed(2);
835
+ let totalCpu = 0, totalMemMB = 0, totalErrors = 0, totalRestarts = 0;
836
+ for (const name of names) {
837
+ const s = stats.get(name);
838
+ if (s) {
839
+ const c = parseFloat(s.cpu);
840
+ if (!isNaN(c)) totalCpu += c;
841
+ const m = parseFloat(s.mem);
842
+ if (!isNaN(m)) totalMemMB += m;
843
+ }
844
+ totalErrors += states.get(name)?.errors ?? 0;
845
+ totalRestarts += states.get(name)?.restarts ?? 0;
846
+ }
847
+ const stackMem = totalMemMB >= 1024 ? (totalMemMB / 1024).toFixed(2) + " GB" : totalMemMB.toFixed(1) + " MB";
848
+ const ml = maxNameLen;
849
+ const contentHeight = height - 2;
850
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
851
+ /* @__PURE__ */ jsxs2(Box2, { children: [
852
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "green", children: " Stats " }),
853
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
854
+ "System: ",
855
+ cpus,
856
+ "c Load ",
857
+ load,
858
+ " RAM ",
859
+ usedGB,
860
+ "/",
861
+ totalGB,
862
+ "GB"
863
+ ] }),
864
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " \u2502 " }),
865
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
866
+ "Stack: CPU ",
867
+ totalCpu.toFixed(1),
868
+ "% RAM ",
869
+ stackMem,
870
+ " Err ",
871
+ totalErrors,
872
+ " Rst ",
873
+ totalRestarts,
874
+ " Svcs ",
875
+ names.length
876
+ ] }),
877
+ sortMode !== "name" && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
878
+ " \u2502 Sort: ",
879
+ sortMode
880
+ ] })
881
+ ] }),
882
+ /* @__PURE__ */ jsxs2(Box2, { flexGrow: 1, children: [
883
+ /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
884
+ /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
885
+ " APIs (",
886
+ apis.length,
887
+ ")"
888
+ ] }),
889
+ /* @__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))
891
+ ] }),
892
+ /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2502" }, i)) }),
893
+ /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
894
+ /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "magenta", children: [
895
+ " Webs (",
896
+ webs.length,
897
+ ")"
898
+ ] }),
899
+ /* @__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))
901
+ ] })
902
+ ] })
903
+ ] });
904
+ }
905
+
906
+ // src/tui/StatusBar.tsx
907
+ import { Box as Box3, Text as Text3 } from "ink";
908
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
909
+ function StatusBar() {
910
+ return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsxs3(Text3, { children: [
911
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "q" }),
912
+ " Quit ",
913
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Tab" }),
914
+ " Switch ",
915
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "c" }),
916
+ " Clear ",
917
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
918
+ " Filter ",
919
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "a" }),
920
+ " All ",
921
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "r" }),
922
+ " Restart ",
923
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "/" }),
924
+ " Search ",
925
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "s" }),
926
+ " Sort ",
927
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "o" }),
928
+ " Open ",
929
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "p" }),
930
+ " Pause ",
931
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "t" }),
932
+ " Time ",
933
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "T" }),
934
+ " Proxy"
935
+ ] }) });
936
+ }
937
+
938
+ // src/tui/ServiceList.tsx
939
+ import { useState as useState3 } from "react";
940
+ import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
941
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
942
+ function ServiceList({ title, services, onSelect, onClose, filterType }) {
943
+ const names = [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType);
944
+ const [idx, setIdx] = useState3(0);
945
+ useInput2((input, key) => {
946
+ if (key.escape) onClose();
947
+ else if (key.return) {
948
+ if (names[idx]) onSelect(names[idx]);
949
+ } else if (key.upArrow) setIdx((i) => Math.max(0, i - 1));
950
+ else if (key.downArrow) setIdx((i) => Math.min(names.length - 1, i + 1));
951
+ }, { isActive: process.stdin.isTTY ?? false });
952
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
953
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
954
+ " ",
955
+ title,
956
+ " "
957
+ ] }),
958
+ names.map((name, i) => /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { color: i === idx ? "cyan" : void 0, inverse: i === idx, children: [
959
+ " ",
960
+ name,
961
+ " :",
962
+ services.get(name).svc.port,
963
+ " "
964
+ ] }) }, name)),
965
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191\u2193 navigate Enter select Esc close" })
966
+ ] });
967
+ }
968
+
969
+ // src/tui/SearchInput.tsx
970
+ import { useState as useState4 } from "react";
971
+ import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
972
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
973
+ function SearchInput({ onSubmit, onClose }) {
974
+ const [value, setValue] = useState4("");
975
+ useInput3((input, key) => {
976
+ if (key.escape) onClose();
977
+ else if (key.return) onSubmit(value.trim() || null);
978
+ else if (key.backspace || key.delete) setValue((v) => v.slice(0, -1));
979
+ else if (input && !key.ctrl && !key.meta) setValue((v) => v + input);
980
+ }, { isActive: process.stdin.isTTY ?? false });
981
+ return /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
982
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "yellow", children: "Search: " }),
983
+ /* @__PURE__ */ jsx5(Text5, { children: value }),
984
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2588" })
985
+ ] });
986
+ }
987
+
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
+ // src/lazy/proxy.ts
1011
+ import net2 from "net";
1012
+ function createLazyProxy(opts) {
1013
+ const { listenPort, targetPort, timeoutMin, onDemandStart, onIdleStop, isAlive, onLog } = opts;
1014
+ let idleTimer = null;
1015
+ let starting = false;
1016
+ let serviceReady = false;
1017
+ let pendingConns = [];
1018
+ function resetTimer() {
1019
+ 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
+ }
1027
+ }
1028
+ function pipeToTarget(client) {
1029
+ const target = net2.createConnection({ port: targetPort, host: "127.0.0.1", allowHalfOpen: true });
1030
+ target.on("error", () => {
1031
+ client.destroy();
1032
+ });
1033
+ client.on("error", () => {
1034
+ target.destroy();
1035
+ });
1036
+ target.on("connect", () => {
1037
+ target.on("data", (chunk) => {
1038
+ if (!client.destroyed) client.write(chunk);
1039
+ });
1040
+ client.on("data", (chunk) => {
1041
+ if (!target.destroyed) target.write(chunk);
1042
+ });
1043
+ target.on("end", () => {
1044
+ if (!client.destroyed) client.end();
1045
+ });
1046
+ client.on("end", () => {
1047
+ if (!target.destroyed) target.end();
1048
+ });
1049
+ });
1050
+ }
1051
+ async function handleConnection(client) {
1052
+ resetTimer();
1053
+ client.on("error", () => {
1054
+ });
1055
+ if (serviceReady && isAlive()) {
1056
+ pipeToTarget(client);
1057
+ return;
1058
+ }
1059
+ pendingConns.push(client);
1060
+ client.on("close", () => {
1061
+ pendingConns = pendingConns.filter((s) => s !== client);
1062
+ });
1063
+ if (starting) return;
1064
+ starting = true;
1065
+ onLog?.("\u26A1 on-demand start");
1066
+ try {
1067
+ await onDemandStart();
1068
+ const ok = await waitForPort(targetPort, { timeout: 45e3, interval: 500 });
1069
+ if (ok) serviceReady = true;
1070
+ else onLog?.("\u26A0 timeout waiting for service");
1071
+ } catch (e) {
1072
+ onLog?.(`\u274C start failed: ${e.message}`);
1073
+ }
1074
+ starting = false;
1075
+ const conns = pendingConns.splice(0);
1076
+ for (const conn of conns) {
1077
+ if (!conn.destroyed) pipeToTarget(conn);
1078
+ }
1079
+ }
1080
+ const server = net2.createServer({ allowHalfOpen: true }, (socket) => handleConnection(socket));
1081
+ server.listen(listenPort, "0.0.0.0");
1082
+ resetTimer();
1083
+ return {
1084
+ server,
1085
+ resetTimer,
1086
+ destroy: () => {
1087
+ if (idleTimer) clearTimeout(idleTimer);
1088
+ pendingConns.forEach((s) => s.destroy());
1089
+ server.close();
1090
+ }
1091
+ };
1092
+ }
1093
+
1094
+ // src/tui/App.tsx
1095
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1096
+ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts }) {
1097
+ const { stdout } = useStdout();
1098
+ const rows = stdout?.rows ?? 40;
1099
+ const logsHeight = Math.floor(rows * 0.65);
1100
+ const statsHeight = rows - logsHeight - 2;
1101
+ const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
1102
+ const pm = useProcessManager(platform, baseCwd, env);
1103
+ const [booted, setBooted] = useState5(false);
1104
+ const lazyProxies = useRef3(/* @__PURE__ */ new Map());
1105
+ const kb = useKeyBindings({
1106
+ onQuit: () => {
1107
+ lazyProxies.current.forEach((p) => p.destroy());
1108
+ pm.cleanup();
1109
+ process.exit(0);
1110
+ },
1111
+ onClearLogs: () => {
1112
+ },
1113
+ onToggleProxy: () => {
1114
+ }
1115
+ });
1116
+ useProxySync(proxyProvider, proxyOpts, pm.states, kb.proxyEnabled);
1117
+ useEffect3(() => {
1118
+ if (booted || !pm.manager) return;
1119
+ setBooted(true);
1120
+ const mgr = pm.manager;
1121
+ (async () => {
1122
+ const lazyMode = cliArgs.lazy;
1123
+ const lazyTimeout = cliArgs.lazyTimeout;
1124
+ if (lazyMode && config.lazy) {
1125
+ const { alwaysOn, lazy } = classifyServices(services, config.lazy);
1126
+ const aoPhases = groupByPhase(alwaysOn);
1127
+ let colorIdx = 0;
1128
+ for (const num of Object.keys(aoPhases).map(Number).sort((a, b) => a - b)) {
1129
+ const svcs = aoPhases[num];
1130
+ for (const svc of svcs) {
1131
+ await mgr.install(svc);
1132
+ await mgr.start(svc, colorIdx++);
1133
+ }
1134
+ const apis = svcs.filter((s) => s.type === "api");
1135
+ if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
1136
+ svcs.filter((s) => s.type === "web").forEach((s) => {
1137
+ const st = mgr.state.get(s.name);
1138
+ if (st) st.status = "running";
1139
+ });
1140
+ }
1141
+ for (const svc of lazy) {
1142
+ const ci = colorIdx++;
1143
+ const rewritten = rewriteServicePort(svc);
1144
+ const idleState = {
1145
+ svc: rewritten,
1146
+ proc: null,
1147
+ pid: null,
1148
+ status: "idle",
1149
+ health: "idle",
1150
+ errors: 0,
1151
+ restarts: 0,
1152
+ startedAt: null,
1153
+ intentionalStop: false,
1154
+ colorIdx: ci
1155
+ };
1156
+ mgr.state.set(svc.name, idleState);
1157
+ const proxy = createLazyProxy({
1158
+ listenPort: svc.port,
1159
+ targetPort: rewritten.realPort,
1160
+ timeoutMin: lazyTimeout,
1161
+ onDemandStart: async () => {
1162
+ await mgr.install(rewritten);
1163
+ await mgr.start(rewritten, ci);
1164
+ const ok = await waitForPort(rewritten.realPort, { timeout: 45e3 });
1165
+ const st = mgr.state.get(svc.name);
1166
+ if (st) {
1167
+ st.status = ok ? "running" : "timeout";
1168
+ if (ok) st.health = "up";
1169
+ }
1170
+ },
1171
+ onIdleStop: () => {
1172
+ mgr.stop(svc.name);
1173
+ const st = mgr.state.get(svc.name);
1174
+ if (st) {
1175
+ st.status = "idle";
1176
+ st.health = "idle";
1177
+ st.pid = null;
1178
+ st.proc = null;
1179
+ st.startedAt = null;
1180
+ }
1181
+ },
1182
+ isAlive: () => {
1183
+ const st = mgr.state.get(svc.name);
1184
+ return !!st && !!st.proc && !st.proc.killed && st.health === "up";
1185
+ }
1186
+ });
1187
+ lazyProxies.current.set(svc.name, proxy);
1188
+ }
1189
+ } else {
1190
+ const phases = groupByPhase(services);
1191
+ let colorIdx = 0;
1192
+ for (const num of Object.keys(phases).map(Number).sort((a, b) => a - b)) {
1193
+ const svcs = phases[num];
1194
+ for (const svc of svcs) {
1195
+ await mgr.install(svc);
1196
+ await mgr.start(svc, colorIdx++);
1197
+ }
1198
+ const apis = svcs.filter((s) => s.type === "api");
1199
+ if (apis.length) await Promise.all(apis.map((s) => waitForPort(s.port, { timeout: 45e3 })));
1200
+ svcs.filter((s) => s.type === "web").forEach((s) => {
1201
+ const st = mgr.state.get(s.name);
1202
+ if (st) st.status = "running";
1203
+ });
1204
+ }
1205
+ }
1206
+ })();
1207
+ }, [booted, pm.manager, services, cliArgs, config.lazy]);
1208
+ const handleFilterSelect = useCallback3((name) => kb.setFilter(name), [kb]);
1209
+ const handleRestartSelect = useCallback3((name) => {
1210
+ pm.restart(name);
1211
+ kb.setModal("none");
1212
+ }, [pm, kb]);
1213
+ const handleOpenSelect = useCallback3((name) => {
1214
+ const st = pm.states.get(name);
1215
+ if (st) platform.openBrowser(`http://localhost:${st.svc.port}`);
1216
+ kb.setModal("none");
1217
+ }, [pm, platform, kb]);
1218
+ const icon = config.icon ?? "\u{1F4E6}";
1219
+ const modeLabel = cliArgs.lazy && config.lazy ? "lazy" : "normal";
1220
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: rows, children: [
1221
+ /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsxs6(Text6, { bold: true, color: "cyan", children: [
1222
+ " ",
1223
+ icon,
1224
+ " ",
1225
+ config.name,
1226
+ " \u2014 devup \u2014 ",
1227
+ services.length,
1228
+ " services (",
1229
+ modeLabel,
1230
+ ") "
1231
+ ] }) }),
1232
+ /* @__PURE__ */ jsx6(
1233
+ LogsPanel,
1234
+ {
1235
+ logs: pm.logs,
1236
+ filter: kb.logFilter,
1237
+ searchTerm: kb.searchTerm,
1238
+ paused: kb.logsPaused,
1239
+ showTimestamps: kb.showTimestamps,
1240
+ maxNameLen,
1241
+ height: logsHeight,
1242
+ focused: kb.panel === "logs"
1243
+ }
1244
+ ),
1245
+ /* @__PURE__ */ jsx6(
1246
+ StatsPanel,
1247
+ {
1248
+ states: pm.states,
1249
+ stats: pm.stats,
1250
+ sortMode: kb.sortMode,
1251
+ maxNameLen,
1252
+ height: statsHeight,
1253
+ focused: kb.panel === "stats"
1254
+ }
1255
+ ),
1256
+ kb.modal === "filter" && /* @__PURE__ */ jsx6(ServiceList, { title: "Filter by service", services: pm.states, onSelect: handleFilterSelect, onClose: () => kb.setModal("none") }),
1257
+ kb.modal === "restart" && /* @__PURE__ */ jsx6(ServiceList, { title: "Restart service", services: pm.states, onSelect: handleRestartSelect, onClose: () => kb.setModal("none") }),
1258
+ kb.modal === "open" && /* @__PURE__ */ jsx6(ServiceList, { title: "Open in browser", services: pm.states, onSelect: handleOpenSelect, onClose: () => kb.setModal("none"), filterType: "web" }),
1259
+ kb.modal === "search" && /* @__PURE__ */ jsx6(SearchInput, { onSubmit: kb.setSearch, onClose: () => kb.setModal("none") }),
1260
+ /* @__PURE__ */ jsx6(StatusBar, {})
1261
+ ] });
1262
+ }
1263
+
1264
+ // src/config/types.ts
1265
+ function defineConfig(config) {
1266
+ return config;
1267
+ }
1268
+
1269
+ // src/index.ts
1270
+ async function main() {
1271
+ const cwd = process.cwd();
1272
+ const cliArgs = parseCliArgs(process.argv.slice(2));
1273
+ let configPath;
1274
+ try {
1275
+ configPath = findConfigFile(cwd, cliArgs.configPath);
1276
+ } catch (e) {
1277
+ console.error(`\u274C ${e.message}`);
1278
+ process.exit(1);
1279
+ }
1280
+ const config = await loadConfig(configPath);
1281
+ const errors = validateConfig(config, cwd);
1282
+ if (errors.length) {
1283
+ console.error(`\u274C Config validation failed:
1284
+ ${formatValidationErrors(errors)}`);
1285
+ process.exit(1);
1286
+ }
1287
+ const services = filterServices(config.services, cliArgs);
1288
+ if (!services.length) {
1289
+ console.error("\u274C No services to run after filtering");
1290
+ process.exit(1);
1291
+ }
1292
+ const platform = await detectPlatform();
1293
+ const envFile = config.envFile ? join4(cwd, config.envFile) : join4(cwd, ".env");
1294
+ const env = parseEnvFile(envFile, process.env);
1295
+ if (config.env) {
1296
+ for (const [k, v] of Object.entries(config.env)) {
1297
+ if (!env[k]) env[k] = v;
1298
+ }
1299
+ }
1300
+ let proxyProvider = null;
1301
+ let proxyOpts = null;
1302
+ if (cliArgs.proxy && config.proxy) {
1303
+ proxyProvider = detectProxyProvider(config.proxy.provider);
1304
+ proxyOpts = {
1305
+ host: cliArgs.proxyHost ?? config.proxy.host ?? platform.defaultTraefikHost,
1306
+ domain: env["GUESTHUB_DOMAIN"] ?? env["DOMAIN"] ?? "localhost",
1307
+ routes: config.proxy.routes,
1308
+ tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
1309
+ entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
1310
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join4(homedir(), ".traefik", "traefik_conf.yaml")
1311
+ };
1312
+ }
1313
+ const isInteractive = process.stdin.isTTY ?? false;
1314
+ const { waitUntilExit } = render(
1315
+ React4.createElement(App, {
1316
+ config,
1317
+ services,
1318
+ cliArgs,
1319
+ platform,
1320
+ env,
1321
+ baseCwd: cwd,
1322
+ proxyProvider,
1323
+ proxyOpts
1324
+ }),
1325
+ { exitOnCtrlC: false, patchConsole: isInteractive, interactive: isInteractive }
1326
+ );
1327
+ await waitUntilExit();
1328
+ }
1329
+ main().catch((e) => {
1330
+ console.error(e);
1331
+ process.exit(1);
1332
+ });
1333
+ export {
1334
+ defineConfig
1335
+ };
1336
+ //# sourceMappingURL=index.js.map