numux 1.5.1 → 1.6.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 (4) hide show
  1. package/README.md +15 -12
  2. package/dist/bin.js +20 -2608
  3. package/dist/numux.js +2614 -0
  4. package/package.json +3 -4
package/dist/numux.js ADDED
@@ -0,0 +1,2614 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+ var __create = Object.create;
4
+ var __getProtoOf = Object.getPrototypeOf;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __toESM = (mod, isNodeMode, target) => {
9
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
10
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
+ for (let key of __getOwnPropNames(mod))
12
+ if (!__hasOwnProp.call(to, key))
13
+ __defProp(to, key, {
14
+ get: () => mod[key],
15
+ enumerable: true
16
+ });
17
+ return to;
18
+ };
19
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
20
+
21
+ // package.json
22
+ var require_package = __commonJS((exports, module) => {
23
+ module.exports = {
24
+ name: "numux",
25
+ version: "1.6.0",
26
+ description: "Terminal multiplexer with dependency orchestration",
27
+ type: "module",
28
+ license: "MIT",
29
+ author: "hyldmo",
30
+ repository: {
31
+ type: "git",
32
+ url: "https://github.com/hyldmo/numux.git"
33
+ },
34
+ keywords: [
35
+ "terminal",
36
+ "multiplexer",
37
+ "process-manager",
38
+ "tui",
39
+ "dev-tools",
40
+ "orchestration"
41
+ ],
42
+ engines: {
43
+ bun: ">=1.0"
44
+ },
45
+ bin: {
46
+ numux: "dist/bin.js"
47
+ },
48
+ exports: {
49
+ ".": {
50
+ types: "./dist/config.d.ts",
51
+ import: "./dist/config.js"
52
+ }
53
+ },
54
+ scripts: {
55
+ build: "bun build src/index.ts --outfile dist/numux.js --target bun --packages external && bun build src/config.ts --outfile dist/config.js --packages external && bunx tsc src/config.ts src/types.ts --emitDeclarationOnly --declaration --outDir dist --target ESNext --module ESNext --moduleResolution bundler && cp src/bin-wrapper.js dist/bin.js",
56
+ prepublishOnly: "bun run build",
57
+ dev: "cd example && bun run dev --debug",
58
+ test: "bun test",
59
+ typecheck: "bunx tsc --noEmit",
60
+ lint: "biome check .",
61
+ fix: "biome check . --fix --unsafe"
62
+ },
63
+ files: [
64
+ "dist/"
65
+ ],
66
+ dependencies: {
67
+ "@opentui/core": "^0.1.81",
68
+ "ghostty-opentui": "^1.4.3"
69
+ },
70
+ devDependencies: {
71
+ "@biomejs/biome": "^2.4.4",
72
+ "@types/bun": "^1.3.9"
73
+ }
74
+ };
75
+ });
76
+
77
+ // src/index.ts
78
+ import { existsSync as existsSync4, writeFileSync } from "fs";
79
+ import { resolve as resolve7 } from "path";
80
+
81
+ // src/cli.ts
82
+ function parseArgs(argv) {
83
+ const result = {
84
+ help: false,
85
+ version: false,
86
+ debug: false,
87
+ init: false,
88
+ validate: false,
89
+ exec: false,
90
+ prefix: false,
91
+ killOthers: false,
92
+ timestamps: false,
93
+ noRestart: false,
94
+ noWatch: false,
95
+ configPath: undefined,
96
+ commands: [],
97
+ named: []
98
+ };
99
+ const args = argv.slice(2);
100
+ let i = 0;
101
+ const consumeValue = (flag) => {
102
+ const next = args[++i];
103
+ if (next === undefined) {
104
+ throw new Error(`Missing value for ${flag}`);
105
+ }
106
+ return next;
107
+ };
108
+ while (i < args.length) {
109
+ const arg = args[i];
110
+ if (arg === "-h" || arg === "--help") {
111
+ result.help = true;
112
+ } else if (arg === "-v" || arg === "--version") {
113
+ result.version = true;
114
+ } else if (arg === "--debug") {
115
+ result.debug = true;
116
+ } else if (arg === "-p" || arg === "--prefix") {
117
+ result.prefix = true;
118
+ } else if (arg === "--kill-others") {
119
+ result.killOthers = true;
120
+ } else if (arg === "-t" || arg === "--timestamps") {
121
+ result.timestamps = true;
122
+ } else if (arg === "--no-restart") {
123
+ result.noRestart = true;
124
+ } else if (arg === "--no-watch") {
125
+ result.noWatch = true;
126
+ } else if (arg === "--config") {
127
+ result.configPath = consumeValue(arg);
128
+ } else if (arg === "-c" || arg === "--color") {
129
+ result.colors = consumeValue(arg).split(",").map((s) => s.trim()).filter(Boolean);
130
+ } else if (arg === "--log-dir") {
131
+ result.logDir = consumeValue(arg);
132
+ } else if (arg === "--only") {
133
+ result.only = consumeValue(arg).split(",").map((s) => s.trim()).filter(Boolean);
134
+ } else if (arg === "--exclude") {
135
+ result.exclude = consumeValue(arg).split(",").map((s) => s.trim()).filter(Boolean);
136
+ } else if (arg === "-n" || arg === "--name") {
137
+ const value = consumeValue(arg);
138
+ const eq = value.indexOf("=");
139
+ if (eq < 1) {
140
+ throw new Error(`Invalid --name value: expected "name=command", got "${value}"`);
141
+ }
142
+ result.named.push({
143
+ name: value.slice(0, eq),
144
+ command: value.slice(eq + 1)
145
+ });
146
+ } else if (arg === "init" && result.commands.length === 0) {
147
+ result.init = true;
148
+ } else if (arg === "validate" && result.commands.length === 0) {
149
+ result.validate = true;
150
+ } else if (arg === "exec" && result.commands.length === 0) {
151
+ result.exec = true;
152
+ const name = args[++i];
153
+ if (!name)
154
+ throw new Error("exec requires a process name");
155
+ result.execName = name;
156
+ if (args[i + 1] === "--")
157
+ i++;
158
+ const rest = args.slice(i + 1);
159
+ if (rest.length === 0)
160
+ throw new Error("exec requires a command to run");
161
+ result.execCommand = rest.join(" ");
162
+ break;
163
+ } else if (arg === "completions" && result.commands.length === 0) {
164
+ result.completions = consumeValue(arg);
165
+ } else if (!arg.startsWith("-")) {
166
+ result.commands.push(arg);
167
+ } else {
168
+ throw new Error(`Unknown option: ${arg}`);
169
+ }
170
+ i++;
171
+ }
172
+ return result;
173
+ }
174
+ function buildConfigFromArgs(commands, named, options) {
175
+ const processes = {};
176
+ const maxRestarts = options?.noRestart ? 0 : undefined;
177
+ const colors = options?.colors;
178
+ let colorIndex = 0;
179
+ for (const { name, command } of named) {
180
+ const color = colors?.[colorIndex++ % colors.length];
181
+ processes[name] = { command, persistent: true, maxRestarts, ...color ? { color } : {} };
182
+ }
183
+ for (let i = 0;i < commands.length; i++) {
184
+ const cmd = commands[i];
185
+ let name = cmd.split(/\s+/)[0].split("/").pop();
186
+ if (processes[name]) {
187
+ name = `${name}-${i}`;
188
+ }
189
+ const color = colors?.[colorIndex++ % colors.length];
190
+ processes[name] = { command: cmd, persistent: true, maxRestarts, ...color ? { color } : {} };
191
+ }
192
+ return { processes };
193
+ }
194
+ function filterConfig(config, only, exclude) {
195
+ const allNames = Object.keys(config.processes);
196
+ let selected;
197
+ if (only && only.length > 0) {
198
+ for (const name of only) {
199
+ if (!allNames.includes(name)) {
200
+ throw new Error(`--only: unknown process "${name}"`);
201
+ }
202
+ }
203
+ selected = new Set;
204
+ const queue = [...only];
205
+ while (queue.length > 0) {
206
+ const name = queue.pop();
207
+ if (selected.has(name))
208
+ continue;
209
+ selected.add(name);
210
+ const deps = config.processes[name].dependsOn ?? [];
211
+ for (const dep of deps) {
212
+ if (!selected.has(dep))
213
+ queue.push(dep);
214
+ }
215
+ }
216
+ } else {
217
+ selected = new Set(allNames);
218
+ }
219
+ if (exclude && exclude.length > 0) {
220
+ for (const name of exclude) {
221
+ if (!allNames.includes(name)) {
222
+ throw new Error(`--exclude: unknown process "${name}"`);
223
+ }
224
+ selected.delete(name);
225
+ }
226
+ }
227
+ if (selected.size === 0) {
228
+ throw new Error("No processes left after filtering");
229
+ }
230
+ const processes = {};
231
+ for (const name of selected) {
232
+ const proc = { ...config.processes[name] };
233
+ if (proc.dependsOn) {
234
+ proc.dependsOn = proc.dependsOn.filter((d) => selected.has(d));
235
+ if (proc.dependsOn.length === 0)
236
+ proc.dependsOn = undefined;
237
+ }
238
+ processes[name] = proc;
239
+ }
240
+ return { processes };
241
+ }
242
+
243
+ // src/completions.ts
244
+ var SUPPORTED_SHELLS = ["bash", "zsh", "fish"];
245
+ function generateCompletions(shell) {
246
+ switch (shell) {
247
+ case "bash":
248
+ return bashCompletions();
249
+ case "zsh":
250
+ return zshCompletions();
251
+ case "fish":
252
+ return fishCompletions();
253
+ default:
254
+ throw new Error(`Unknown shell: "${shell}". Supported: ${SUPPORTED_SHELLS.join(", ")}`);
255
+ }
256
+ }
257
+ function bashCompletions() {
258
+ return `# numux bash completions
259
+ # Add to ~/.bashrc: eval "$(numux completions bash)"
260
+ _numux() {
261
+ local cur prev
262
+ cur="\${COMP_WORDS[COMP_CWORD]}"
263
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
264
+
265
+ case "$prev" in
266
+ --config)
267
+ COMPREPLY=( $(compgen -f -- "$cur") )
268
+ return ;;
269
+ --log-dir)
270
+ COMPREPLY=( $(compgen -d -- "$cur") )
271
+ return ;;
272
+ --only|--exclude)
273
+ return ;;
274
+ -n|--name)
275
+ return ;;
276
+ completions)
277
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
278
+ return ;;
279
+ esac
280
+
281
+ if [[ "$cur" == -* ]]; then
282
+ COMPREPLY=( $(compgen -W "-h --help -v --version -c --color --config -n --name -p --prefix --only --exclude --kill-others --no-restart --no-watch -t --timestamps --log-dir --debug" -- "$cur") )
283
+ else
284
+ local subcmds="init validate exec completions"
285
+ COMPREPLY=( $(compgen -W "$subcmds" -- "$cur") )
286
+ fi
287
+ }
288
+ complete -F _numux numux`;
289
+ }
290
+ function zshCompletions() {
291
+ return `#compdef numux
292
+ # numux zsh completions
293
+ # Add to ~/.zshrc: eval "$(numux completions zsh)"
294
+ _numux() {
295
+ local -a subcmds
296
+ subcmds=(
297
+ 'init:Create a starter config file'
298
+ 'validate:Validate config and show process graph'
299
+ 'exec:Run a command in a process environment'
300
+ 'completions:Generate shell completions'
301
+ )
302
+
303
+ _arguments -s \\
304
+ '(-h --help)'{-h,--help}'[Show help]' \\
305
+ '(-v --version)'{-v,--version}'[Show version]' \\
306
+ '(-c --color)'{-c,--color}'[Comma-separated colors for processes]' \\
307
+ '--config[Config file path]:file:_files' \\
308
+ '(-n --name)'{-n,--name}'[Named process (name=command)]:named process' \\
309
+ '(-p --prefix)'{-p,--prefix}'[Prefixed output mode]' \\
310
+ '--only[Only run these processes]:processes' \\
311
+ '--exclude[Exclude these processes]:processes' \\
312
+ '--kill-others[Kill all when any exits]' \\
313
+ '--no-restart[Disable auto-restart]' \\
314
+ '--no-watch[Disable file watching]' \\
315
+ '(-t --timestamps)'{-t,--timestamps}'[Add timestamps to output]' \\
316
+ '--log-dir[Log directory]:directory:_directories' \\
317
+ '--debug[Enable debug logging]' \\
318
+ '1:subcommand:->subcmd' \\
319
+ '*:command' \\
320
+ && return
321
+
322
+ case "$state" in
323
+ subcmd)
324
+ _describe 'subcommand' subcmds
325
+ ;;
326
+ esac
327
+ }
328
+ _numux`;
329
+ }
330
+ function fishCompletions() {
331
+ return `# numux fish completions
332
+ # Add to fish: numux completions fish | source
333
+ # Or save to: ~/.config/fish/completions/numux.fish
334
+ complete -c numux -f
335
+
336
+ # Subcommands
337
+ complete -c numux -n __fish_use_subcommand -a init -d 'Create a starter config file'
338
+ complete -c numux -n __fish_use_subcommand -a validate -d 'Validate config and show process graph'
339
+ complete -c numux -n __fish_use_subcommand -a exec -d 'Run a command in a process environment'
340
+ complete -c numux -n __fish_use_subcommand -a completions -d 'Generate shell completions'
341
+
342
+ # Completions subcommand
343
+ complete -c numux -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish'
344
+
345
+ # Options
346
+ complete -c numux -s h -l help -d 'Show help'
347
+ complete -c numux -s v -l version -d 'Show version'
348
+ complete -c numux -s c -l color -r -d 'Comma-separated colors for processes'
349
+ complete -c numux -l config -rF -d 'Config file path'
350
+ complete -c numux -s n -l name -r -d 'Named process (name=command)'
351
+ complete -c numux -s p -l prefix -d 'Prefixed output mode'
352
+ complete -c numux -l only -r -d 'Only run these processes'
353
+ complete -c numux -l exclude -r -d 'Exclude these processes'
354
+ complete -c numux -l kill-others -d 'Kill all when any exits'
355
+ complete -c numux -l no-restart -d 'Disable auto-restart'
356
+ complete -c numux -l no-watch -d 'Disable file watching'
357
+ complete -c numux -s t -l timestamps -d 'Add timestamps to output'
358
+ complete -c numux -l log-dir -ra '(__fish_complete_directories)' -d 'Log directory'
359
+ complete -c numux -l debug -d 'Enable debug logging'`;
360
+ }
361
+
362
+ // src/config/expand-scripts.ts
363
+ import { existsSync, readFileSync } from "fs";
364
+ import { resolve } from "path";
365
+ var LOCKFILE_PM = [
366
+ ["bun.lockb", "bun"],
367
+ ["bun.lock", "bun"],
368
+ ["yarn.lock", "yarn"],
369
+ ["pnpm-lock.yaml", "pnpm"],
370
+ ["package-lock.json", "npm"]
371
+ ];
372
+ function detectPackageManager(pkgJson, cwd) {
373
+ const field = pkgJson.packageManager;
374
+ if (typeof field === "string") {
375
+ const name = field.split("@")[0];
376
+ if (["npm", "yarn", "pnpm", "bun"].includes(name))
377
+ return name;
378
+ }
379
+ for (const [file, pm] of LOCKFILE_PM) {
380
+ if (existsSync(resolve(cwd, file)))
381
+ return pm;
382
+ }
383
+ return "npm";
384
+ }
385
+ function isGlobPattern(name) {
386
+ return /[*?[]/.test(name);
387
+ }
388
+ function expandScriptPatterns(config, cwd) {
389
+ const entries = Object.entries(config.processes);
390
+ const hasWildcard = entries.some(([name]) => name.startsWith("npm:") || isGlobPattern(name));
391
+ if (!hasWildcard)
392
+ return config;
393
+ const dir = config.cwd ?? cwd ?? process.cwd();
394
+ const pkgPath = resolve(dir, "package.json");
395
+ if (!existsSync(pkgPath)) {
396
+ throw new Error(`Wildcard patterns require a package.json (looked in ${dir})`);
397
+ }
398
+ const pkgJson = JSON.parse(readFileSync(pkgPath, "utf-8"));
399
+ const scripts = pkgJson.scripts;
400
+ if (!scripts || typeof scripts !== "object") {
401
+ throw new Error('package.json has no "scripts" field');
402
+ }
403
+ const scriptNames = Object.keys(scripts);
404
+ const pm = detectPackageManager(pkgJson, dir);
405
+ const expanded = {};
406
+ for (const [name, value] of entries) {
407
+ if (!(name.startsWith("npm:") || isGlobPattern(name))) {
408
+ expanded[name] = value;
409
+ continue;
410
+ }
411
+ const pattern = name.startsWith("npm:") ? name.slice(4) : name;
412
+ const template = value ?? {};
413
+ if (template.command) {
414
+ throw new Error(`"${name}": wildcard processes cannot have a "command" field (commands come from package.json scripts)`);
415
+ }
416
+ const glob = new Bun.Glob(pattern);
417
+ const matches = scriptNames.filter((s) => glob.match(s));
418
+ if (matches.length === 0) {
419
+ throw new Error(`"${name}": no scripts matched pattern "${pattern}". Available scripts: ${scriptNames.join(", ")}`);
420
+ }
421
+ const colors = Array.isArray(template.color) ? template.color : undefined;
422
+ const singleColor = typeof template.color === "string" ? template.color : undefined;
423
+ for (let i = 0;i < matches.length; i++) {
424
+ const scriptName = matches[i];
425
+ if (expanded[scriptName]) {
426
+ throw new Error(`"${name}": expanded script "${scriptName}" collides with an existing process name`);
427
+ }
428
+ const color = colors ? colors[i % colors.length] : singleColor;
429
+ const { color: _color, ...rest } = template;
430
+ expanded[scriptName] = {
431
+ ...rest,
432
+ command: `${pm} run ${scriptName}`,
433
+ ...color ? { color } : {}
434
+ };
435
+ }
436
+ }
437
+ return { ...config, processes: expanded };
438
+ }
439
+
440
+ // src/config/loader.ts
441
+ import { existsSync as existsSync3 } from "fs";
442
+ import { resolve as resolve3 } from "path";
443
+
444
+ // src/utils/logger.ts
445
+ import { appendFileSync, existsSync as existsSync2, mkdirSync } from "fs";
446
+ import { resolve as resolve2 } from "path";
447
+ var enabled = false;
448
+ var logFile = "";
449
+ var debugCallback = null;
450
+ function enableDebugLog(dir) {
451
+ const logDir = dir ?? resolve2(process.cwd(), ".numux");
452
+ logFile = resolve2(logDir, "debug.log");
453
+ if (!existsSync2(logDir)) {
454
+ mkdirSync(logDir, { recursive: true });
455
+ }
456
+ enabled = true;
457
+ }
458
+ function log(...args) {
459
+ if (!enabled)
460
+ return;
461
+ try {
462
+ const timestamp = new Date().toISOString();
463
+ const formatted = args.length > 0 ? `${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}` : "";
464
+ const line = `[${timestamp}] ${formatted}`;
465
+ appendFileSync(logFile, `${line}
466
+ `);
467
+ debugCallback?.(line);
468
+ } catch {
469
+ enabled = false;
470
+ }
471
+ }
472
+
473
+ // src/config/interpolate.ts
474
+ var VAR_RE = /\$\{([^}:]+)(?::([-?])([^}]*))?\}/g;
475
+ function interpolateConfig(config) {
476
+ return interpolateValue(config);
477
+ }
478
+ function interpolateValue(value) {
479
+ if (typeof value === "string") {
480
+ return interpolateString(value);
481
+ }
482
+ if (Array.isArray(value)) {
483
+ return value.map(interpolateValue);
484
+ }
485
+ if (value && typeof value === "object") {
486
+ const result = {};
487
+ for (const [k, v] of Object.entries(value)) {
488
+ result[k] = interpolateValue(v);
489
+ }
490
+ return result;
491
+ }
492
+ return value;
493
+ }
494
+ function interpolateString(str) {
495
+ return str.replace(VAR_RE, (_match, name, operator, operand) => {
496
+ const value = process.env[name];
497
+ if (value !== undefined && value !== "") {
498
+ return value;
499
+ }
500
+ if (operator === "-") {
501
+ return operand ?? "";
502
+ }
503
+ if (operator === "?") {
504
+ throw new Error(operand || `Required variable ${name} is not set`);
505
+ }
506
+ return "";
507
+ });
508
+ }
509
+
510
+ // src/config/loader.ts
511
+ var CONFIG_FILES = ["numux.config.ts", "numux.config.js"];
512
+ async function loadConfig(configPath, cwd) {
513
+ if (configPath) {
514
+ return loadExplicitConfig(configPath);
515
+ }
516
+ return autoDetectConfig(cwd ?? process.cwd());
517
+ }
518
+ async function loadFile(path) {
519
+ try {
520
+ const mod = await import(path);
521
+ return interpolateConfig(mod.default ?? mod);
522
+ } catch (err) {
523
+ throw new Error(`Failed to load ${path}: ${err instanceof Error ? err.message : err}`, { cause: err });
524
+ }
525
+ }
526
+ async function loadExplicitConfig(configPath) {
527
+ const path = resolve3(configPath);
528
+ if (!existsSync3(path)) {
529
+ throw new Error(`Config file not found: ${path}`);
530
+ }
531
+ log(`Loading explicit config: ${path}`);
532
+ return loadFile(path);
533
+ }
534
+ async function autoDetectConfig(cwd) {
535
+ for (const file of CONFIG_FILES) {
536
+ const path = resolve3(cwd, file);
537
+ if (existsSync3(path)) {
538
+ log(`Found config file: ${path}`);
539
+ return loadFile(path);
540
+ }
541
+ }
542
+ throw new Error(`No numux config found. Create one of: ${CONFIG_FILES.join(", ")}`);
543
+ }
544
+
545
+ // src/config/resolver.ts
546
+ function resolveDependencyTiers(config) {
547
+ const names = Object.keys(config.processes);
548
+ const inDegree = new Map;
549
+ const dependents = new Map;
550
+ for (const name of names) {
551
+ inDegree.set(name, 0);
552
+ dependents.set(name, []);
553
+ }
554
+ for (const name of names) {
555
+ const deps = config.processes[name].dependsOn ?? [];
556
+ inDegree.set(name, deps.length);
557
+ for (const dep of deps) {
558
+ dependents.get(dep).push(name);
559
+ }
560
+ }
561
+ const tiers = [];
562
+ const remaining = new Set(names);
563
+ while (remaining.size > 0) {
564
+ const tier = [...remaining].filter((n) => inDegree.get(n) === 0);
565
+ if (tier.length === 0) {
566
+ const cycle = findCycle(remaining, config);
567
+ throw new Error(`Dependency cycle detected: ${cycle.join(" \u2192 ")} \u2192 ${cycle[0]}`);
568
+ }
569
+ tiers.push(tier);
570
+ for (const name of tier) {
571
+ remaining.delete(name);
572
+ for (const dep of dependents.get(name)) {
573
+ inDegree.set(dep, inDegree.get(dep) - 1);
574
+ }
575
+ }
576
+ }
577
+ return tiers;
578
+ }
579
+ function findCycle(remaining, config) {
580
+ const start = remaining.values().next().value;
581
+ const visited = new Set;
582
+ const path = [];
583
+ let current = start;
584
+ while (!visited.has(current)) {
585
+ visited.add(current);
586
+ path.push(current);
587
+ const deps = (config.processes[current].dependsOn ?? []).filter((d) => remaining.has(d));
588
+ current = deps[0];
589
+ }
590
+ const cycleStart = path.indexOf(current);
591
+ return path.slice(cycleStart);
592
+ }
593
+
594
+ // src/utils/color.ts
595
+ function hexToAnsi(hex) {
596
+ const h = hex.replace("#", "");
597
+ const r = Number.parseInt(h.slice(0, 2), 16);
598
+ const g = Number.parseInt(h.slice(2, 4), 16);
599
+ const b = Number.parseInt(h.slice(4, 6), 16);
600
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b))
601
+ return "";
602
+ return `\x1B[38;2;${r};${g};${b}m`;
603
+ }
604
+ var HEX_COLOR_RE = /^#?[0-9a-fA-F]{6}$/;
605
+ var STATUS_ANSI = {
606
+ ready: "\x1B[32m",
607
+ running: "\x1B[36m",
608
+ finished: "\x1B[32m",
609
+ failed: "\x1B[31m",
610
+ stopped: "\x1B[90m",
611
+ skipped: "\x1B[90m"
612
+ };
613
+ var ANSI_RESET = "\x1B[0m";
614
+ var ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()#][0-9A-Za-z]|\x1b[A-Za-z><=]/g;
615
+ function stripAnsi(str) {
616
+ return str.replace(ANSI_RE, "");
617
+ }
618
+ var DEFAULT_ANSI_COLORS = [
619
+ "\x1B[36m",
620
+ "\x1B[33m",
621
+ "\x1B[35m",
622
+ "\x1B[34m",
623
+ "\x1B[32m",
624
+ "\x1B[91m",
625
+ "\x1B[93m",
626
+ "\x1B[95m"
627
+ ];
628
+ var DEFAULT_HEX_COLORS = ["#00cccc", "#cccc00", "#cc00cc", "#0000cc", "#00cc00", "#ff5555", "#ffff55", "#ff55ff"];
629
+ function resolveColor(color) {
630
+ if (typeof color === "string")
631
+ return color;
632
+ if (Array.isArray(color) && color.length > 0)
633
+ return color[0];
634
+ return;
635
+ }
636
+ function buildProcessColorMap(names, config) {
637
+ const map = new Map;
638
+ if ("NO_COLOR" in process.env)
639
+ return map;
640
+ let paletteIndex = 0;
641
+ for (const name of names) {
642
+ const explicit = resolveColor(config.processes[name]?.color);
643
+ if (explicit) {
644
+ map.set(name, hexToAnsi(explicit));
645
+ } else {
646
+ map.set(name, DEFAULT_ANSI_COLORS[paletteIndex % DEFAULT_ANSI_COLORS.length]);
647
+ paletteIndex++;
648
+ }
649
+ }
650
+ return map;
651
+ }
652
+ function buildProcessHexColorMap(names, config) {
653
+ const map = new Map;
654
+ if ("NO_COLOR" in process.env)
655
+ return map;
656
+ let paletteIndex = 0;
657
+ for (const name of names) {
658
+ const explicit = resolveColor(config.processes[name]?.color);
659
+ if (explicit) {
660
+ map.set(name, explicit.startsWith("#") ? explicit : `#${explicit}`);
661
+ } else {
662
+ map.set(name, DEFAULT_HEX_COLORS[paletteIndex % DEFAULT_HEX_COLORS.length]);
663
+ paletteIndex++;
664
+ }
665
+ }
666
+ return map;
667
+ }
668
+
669
+ // src/config/validator.ts
670
+ function validateConfig(raw, warnings) {
671
+ if (!raw || typeof raw !== "object") {
672
+ throw new Error("Config must be an object");
673
+ }
674
+ const config = raw;
675
+ if (!config.processes || typeof config.processes !== "object") {
676
+ throw new Error('Config must have a "processes" object');
677
+ }
678
+ const processes = config.processes;
679
+ const names = Object.keys(processes);
680
+ if (names.length === 0) {
681
+ throw new Error("Config must define at least one process");
682
+ }
683
+ const globalCwd = typeof config.cwd === "string" ? config.cwd : undefined;
684
+ const globalEnvFile = validateStringOrStringArray(config.envFile);
685
+ let globalEnv;
686
+ if (config.env && typeof config.env === "object") {
687
+ for (const [k, v] of Object.entries(config.env)) {
688
+ if (typeof v !== "string") {
689
+ throw new Error(`env.${k} must be a string, got ${typeof v}`);
690
+ }
691
+ }
692
+ globalEnv = config.env;
693
+ }
694
+ const validated = {};
695
+ for (const name of names) {
696
+ let proc = processes[name];
697
+ if (typeof proc === "string") {
698
+ proc = { command: proc };
699
+ }
700
+ if (!proc || typeof proc !== "object") {
701
+ throw new Error(`Process "${name}" must be an object or a command string`);
702
+ }
703
+ const p = proc;
704
+ if (typeof p.command !== "string" || !p.command.trim()) {
705
+ throw new Error(`Process "${name}" must have a non-empty "command" string`);
706
+ }
707
+ if (p.dependsOn !== undefined) {
708
+ if (!Array.isArray(p.dependsOn)) {
709
+ throw new Error(`Process "${name}".dependsOn must be an array`);
710
+ }
711
+ for (const dep of p.dependsOn) {
712
+ if (typeof dep !== "string") {
713
+ throw new Error(`Process "${name}".dependsOn entries must be strings`);
714
+ }
715
+ if (!names.includes(dep)) {
716
+ throw new Error(`Process "${name}" depends on unknown process "${dep}"`);
717
+ }
718
+ if (dep === name) {
719
+ throw new Error(`Process "${name}" cannot depend on itself`);
720
+ }
721
+ }
722
+ }
723
+ if (typeof p.color === "string") {
724
+ if (!HEX_COLOR_RE.test(p.color)) {
725
+ throw new Error(`Process "${name}".color must be a valid hex color (e.g. "#ff8800"), got "${p.color}"`);
726
+ }
727
+ } else if (Array.isArray(p.color)) {
728
+ for (const c of p.color) {
729
+ if (typeof c !== "string" || !HEX_COLOR_RE.test(c)) {
730
+ throw new Error(`Process "${name}".color entries must be valid hex colors (e.g. "#ff8800"), got "${c}"`);
731
+ }
732
+ }
733
+ }
734
+ const persistent = typeof p.persistent === "boolean" ? p.persistent : true;
735
+ const readyPattern = typeof p.readyPattern === "string" ? p.readyPattern : undefined;
736
+ if (readyPattern && !persistent) {
737
+ warnings?.push({
738
+ process: name,
739
+ message: "readyPattern is ignored on non-persistent processes (readiness is determined by exit code)"
740
+ });
741
+ }
742
+ if (p.env && typeof p.env === "object") {
743
+ for (const [k, v] of Object.entries(p.env)) {
744
+ if (typeof v !== "string") {
745
+ throw new Error(`Process "${name}".env.${k} must be a string, got ${typeof v}`);
746
+ }
747
+ }
748
+ }
749
+ const processCwd = typeof p.cwd === "string" ? p.cwd : undefined;
750
+ const processEnv = p.env && typeof p.env === "object" ? p.env : undefined;
751
+ const processEnvFile = validateEnvFile(p.envFile);
752
+ validated[name] = {
753
+ command: p.command,
754
+ cwd: processCwd ?? globalCwd,
755
+ env: globalEnv || processEnv ? { ...globalEnv, ...processEnv } : undefined,
756
+ envFile: processEnvFile ?? globalEnvFile,
757
+ dependsOn: Array.isArray(p.dependsOn) ? p.dependsOn : undefined,
758
+ readyPattern,
759
+ persistent,
760
+ maxRestarts: typeof p.maxRestarts === "number" && p.maxRestarts >= 0 ? p.maxRestarts : undefined,
761
+ readyTimeout: typeof p.readyTimeout === "number" && p.readyTimeout > 0 ? p.readyTimeout : undefined,
762
+ delay: typeof p.delay === "number" && p.delay > 0 ? p.delay : undefined,
763
+ condition: typeof p.condition === "string" && p.condition.trim() ? p.condition.trim() : undefined,
764
+ stopSignal: validateStopSignal(p.stopSignal),
765
+ color: typeof p.color === "string" ? p.color : Array.isArray(p.color) ? p.color : undefined,
766
+ watch: validateStringOrStringArray(p.watch),
767
+ interactive: typeof p.interactive === "boolean" ? p.interactive : false
768
+ };
769
+ }
770
+ return { processes: validated };
771
+ }
772
+ function validateStringOrStringArray(value) {
773
+ if (typeof value === "string")
774
+ return value;
775
+ if (Array.isArray(value) && value.every((v) => typeof v === "string"))
776
+ return value;
777
+ return;
778
+ }
779
+ var validateEnvFile = validateStringOrStringArray;
780
+ var VALID_STOP_SIGNALS = new Set(["SIGTERM", "SIGINT", "SIGHUP"]);
781
+ function validateStopSignal(value) {
782
+ if (typeof value === "string" && VALID_STOP_SIGNALS.has(value)) {
783
+ return value;
784
+ }
785
+ return;
786
+ }
787
+
788
+ // src/process/manager.ts
789
+ import { resolve as resolve6 } from "path";
790
+
791
+ // src/utils/watcher.ts
792
+ import { watch } from "fs";
793
+ var DEBOUNCE_MS = 300;
794
+ var IGNORED_SEGMENTS = new Set(["node_modules", ".git"]);
795
+
796
+ class FileWatcher {
797
+ watchers = [];
798
+ debounceTimers = new Map;
799
+ watch(name, patterns, cwd, onChanged) {
800
+ const globs = patterns.map((p) => new Bun.Glob(p));
801
+ try {
802
+ const watcher = watch(cwd, { recursive: true }, (_event, filename) => {
803
+ if (!filename)
804
+ return;
805
+ const segments = filename.split("/");
806
+ if (segments.some((s) => IGNORED_SEGMENTS.has(s)))
807
+ return;
808
+ if (!globs.some((g) => g.match(filename)))
809
+ return;
810
+ const existing = this.debounceTimers.get(name);
811
+ if (existing)
812
+ clearTimeout(existing);
813
+ this.debounceTimers.set(name, setTimeout(() => {
814
+ this.debounceTimers.delete(name);
815
+ onChanged(filename);
816
+ }, DEBOUNCE_MS));
817
+ });
818
+ this.watchers.push(watcher);
819
+ log(`[${name}] Watching: ${patterns.join(", ")}`);
820
+ } catch (err) {
821
+ log(`[${name}] Failed to set up file watcher: ${err}`);
822
+ }
823
+ }
824
+ close() {
825
+ for (const timer of this.debounceTimers.values()) {
826
+ clearTimeout(timer);
827
+ }
828
+ this.debounceTimers.clear();
829
+ for (const watcher of this.watchers) {
830
+ watcher.close();
831
+ }
832
+ this.watchers = [];
833
+ }
834
+ }
835
+
836
+ // src/process/runner.ts
837
+ import { resolve as resolve5 } from "path";
838
+
839
+ // src/utils/env-file.ts
840
+ import { readFileSync as readFileSync2 } from "fs";
841
+ import { resolve as resolve4 } from "path";
842
+ function parseEnvFile(content) {
843
+ const vars = {};
844
+ for (const line of content.split(/\r?\n/)) {
845
+ const trimmed = line.trim();
846
+ if (!trimmed || trimmed.startsWith("#"))
847
+ continue;
848
+ const eqIndex = trimmed.indexOf("=");
849
+ if (eqIndex < 1)
850
+ continue;
851
+ const key = trimmed.slice(0, eqIndex).trim();
852
+ let value = trimmed.slice(eqIndex + 1).trim();
853
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
854
+ value = value.slice(1, -1);
855
+ } else {
856
+ const commentIndex = value.indexOf(" #");
857
+ if (commentIndex !== -1) {
858
+ value = value.slice(0, commentIndex).trimEnd();
859
+ }
860
+ }
861
+ vars[key] = value;
862
+ }
863
+ return vars;
864
+ }
865
+ function loadEnvFiles(envFile, cwd) {
866
+ const files = Array.isArray(envFile) ? envFile : [envFile];
867
+ const merged = {};
868
+ for (const file of files) {
869
+ const path = resolve4(cwd, file);
870
+ let content;
871
+ try {
872
+ content = readFileSync2(path, "utf-8");
873
+ } catch (err) {
874
+ const code = err.code;
875
+ if (code === "ENOENT") {
876
+ throw new Error(`envFile not found: ${path}`, { cause: err });
877
+ }
878
+ throw new Error(`Failed to read envFile "${path}": ${err instanceof Error ? err.message : err}`, {
879
+ cause: err
880
+ });
881
+ }
882
+ Object.assign(merged, parseEnvFile(content));
883
+ }
884
+ return merged;
885
+ }
886
+
887
+ // src/process/ready.ts
888
+ var BUFFER_CAP = 65536;
889
+ function createReadinessChecker(config) {
890
+ const pattern = config.readyPattern ? new RegExp(config.readyPattern) : null;
891
+ const persistent = config.persistent !== false;
892
+ let outputBuffer = "";
893
+ return {
894
+ feedOutput(data) {
895
+ if (!(persistent && pattern))
896
+ return false;
897
+ outputBuffer += data;
898
+ if (outputBuffer.length > BUFFER_CAP) {
899
+ outputBuffer = outputBuffer.slice(-BUFFER_CAP);
900
+ }
901
+ return pattern.test(outputBuffer);
902
+ },
903
+ get isImmediatelyReady() {
904
+ return persistent && !pattern;
905
+ },
906
+ get dependsOnExit() {
907
+ return !persistent;
908
+ }
909
+ };
910
+ }
911
+
912
+ // src/process/runner.ts
913
+ class ProcessRunner {
914
+ name;
915
+ config;
916
+ handler;
917
+ proc = null;
918
+ readiness;
919
+ _ready = false;
920
+ stopping = false;
921
+ decoder = new TextDecoder;
922
+ generation = 0;
923
+ readyTimer = null;
924
+ restarting = false;
925
+ readyTimedOut = false;
926
+ constructor(name, config, handler) {
927
+ this.name = name;
928
+ this.config = config;
929
+ this.handler = handler;
930
+ this.readiness = createReadinessChecker(config);
931
+ }
932
+ get isReady() {
933
+ return this._ready;
934
+ }
935
+ get signal() {
936
+ return this.config.stopSignal ?? "SIGTERM";
937
+ }
938
+ start(cols, rows) {
939
+ const gen = ++this.generation;
940
+ this.stopping = false;
941
+ log(`[${this.name}] Starting (gen ${gen}): ${this.config.command}`);
942
+ this.handler.onStatus("starting");
943
+ const cwd = this.config.cwd ? resolve5(this.config.cwd) : process.cwd();
944
+ try {
945
+ const envFromFile = this.config.envFile ? loadEnvFiles(this.config.envFile, cwd) : {};
946
+ const noColor = "NO_COLOR" in process.env;
947
+ const env = {
948
+ ...process.env,
949
+ ...noColor ? {} : { FORCE_COLOR: "1" },
950
+ TERM: "xterm-256color",
951
+ ...envFromFile,
952
+ ...this.config.env
953
+ };
954
+ this.proc = Bun.spawn(["sh", "-c", this.config.command], {
955
+ cwd,
956
+ env,
957
+ terminal: {
958
+ cols,
959
+ rows,
960
+ data: (_terminal, data) => {
961
+ if (this.generation !== gen)
962
+ return;
963
+ this.handler.onOutput(data);
964
+ this.checkReadiness(data);
965
+ }
966
+ }
967
+ });
968
+ } catch (err) {
969
+ log(`[${this.name}] Spawn failed: ${err}`);
970
+ const encoder = new TextEncoder;
971
+ const msg = `\r
972
+ \x1B[31m[numux] failed to start: ${err instanceof Error ? err.message : err}\x1B[0m\r
973
+ `;
974
+ this.handler.onOutput(encoder.encode(msg));
975
+ this.handler.onStatus("failed");
976
+ this.handler.onExit(null);
977
+ return;
978
+ }
979
+ this.handler.onStatus(this.config.persistent !== false ? "running" : "starting");
980
+ if (this.readiness.isImmediatelyReady) {
981
+ this.markReady();
982
+ }
983
+ this.startReadyTimeout(gen);
984
+ this.proc.exited.then((code) => {
985
+ if (this.generation !== gen)
986
+ return;
987
+ log(`[${this.name}] Exited with code ${code}`);
988
+ if (this.readiness.dependsOnExit && code === 0) {
989
+ this.markReady();
990
+ }
991
+ if (code === 127 || code === 126) {
992
+ const encoder = new TextEncoder;
993
+ const hint = code === 127 ? "command not found" : "permission denied";
994
+ const msg = `\r
995
+ \x1B[31m[numux] exit ${code}: ${hint}\x1B[0m\r
996
+ `;
997
+ this.handler.onOutput(encoder.encode(msg));
998
+ }
999
+ if (!this.readyTimedOut) {
1000
+ const status = this.stopping ? "stopped" : code === 0 ? "finished" : "failed";
1001
+ this.handler.onStatus(status);
1002
+ this.handler.onExit(code);
1003
+ }
1004
+ }).catch((err) => {
1005
+ if (this.generation !== gen)
1006
+ return;
1007
+ log(`[${this.name}] proc.exited rejected: ${err}`);
1008
+ this.handler.onStatus("failed");
1009
+ this.handler.onExit(null);
1010
+ });
1011
+ }
1012
+ checkReadiness(data) {
1013
+ if (this._ready)
1014
+ return;
1015
+ const text = this.decoder.decode(data, { stream: true });
1016
+ if (this.readiness.feedOutput(text)) {
1017
+ this.markReady();
1018
+ }
1019
+ }
1020
+ startReadyTimeout(gen) {
1021
+ const timeout = this.config.readyTimeout;
1022
+ if (!(timeout && this.config.readyPattern) || this.config.persistent === false)
1023
+ return;
1024
+ this.readyTimer = setTimeout(() => {
1025
+ this.readyTimer = null;
1026
+ if (this.generation !== gen || this._ready)
1027
+ return;
1028
+ this.readyTimedOut = true;
1029
+ log(`[${this.name}] Ready timeout after ${timeout}ms`);
1030
+ const encoder = new TextEncoder;
1031
+ const msg = `\r
1032
+ \x1B[31m[numux] readyPattern not matched within ${(timeout / 1000).toFixed(0)}s \u2014 marking as failed\x1B[0m\r
1033
+ `;
1034
+ this.handler.onOutput(encoder.encode(msg));
1035
+ this.handler.onStatus("failed");
1036
+ this.handler.onReady();
1037
+ }, timeout);
1038
+ }
1039
+ clearReadyTimeout() {
1040
+ if (this.readyTimer) {
1041
+ clearTimeout(this.readyTimer);
1042
+ this.readyTimer = null;
1043
+ }
1044
+ }
1045
+ markReady() {
1046
+ if (this._ready)
1047
+ return;
1048
+ this._ready = true;
1049
+ this.clearReadyTimeout();
1050
+ log(`[${this.name}] Ready`);
1051
+ this.handler.onStatus("ready");
1052
+ this.handler.onReady();
1053
+ }
1054
+ async restart(cols, rows) {
1055
+ if (this.restarting)
1056
+ return;
1057
+ this.restarting = true;
1058
+ log(`[${this.name}] Restarting`);
1059
+ this.clearReadyTimeout();
1060
+ if (this.proc) {
1061
+ this.stopping = true;
1062
+ this.handler.onStatus("stopping");
1063
+ this.killProcessGroup(this.signal);
1064
+ const result = await Promise.race([
1065
+ this.proc.exited.then(() => "exited"),
1066
+ new Promise((r) => setTimeout(() => r("timeout"), 2000))
1067
+ ]);
1068
+ if (result === "timeout" && this.proc) {
1069
+ this.killProcessGroup("SIGKILL");
1070
+ await this.proc.exited;
1071
+ }
1072
+ }
1073
+ this.proc = null;
1074
+ this._ready = false;
1075
+ this.restarting = false;
1076
+ this.readyTimedOut = false;
1077
+ this.readiness = createReadinessChecker(this.config);
1078
+ this.start(cols, rows);
1079
+ }
1080
+ async stop(timeoutMs = 5000) {
1081
+ if (!this.proc)
1082
+ return;
1083
+ this.clearReadyTimeout();
1084
+ this.stopping = true;
1085
+ log(`[${this.name}] Stopping (timeout: ${timeoutMs}ms)`);
1086
+ this.handler.onStatus("stopping");
1087
+ this.killProcessGroup(this.signal);
1088
+ const exited = Promise.race([
1089
+ this.proc.exited,
1090
+ new Promise((r) => setTimeout(() => r("timeout"), timeoutMs))
1091
+ ]);
1092
+ const result = await exited;
1093
+ if (result === "timeout") {
1094
+ this.killProcessGroup("SIGKILL");
1095
+ await this.proc.exited;
1096
+ }
1097
+ this.proc = null;
1098
+ }
1099
+ killProcessGroup(sig) {
1100
+ if (!this.proc)
1101
+ return;
1102
+ try {
1103
+ process.kill(-this.proc.pid, sig);
1104
+ } catch {
1105
+ try {
1106
+ this.proc.kill(sig);
1107
+ } catch {}
1108
+ }
1109
+ }
1110
+ resize(cols, rows) {
1111
+ if (this.proc?.terminal) {
1112
+ this.proc.terminal.resize(cols, rows);
1113
+ }
1114
+ }
1115
+ write(data) {
1116
+ if (this.config.interactive && this.proc?.terminal) {
1117
+ this.proc.terminal.write(data);
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ // src/process/manager.ts
1123
+ var BACKOFF_BASE_MS = 1000;
1124
+ var BACKOFF_MAX_MS = 30000;
1125
+ var BACKOFF_RESET_MS = 1e4;
1126
+
1127
+ class ProcessManager {
1128
+ config;
1129
+ runners = new Map;
1130
+ states = new Map;
1131
+ tiers;
1132
+ listeners = [];
1133
+ stopping = false;
1134
+ lastCols = 80;
1135
+ lastRows = 24;
1136
+ restartAttempts = new Map;
1137
+ restartTimers = new Map;
1138
+ startTimes = new Map;
1139
+ pendingReadyResolvers = new Map;
1140
+ fileWatcher;
1141
+ constructor(config) {
1142
+ this.config = config;
1143
+ this.tiers = resolveDependencyTiers(config);
1144
+ log(`Resolved ${this.tiers.length} dependency tiers:`, this.tiers);
1145
+ for (const [name, proc] of Object.entries(config.processes)) {
1146
+ this.states.set(name, {
1147
+ name,
1148
+ config: proc,
1149
+ status: "pending",
1150
+ exitCode: null,
1151
+ restartCount: 0
1152
+ });
1153
+ }
1154
+ }
1155
+ on(listener) {
1156
+ this.listeners.push(listener);
1157
+ }
1158
+ emit(event) {
1159
+ for (const listener of this.listeners) {
1160
+ listener(event);
1161
+ }
1162
+ }
1163
+ getState(name) {
1164
+ return this.states.get(name);
1165
+ }
1166
+ getAllStates() {
1167
+ return [...this.states.values()];
1168
+ }
1169
+ getProcessNames() {
1170
+ return this.tiers.flat();
1171
+ }
1172
+ async startAll(cols, rows) {
1173
+ log("Starting all processes");
1174
+ this.lastCols = cols;
1175
+ this.lastRows = rows;
1176
+ for (const tier of this.tiers) {
1177
+ const readyPromises = [];
1178
+ for (const name of tier) {
1179
+ const proc = this.config.processes[name];
1180
+ if (proc.condition && !evaluateCondition(proc.condition)) {
1181
+ log(`Skipping ${name}: condition "${proc.condition}" not met`);
1182
+ this.updateStatus(name, "skipped");
1183
+ continue;
1184
+ }
1185
+ const deps = proc.dependsOn ?? [];
1186
+ const failedDep = deps.find((d) => {
1187
+ const s = this.states.get(d).status;
1188
+ return s === "failed" || s === "skipped";
1189
+ });
1190
+ if (failedDep) {
1191
+ log(`Skipping ${name}: dependency ${failedDep} failed`);
1192
+ this.updateStatus(name, "skipped");
1193
+ continue;
1194
+ }
1195
+ const { promise, resolve: resolve7 } = Promise.withResolvers();
1196
+ readyPromises.push(promise);
1197
+ this.pendingReadyResolvers.set(name, resolve7);
1198
+ this.createRunner(name, () => {
1199
+ this.pendingReadyResolvers.delete(name);
1200
+ resolve7();
1201
+ });
1202
+ this.startProcess(name, cols, rows);
1203
+ }
1204
+ if (readyPromises.length > 0) {
1205
+ await Promise.all(readyPromises);
1206
+ }
1207
+ }
1208
+ this.setupWatchers();
1209
+ }
1210
+ startProcess(name, cols, rows) {
1211
+ const delay = this.config.processes[name].delay;
1212
+ if (delay) {
1213
+ log(`[${name}] Delaying start by ${delay}ms`);
1214
+ const timer = setTimeout(() => {
1215
+ this.restartTimers.delete(name);
1216
+ if (this.stopping)
1217
+ return;
1218
+ this.startTimes.set(name, Date.now());
1219
+ this.runners.get(name).start(cols, rows);
1220
+ }, delay);
1221
+ this.restartTimers.set(name, timer);
1222
+ } else {
1223
+ this.startTimes.set(name, Date.now());
1224
+ this.runners.get(name).start(cols, rows);
1225
+ }
1226
+ }
1227
+ createRunner(name, onInitialReady) {
1228
+ let readyResolved = !onInitialReady;
1229
+ const runner = new ProcessRunner(name, this.config.processes[name], {
1230
+ onStatus: (status) => this.updateStatus(name, status),
1231
+ onOutput: (data) => this.emit({ type: "output", name, data }),
1232
+ onExit: (code) => {
1233
+ const state = this.states.get(name);
1234
+ state.exitCode = code;
1235
+ this.emit({ type: "exit", name, code });
1236
+ if (!readyResolved) {
1237
+ readyResolved = true;
1238
+ onInitialReady();
1239
+ }
1240
+ this.scheduleAutoRestart(name, code);
1241
+ },
1242
+ onReady: () => {
1243
+ if (!readyResolved) {
1244
+ readyResolved = true;
1245
+ onInitialReady();
1246
+ }
1247
+ }
1248
+ });
1249
+ this.runners.set(name, runner);
1250
+ }
1251
+ scheduleAutoRestart(name, exitCode) {
1252
+ if (this.stopping)
1253
+ return;
1254
+ const proc = this.config.processes[name];
1255
+ if (proc.persistent === false)
1256
+ return;
1257
+ if (exitCode === 0)
1258
+ return;
1259
+ if (exitCode === null)
1260
+ return;
1261
+ log(`Scheduling auto-restart for ${name} (exit code: ${exitCode})`);
1262
+ const startTime = this.startTimes.get(name) ?? 0;
1263
+ if (Date.now() - startTime > BACKOFF_RESET_MS) {
1264
+ this.restartAttempts.set(name, 0);
1265
+ }
1266
+ const attempt = this.restartAttempts.get(name) ?? 0;
1267
+ const maxRestarts = proc.maxRestarts;
1268
+ if (maxRestarts !== undefined && attempt >= maxRestarts) {
1269
+ log(`[${name}] Reached maxRestarts limit (${maxRestarts}), not restarting`);
1270
+ const encoder2 = new TextEncoder;
1271
+ const msg2 = `\r
1272
+ \x1B[31m[numux] reached restart limit (${maxRestarts}), giving up\x1B[0m\r
1273
+ `;
1274
+ this.emit({ type: "output", name, data: encoder2.encode(msg2) });
1275
+ return;
1276
+ }
1277
+ const delay = Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_MAX_MS);
1278
+ this.restartAttempts.set(name, attempt + 1);
1279
+ const encoder = new TextEncoder;
1280
+ const msg = `\r
1281
+ \x1B[33m[numux] restarting in ${(delay / 1000).toFixed(0)}s (attempt ${attempt + 1}${maxRestarts !== undefined ? `/${maxRestarts}` : ""})...\x1B[0m\r
1282
+ `;
1283
+ this.emit({ type: "output", name, data: encoder.encode(msg) });
1284
+ const timer = setTimeout(() => {
1285
+ this.restartTimers.delete(name);
1286
+ if (this.stopping)
1287
+ return;
1288
+ const runner = this.runners.get(name);
1289
+ if (!runner)
1290
+ return;
1291
+ const state = this.states.get(name);
1292
+ if (state)
1293
+ state.restartCount++;
1294
+ this.startTimes.set(name, Date.now());
1295
+ runner.restart(this.lastCols, this.lastRows);
1296
+ }, delay);
1297
+ this.restartTimers.set(name, timer);
1298
+ }
1299
+ setupWatchers() {
1300
+ const encoder = new TextEncoder;
1301
+ for (const [name, proc] of Object.entries(this.config.processes)) {
1302
+ if (!proc.watch)
1303
+ continue;
1304
+ if (!this.fileWatcher)
1305
+ this.fileWatcher = new FileWatcher;
1306
+ const patterns = Array.isArray(proc.watch) ? proc.watch : [proc.watch];
1307
+ const cwd = proc.cwd ? resolve6(proc.cwd) : process.cwd();
1308
+ this.fileWatcher.watch(name, patterns, cwd, (changedFile) => {
1309
+ const state = this.states.get(name);
1310
+ if (!state)
1311
+ return;
1312
+ if (state.status === "pending" || state.status === "stopped" || state.status === "finished" || state.status === "stopping" || state.status === "skipped")
1313
+ return;
1314
+ log(`[${name}] File changed: ${changedFile}, restarting`);
1315
+ const msg = `\r
1316
+ \x1B[36m[numux] file changed: ${changedFile}, restarting...\x1B[0m\r
1317
+ `;
1318
+ this.emit({ type: "output", name, data: encoder.encode(msg) });
1319
+ this.restart(name, this.lastCols, this.lastRows);
1320
+ });
1321
+ }
1322
+ }
1323
+ updateStatus(name, status) {
1324
+ const state = this.states.get(name);
1325
+ state.status = status;
1326
+ this.emit({ type: "status", name, status });
1327
+ }
1328
+ restart(name, cols, rows) {
1329
+ const state = this.states.get(name);
1330
+ if (!state)
1331
+ return;
1332
+ if (state.status === "pending" || state.status === "stopping" || state.status === "skipped")
1333
+ return;
1334
+ const runner = this.runners.get(name);
1335
+ if (!runner)
1336
+ return;
1337
+ const timer = this.restartTimers.get(name);
1338
+ if (timer) {
1339
+ clearTimeout(timer);
1340
+ this.restartTimers.delete(name);
1341
+ }
1342
+ this.restartAttempts.set(name, 0);
1343
+ state.exitCode = null;
1344
+ state.restartCount++;
1345
+ this.startTimes.set(name, Date.now());
1346
+ runner.restart(cols, rows);
1347
+ }
1348
+ async stop(name) {
1349
+ const state = this.states.get(name);
1350
+ if (!state)
1351
+ return;
1352
+ if (state.status === "pending" || state.status === "stopped" || state.status === "finished" || state.status === "stopping" || state.status === "skipped")
1353
+ return;
1354
+ const timer = this.restartTimers.get(name);
1355
+ if (timer) {
1356
+ clearTimeout(timer);
1357
+ this.restartTimers.delete(name);
1358
+ }
1359
+ const runner = this.runners.get(name);
1360
+ if (!runner)
1361
+ return;
1362
+ if (state.status === "failed") {
1363
+ await runner.stop();
1364
+ this.updateStatus(name, "stopped");
1365
+ return;
1366
+ }
1367
+ await runner.stop();
1368
+ }
1369
+ start(name, cols, rows) {
1370
+ const state = this.states.get(name);
1371
+ if (!state)
1372
+ return;
1373
+ if (state.status !== "stopped" && state.status !== "finished" && state.status !== "failed")
1374
+ return;
1375
+ const timer = this.restartTimers.get(name);
1376
+ if (timer) {
1377
+ clearTimeout(timer);
1378
+ this.restartTimers.delete(name);
1379
+ }
1380
+ this.restartAttempts.set(name, 0);
1381
+ state.exitCode = null;
1382
+ state.restartCount++;
1383
+ this.startTimes.set(name, Date.now());
1384
+ this.runners.get(name)?.restart(cols, rows);
1385
+ }
1386
+ restartAll(cols, rows) {
1387
+ log("Restarting all processes");
1388
+ for (const name of this.tiers.flat()) {
1389
+ this.restart(name, cols, rows);
1390
+ }
1391
+ }
1392
+ resize(name, cols, rows) {
1393
+ this.runners.get(name)?.resize(cols, rows);
1394
+ }
1395
+ resizeAll(cols, rows) {
1396
+ this.lastCols = cols;
1397
+ this.lastRows = rows;
1398
+ for (const runner of this.runners.values()) {
1399
+ runner.resize(cols, rows);
1400
+ }
1401
+ }
1402
+ write(name, data) {
1403
+ this.runners.get(name)?.write(data);
1404
+ }
1405
+ async stopAll() {
1406
+ log("Stopping all processes");
1407
+ this.stopping = true;
1408
+ this.fileWatcher?.close();
1409
+ for (const timer of this.restartTimers.values()) {
1410
+ clearTimeout(timer);
1411
+ }
1412
+ this.restartTimers.clear();
1413
+ for (const resolve7 of this.pendingReadyResolvers.values()) {
1414
+ resolve7();
1415
+ }
1416
+ this.pendingReadyResolvers.clear();
1417
+ const reversed = [...this.tiers].reverse();
1418
+ for (const tier of reversed) {
1419
+ await Promise.allSettled(tier.map((name) => this.runners.get(name)?.stop()).filter(Boolean));
1420
+ }
1421
+ }
1422
+ }
1423
+ var FALSY_VALUES = new Set(["", "0", "false", "no", "off"]);
1424
+ function evaluateCondition(condition) {
1425
+ const negated = condition.startsWith("!");
1426
+ const varName = negated ? condition.slice(1) : condition;
1427
+ const value = process.env[varName];
1428
+ const isTruthy = value !== undefined && !FALSY_VALUES.has(value.toLowerCase());
1429
+ return negated ? !isTruthy : isTruthy;
1430
+ }
1431
+
1432
+ // src/ui/app.ts
1433
+ import { BoxRenderable, createCliRenderer } from "@opentui/core";
1434
+
1435
+ // src/ui/pane.ts
1436
+ import { ScrollBoxRenderable } from "@opentui/core";
1437
+ import { GhosttyTerminalRenderable } from "ghostty-opentui/terminal-buffer";
1438
+
1439
+ class Pane {
1440
+ scrollBox;
1441
+ terminal;
1442
+ decoder = new TextDecoder;
1443
+ _onScroll = null;
1444
+ constructor(renderer, name, cols, rows, interactive = false) {
1445
+ this.scrollBox = new ScrollBoxRenderable(renderer, {
1446
+ id: `pane-${name}`,
1447
+ flexGrow: 1,
1448
+ width: "100%",
1449
+ stickyScroll: true,
1450
+ stickyStart: "bottom",
1451
+ visible: false,
1452
+ onMouseScroll: () => this._onScroll?.()
1453
+ });
1454
+ this.terminal = new GhosttyTerminalRenderable(renderer, {
1455
+ id: `term-${name}`,
1456
+ cols,
1457
+ rows,
1458
+ persistent: true,
1459
+ showCursor: interactive,
1460
+ trimEnd: true
1461
+ });
1462
+ this.scrollBox.add(this.terminal);
1463
+ }
1464
+ feed(data) {
1465
+ const text = this.decoder.decode(data, { stream: true });
1466
+ this.terminal.feed(text);
1467
+ }
1468
+ resize(cols, rows) {
1469
+ this.terminal.cols = cols;
1470
+ this.terminal.rows = rows;
1471
+ }
1472
+ get isAtBottom() {
1473
+ const { scrollTop, scrollHeight, viewport } = this.scrollBox;
1474
+ if (scrollHeight <= 0)
1475
+ return true;
1476
+ return scrollTop >= scrollHeight - viewport.height - 2;
1477
+ }
1478
+ scrollBy(delta) {
1479
+ this.scrollBox.scrollBy(delta);
1480
+ }
1481
+ scrollToTop() {
1482
+ this.scrollBox.scrollTo(0);
1483
+ }
1484
+ scrollToBottom() {
1485
+ this.scrollBox.scrollTo(this.scrollBox.scrollHeight);
1486
+ }
1487
+ onScroll(handler) {
1488
+ this._onScroll = handler;
1489
+ }
1490
+ show() {
1491
+ this.scrollBox.visible = true;
1492
+ }
1493
+ hide() {
1494
+ this.scrollBox.visible = false;
1495
+ }
1496
+ search(query) {
1497
+ if (!query)
1498
+ return [];
1499
+ const text = this.terminal.getText();
1500
+ const lines = text.split(`
1501
+ `);
1502
+ const matches = [];
1503
+ const lowerQuery = query.toLowerCase();
1504
+ for (let line = 0;line < lines.length; line++) {
1505
+ const lowerLine = lines[line].toLowerCase();
1506
+ let pos = 0;
1507
+ while (true) {
1508
+ const idx = lowerLine.indexOf(lowerQuery, pos);
1509
+ if (idx === -1)
1510
+ break;
1511
+ matches.push({ line, start: idx, end: idx + query.length });
1512
+ pos = idx + 1;
1513
+ }
1514
+ }
1515
+ return matches;
1516
+ }
1517
+ setHighlights(matches, currentIndex) {
1518
+ const regions = matches.map((m, i) => ({
1519
+ line: m.line,
1520
+ start: m.start,
1521
+ end: m.end,
1522
+ backgroundColor: i === currentIndex ? "#b58900" : "#073642"
1523
+ }));
1524
+ this.terminal.highlights = regions;
1525
+ }
1526
+ clearHighlights() {
1527
+ this.terminal.highlights = undefined;
1528
+ }
1529
+ scrollToLine(line) {
1530
+ const pos = this.terminal.getScrollPositionForLine(line);
1531
+ this.scrollBox.scrollTo(pos);
1532
+ }
1533
+ clear() {
1534
+ this.terminal.reset();
1535
+ }
1536
+ destroy() {
1537
+ this.terminal.destroy();
1538
+ }
1539
+ }
1540
+
1541
+ // src/ui/status-bar.ts
1542
+ import { cyan, red, reverse, StyledText, TextRenderable, yellow } from "@opentui/core";
1543
+ function plain(text) {
1544
+ return { __isChunk: true, text };
1545
+ }
1546
+
1547
+ class StatusBar {
1548
+ renderable;
1549
+ _searchMode = false;
1550
+ _searchQuery = "";
1551
+ _searchMatchCount = 0;
1552
+ _searchCurrentIndex = -1;
1553
+ constructor(renderer) {
1554
+ this.renderable = new TextRenderable(renderer, {
1555
+ id: "status-bar",
1556
+ width: "100%",
1557
+ height: 1,
1558
+ content: this.buildContent(),
1559
+ bg: "#1a1a1a",
1560
+ paddingX: 1
1561
+ });
1562
+ }
1563
+ setSearchMode(active, query = "", matchCount = 0, currentIndex = -1) {
1564
+ this._searchMode = active;
1565
+ this._searchQuery = query;
1566
+ this._searchMatchCount = matchCount;
1567
+ this._searchCurrentIndex = currentIndex;
1568
+ this.renderable.content = this.buildContent();
1569
+ }
1570
+ buildContent() {
1571
+ if (this._searchMode) {
1572
+ return this.buildSearchContent();
1573
+ }
1574
+ return new StyledText([
1575
+ plain("\u2190\u2192/1-9: tabs R: restart S: stop/start F: search L: clear Ctrl+C: quit")
1576
+ ]);
1577
+ }
1578
+ buildSearchContent() {
1579
+ const chunks = [];
1580
+ chunks.push(yellow("/"));
1581
+ if (this._searchQuery)
1582
+ chunks.push(plain(this._searchQuery));
1583
+ chunks.push(reverse(" "));
1584
+ if (this._searchMatchCount === 0 && this._searchQuery) {
1585
+ chunks.push(plain(" "));
1586
+ chunks.push(red("no matches"));
1587
+ chunks.push(plain(" Esc: close"));
1588
+ } else if (this._searchMatchCount > 0) {
1589
+ chunks.push(plain(" "));
1590
+ chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`));
1591
+ chunks.push(plain(" Enter/Shift+Enter: next/prev Esc: close"));
1592
+ } else {
1593
+ chunks.push(plain(" Enter: next Esc: close"));
1594
+ }
1595
+ return new StyledText(chunks);
1596
+ }
1597
+ }
1598
+
1599
+ // src/ui/tabs.ts
1600
+ import {
1601
+ parseColor,
1602
+ SelectRenderable,
1603
+ SelectRenderableEvents
1604
+ } from "@opentui/core";
1605
+ var STATUS_ICONS = {
1606
+ pending: "\u25CB",
1607
+ starting: "\u25D0",
1608
+ running: "\u25C9",
1609
+ ready: "\u25CF",
1610
+ stopping: "\u25D1",
1611
+ stopped: "\u25A0",
1612
+ finished: "\u2713",
1613
+ failed: "\u2716",
1614
+ skipped: "\u2298"
1615
+ };
1616
+ var STATUS_ICON_HEX = {
1617
+ ready: "#00cc00",
1618
+ finished: "#66aa66",
1619
+ failed: "#ff5555",
1620
+ stopped: "#888888",
1621
+ skipped: "#888888"
1622
+ };
1623
+ var TERMINAL_STATUSES = new Set(["finished", "stopped", "failed", "skipped"]);
1624
+
1625
+ class ColoredSelectRenderable extends SelectRenderable {
1626
+ _optionColors = [];
1627
+ setOptionColors(colors) {
1628
+ this._optionColors = colors;
1629
+ this.requestRender();
1630
+ }
1631
+ renderSelf(buffer, deltaTime) {
1632
+ const wasDirty = this.isDirty;
1633
+ super.renderSelf(buffer, deltaTime);
1634
+ if (wasDirty && this.frameBuffer && this._optionColors.length > 0) {
1635
+ this.colorizeOptions();
1636
+ }
1637
+ }
1638
+ onMouseEvent(event) {
1639
+ if (event.type === "down") {
1640
+ const linesPerItem = this.linesPerItem;
1641
+ const scrollOffset = this.scrollOffset;
1642
+ const clickedIndex = scrollOffset + Math.floor(event.y / linesPerItem);
1643
+ if (clickedIndex >= 0 && clickedIndex < this.options.length) {
1644
+ this.setSelectedIndex(clickedIndex);
1645
+ this.selectCurrent();
1646
+ }
1647
+ }
1648
+ }
1649
+ colorizeOptions() {
1650
+ const fb = this.frameBuffer;
1651
+ const scrollOffset = this.scrollOffset;
1652
+ const maxVisibleItems = this.maxVisibleItems;
1653
+ const linesPerItem = this.linesPerItem;
1654
+ const options = this.options;
1655
+ const visibleCount = Math.min(maxVisibleItems, options.length - scrollOffset);
1656
+ for (let i = 0;i < visibleCount; i++) {
1657
+ const actualIndex = scrollOffset + i;
1658
+ const colors = this._optionColors[actualIndex];
1659
+ if (!colors)
1660
+ continue;
1661
+ const itemY = i * linesPerItem;
1662
+ const optName = options[actualIndex].name;
1663
+ if (colors.icon) {
1664
+ fb.drawText(optName.charAt(0), 3, itemY, colors.icon);
1665
+ }
1666
+ if (colors.name) {
1667
+ fb.drawText(optName.slice(2), 5, itemY, colors.name);
1668
+ }
1669
+ }
1670
+ }
1671
+ }
1672
+
1673
+ class TabBar {
1674
+ renderable;
1675
+ originalNames;
1676
+ names;
1677
+ statuses;
1678
+ baseDescriptions;
1679
+ processColors;
1680
+ inputWaiting = new Set;
1681
+ constructor(renderer, names, colors) {
1682
+ this.originalNames = names;
1683
+ this.names = [...names];
1684
+ this.statuses = new Map(names.map((n) => [n, "pending"]));
1685
+ this.baseDescriptions = new Map(names.map((n) => [n, "pending"]));
1686
+ this.processColors = colors ?? new Map;
1687
+ this.renderable = new ColoredSelectRenderable(renderer, {
1688
+ id: "tab-bar",
1689
+ width: "100%",
1690
+ height: "100%",
1691
+ options: names.map((n) => ({
1692
+ name: this.formatTab(n, "pending"),
1693
+ description: "pending"
1694
+ })),
1695
+ selectedBackgroundColor: "#334455",
1696
+ selectedTextColor: "#fff",
1697
+ textColor: "#888",
1698
+ showDescription: true,
1699
+ wrapSelection: true
1700
+ });
1701
+ this.updateOptionColors();
1702
+ }
1703
+ onSelect(handler) {
1704
+ this.renderable.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
1705
+ handler(index, this.names[index]);
1706
+ });
1707
+ }
1708
+ onSelectionChanged(handler) {
1709
+ this.renderable.on(SelectRenderableEvents.SELECTION_CHANGED, (index) => {
1710
+ handler(index, this.names[index]);
1711
+ });
1712
+ }
1713
+ updateStatus(name, status, exitCode, restartCount) {
1714
+ this.statuses.set(name, status);
1715
+ this.baseDescriptions.set(name, this.formatDescription(status, exitCode, restartCount));
1716
+ if (TERMINAL_STATUSES.has(status) || status === "stopping") {
1717
+ this.inputWaiting.delete(name);
1718
+ }
1719
+ this.refreshOptions();
1720
+ }
1721
+ setInputWaiting(name, waiting) {
1722
+ if (waiting)
1723
+ this.inputWaiting.add(name);
1724
+ else
1725
+ this.inputWaiting.delete(name);
1726
+ this.refreshOptions();
1727
+ }
1728
+ getNameAtIndex(index) {
1729
+ return this.names[index];
1730
+ }
1731
+ get count() {
1732
+ return this.names.length;
1733
+ }
1734
+ refreshOptions() {
1735
+ const currentIdx = this.renderable.getSelectedIndex();
1736
+ const currentName = this.names[currentIdx];
1737
+ this.names = this.getDisplayOrder();
1738
+ this.renderable.options = this.names.map((n) => ({
1739
+ name: this.formatTab(n, this.statuses.get(n)),
1740
+ description: this.getDescription(n)
1741
+ }));
1742
+ const newIdx = this.names.indexOf(currentName);
1743
+ if (newIdx >= 0 && newIdx !== currentIdx) {
1744
+ this.renderable.setSelectedIndex(newIdx);
1745
+ }
1746
+ this.updateOptionColors();
1747
+ }
1748
+ getDisplayOrder() {
1749
+ const active = this.originalNames.filter((n) => !TERMINAL_STATUSES.has(this.statuses.get(n)));
1750
+ const terminal = this.originalNames.filter((n) => TERMINAL_STATUSES.has(this.statuses.get(n)));
1751
+ return [...active, ...terminal];
1752
+ }
1753
+ getDescription(name) {
1754
+ if (this.inputWaiting.has(name))
1755
+ return "awaiting input";
1756
+ return this.baseDescriptions.get(name) ?? "pending";
1757
+ }
1758
+ updateOptionColors() {
1759
+ const colors = this.names.map((name) => {
1760
+ const status = this.statuses.get(name);
1761
+ const waiting = this.inputWaiting.has(name);
1762
+ const statusHex = waiting ? "#ffaa00" : STATUS_ICON_HEX[status];
1763
+ const processHex = this.processColors.get(name);
1764
+ return {
1765
+ icon: parseColor(statusHex ?? processHex ?? "#888888"),
1766
+ name: processHex ? parseColor(processHex) : null
1767
+ };
1768
+ });
1769
+ this.renderable.setOptionColors(colors);
1770
+ }
1771
+ formatDescription(status, exitCode, restartCount) {
1772
+ let desc = status;
1773
+ if ((status === "failed" || status === "stopped") && exitCode != null && exitCode !== 0) {
1774
+ desc = `exit ${exitCode}`;
1775
+ }
1776
+ if (restartCount && restartCount > 0) {
1777
+ desc += ` \xD7${restartCount}`;
1778
+ }
1779
+ return desc;
1780
+ }
1781
+ formatTab(name, status) {
1782
+ const icon = STATUS_ICONS[status];
1783
+ return `${icon} ${name}`;
1784
+ }
1785
+ getSelectedIndex() {
1786
+ return this.renderable.getSelectedIndex();
1787
+ }
1788
+ setSelectedIndex(index) {
1789
+ this.renderable.setSelectedIndex(index);
1790
+ }
1791
+ focus() {
1792
+ this.renderable.focus();
1793
+ }
1794
+ }
1795
+
1796
+ // src/ui/app.ts
1797
+ class App {
1798
+ renderer;
1799
+ manager;
1800
+ panes = new Map;
1801
+ tabBar;
1802
+ statusBar;
1803
+ activePane = null;
1804
+ destroyed = false;
1805
+ names;
1806
+ termCols = 80;
1807
+ termRows = 24;
1808
+ sidebarWidth = 20;
1809
+ config;
1810
+ resizeTimer = null;
1811
+ searchMode = false;
1812
+ searchQuery = "";
1813
+ searchMatches = [];
1814
+ searchIndex = -1;
1815
+ inputWaitTimers = new Map;
1816
+ awaitingInput = new Set;
1817
+ constructor(manager, config) {
1818
+ this.manager = manager;
1819
+ this.config = config;
1820
+ this.names = manager.getProcessNames();
1821
+ }
1822
+ async start() {
1823
+ this.renderer = await createCliRenderer({
1824
+ exitOnCtrlC: false,
1825
+ useMouse: true
1826
+ });
1827
+ const { width, height } = this.renderer;
1828
+ const maxNameLen = Math.max(...this.names.map((n) => n.length));
1829
+ this.sidebarWidth = Math.min(30, Math.max(16, maxNameLen + 5));
1830
+ this.termCols = Math.max(40, width - this.sidebarWidth - 2);
1831
+ this.termRows = Math.max(5, height - 2);
1832
+ const { termCols, termRows } = this;
1833
+ const layout = new BoxRenderable(this.renderer, {
1834
+ id: "root",
1835
+ flexDirection: "column",
1836
+ width: "100%",
1837
+ height: "100%",
1838
+ border: false
1839
+ });
1840
+ const processHexColors = buildProcessHexColorMap(this.names, this.config);
1841
+ this.tabBar = new TabBar(this.renderer, this.names, processHexColors);
1842
+ const contentRow = new BoxRenderable(this.renderer, {
1843
+ id: "content-row",
1844
+ flexDirection: "row",
1845
+ flexGrow: 1,
1846
+ width: "100%",
1847
+ border: false
1848
+ });
1849
+ const sidebar = new BoxRenderable(this.renderer, {
1850
+ id: "sidebar",
1851
+ width: this.sidebarWidth,
1852
+ height: "100%",
1853
+ border: ["right"],
1854
+ borderColor: "#444"
1855
+ });
1856
+ sidebar.add(this.tabBar.renderable);
1857
+ const paneContainer = new BoxRenderable(this.renderer, {
1858
+ id: "pane-container",
1859
+ flexGrow: 1,
1860
+ border: false
1861
+ });
1862
+ for (const name of this.names) {
1863
+ const interactive = this.config.processes[name].interactive === true;
1864
+ const pane = new Pane(this.renderer, name, termCols, termRows, interactive);
1865
+ this.panes.set(name, pane);
1866
+ paneContainer.add(pane.scrollBox);
1867
+ }
1868
+ this.statusBar = new StatusBar(this.renderer);
1869
+ contentRow.add(sidebar);
1870
+ contentRow.add(paneContainer);
1871
+ layout.add(contentRow);
1872
+ layout.add(this.statusBar.renderable);
1873
+ this.renderer.root.add(layout);
1874
+ this.tabBar.onSelect((_index, name) => this.switchPane(name));
1875
+ this.tabBar.onSelectionChanged((_index, name) => this.switchPane(name));
1876
+ this.manager.on((event) => {
1877
+ if (this.destroyed)
1878
+ return;
1879
+ if (event.type === "output") {
1880
+ this.panes.get(event.name)?.feed(event.data);
1881
+ if (this.config.processes[event.name]?.interactive) {
1882
+ this.checkInputWaiting(event.name, event.data);
1883
+ }
1884
+ } else if (event.type === "status") {
1885
+ const state = this.manager.getState(event.name);
1886
+ this.tabBar.updateStatus(event.name, event.status, state?.exitCode, state?.restartCount);
1887
+ if (event.status !== "running" && event.status !== "ready") {
1888
+ this.clearInputWaiting(event.name);
1889
+ }
1890
+ }
1891
+ });
1892
+ this.renderer.on("resize", (w, h) => {
1893
+ this.termCols = Math.max(40, w - this.sidebarWidth - 2);
1894
+ this.termRows = Math.max(5, h - 2);
1895
+ if (this.resizeTimer)
1896
+ clearTimeout(this.resizeTimer);
1897
+ this.resizeTimer = setTimeout(() => {
1898
+ this.resizeTimer = null;
1899
+ for (const pane of this.panes.values()) {
1900
+ pane.resize(this.termCols, this.termRows);
1901
+ }
1902
+ this.manager.resizeAll(this.termCols, this.termRows);
1903
+ }, 50);
1904
+ });
1905
+ this.renderer.keyInput.on("keypress", (key) => {
1906
+ log(key);
1907
+ if (key.ctrl && key.name === "c") {
1908
+ if (this.searchMode) {
1909
+ this.exitSearch();
1910
+ return;
1911
+ }
1912
+ this.shutdown().then(() => {
1913
+ process.exit(this.hasFailures() ? 1 : 0);
1914
+ });
1915
+ return;
1916
+ }
1917
+ if (this.searchMode) {
1918
+ this.handleSearchInput(key);
1919
+ return;
1920
+ }
1921
+ if (!this.activePane)
1922
+ return;
1923
+ const isInteractive = this.config.processes[this.activePane]?.interactive === true;
1924
+ if (!isInteractive) {
1925
+ const name = key.name.toLowerCase();
1926
+ if (key.shift && name === "r") {
1927
+ this.manager.restartAll(this.termCols, this.termRows);
1928
+ return;
1929
+ }
1930
+ if (name === "f") {
1931
+ this.enterSearch();
1932
+ return;
1933
+ }
1934
+ if (name === "r") {
1935
+ this.manager.restart(this.activePane, this.termCols, this.termRows);
1936
+ return;
1937
+ }
1938
+ if (name === "s") {
1939
+ const state = this.manager.getState(this.activePane);
1940
+ if (state?.status === "stopped" || state?.status === "finished" || state?.status === "failed") {
1941
+ this.manager.start(this.activePane, this.termCols, this.termRows);
1942
+ } else {
1943
+ this.manager.stop(this.activePane);
1944
+ }
1945
+ return;
1946
+ }
1947
+ if (name === "l") {
1948
+ this.panes.get(this.activePane)?.clear();
1949
+ return;
1950
+ }
1951
+ const num = Number.parseInt(name, 10);
1952
+ if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
1953
+ this.tabBar.setSelectedIndex(num - 1);
1954
+ this.switchPane(this.tabBar.getNameAtIndex(num - 1));
1955
+ return;
1956
+ }
1957
+ if (name === "left" || name === "right") {
1958
+ const current = this.tabBar.getSelectedIndex();
1959
+ const count = this.tabBar.count;
1960
+ const next = name === "right" ? (current + 1) % count : (current - 1 + count) % count;
1961
+ this.tabBar.setSelectedIndex(next);
1962
+ this.switchPane(this.tabBar.getNameAtIndex(next));
1963
+ return;
1964
+ }
1965
+ if (name === "pageup" || name === "pagedown") {
1966
+ const pane = this.panes.get(this.activePane);
1967
+ const delta = this.termRows - 2;
1968
+ pane?.scrollBy(name === "pageup" ? -delta : delta);
1969
+ return;
1970
+ }
1971
+ if (name === "home") {
1972
+ this.panes.get(this.activePane)?.scrollToTop();
1973
+ return;
1974
+ }
1975
+ if (name === "end") {
1976
+ this.panes.get(this.activePane)?.scrollToBottom();
1977
+ return;
1978
+ }
1979
+ return;
1980
+ }
1981
+ if (key.sequence) {
1982
+ this.manager.write(this.activePane, key.sequence);
1983
+ }
1984
+ });
1985
+ if (this.names.length > 0) {
1986
+ this.switchPane(this.names[0]);
1987
+ this.tabBar.focus();
1988
+ }
1989
+ await this.manager.startAll(termCols, termRows);
1990
+ }
1991
+ switchPane(name) {
1992
+ if (this.activePane === name)
1993
+ return;
1994
+ if (this.searchMode) {
1995
+ this.exitSearch();
1996
+ }
1997
+ if (this.activePane) {
1998
+ this.panes.get(this.activePane)?.hide();
1999
+ }
2000
+ this.activePane = name;
2001
+ this.panes.get(name)?.show();
2002
+ }
2003
+ checkInputWaiting(name, data) {
2004
+ const existing = this.inputWaitTimers.get(name);
2005
+ if (existing)
2006
+ clearTimeout(existing);
2007
+ if (this.awaitingInput.has(name)) {
2008
+ this.awaitingInput.delete(name);
2009
+ this.tabBar.setInputWaiting(name, false);
2010
+ }
2011
+ const lastByte = data[data.length - 1];
2012
+ if (lastByte !== 10 && lastByte !== 13) {
2013
+ const timer = setTimeout(() => {
2014
+ this.inputWaitTimers.delete(name);
2015
+ const state = this.manager.getState(name);
2016
+ if (state && (state.status === "running" || state.status === "ready")) {
2017
+ this.awaitingInput.add(name);
2018
+ this.tabBar.setInputWaiting(name, true);
2019
+ }
2020
+ }, 200);
2021
+ this.inputWaitTimers.set(name, timer);
2022
+ }
2023
+ }
2024
+ clearInputWaiting(name) {
2025
+ const timer = this.inputWaitTimers.get(name);
2026
+ if (timer) {
2027
+ clearTimeout(timer);
2028
+ this.inputWaitTimers.delete(name);
2029
+ }
2030
+ if (this.awaitingInput.has(name)) {
2031
+ this.awaitingInput.delete(name);
2032
+ this.tabBar.setInputWaiting(name, false);
2033
+ }
2034
+ }
2035
+ enterSearch() {
2036
+ this.searchMode = true;
2037
+ this.searchQuery = "";
2038
+ this.searchMatches = [];
2039
+ this.searchIndex = -1;
2040
+ this.statusBar.setSearchMode(true);
2041
+ }
2042
+ exitSearch() {
2043
+ this.searchMode = false;
2044
+ this.searchQuery = "";
2045
+ this.searchMatches = [];
2046
+ this.searchIndex = -1;
2047
+ if (this.activePane) {
2048
+ this.panes.get(this.activePane)?.clearHighlights();
2049
+ }
2050
+ this.statusBar.setSearchMode(false);
2051
+ }
2052
+ handleSearchInput(key) {
2053
+ if (key.name === "escape") {
2054
+ this.exitSearch();
2055
+ return;
2056
+ }
2057
+ if (key.name === "return") {
2058
+ if (this.searchMatches.length === 0)
2059
+ return;
2060
+ if (key.shift) {
2061
+ this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
2062
+ } else {
2063
+ this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length;
2064
+ }
2065
+ this.scrollToCurrentMatch();
2066
+ this.updateSearchHighlights();
2067
+ return;
2068
+ }
2069
+ if (key.name === "backspace") {
2070
+ if (this.searchQuery.length > 0) {
2071
+ this.searchQuery = this.searchQuery.slice(0, -1);
2072
+ this.runSearch();
2073
+ }
2074
+ return;
2075
+ }
2076
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
2077
+ this.searchQuery += key.sequence;
2078
+ this.runSearch();
2079
+ }
2080
+ }
2081
+ runSearch() {
2082
+ if (!this.activePane)
2083
+ return;
2084
+ const pane = this.panes.get(this.activePane);
2085
+ if (!pane)
2086
+ return;
2087
+ this.searchMatches = pane.search(this.searchQuery);
2088
+ this.searchIndex = this.searchMatches.length > 0 ? 0 : -1;
2089
+ this.updateSearchHighlights();
2090
+ if (this.searchIndex >= 0) {
2091
+ this.scrollToCurrentMatch();
2092
+ }
2093
+ }
2094
+ updateSearchHighlights() {
2095
+ if (!this.activePane)
2096
+ return;
2097
+ const pane = this.panes.get(this.activePane);
2098
+ if (!pane)
2099
+ return;
2100
+ if (this.searchMatches.length > 0) {
2101
+ pane.setHighlights(this.searchMatches, this.searchIndex);
2102
+ } else {
2103
+ pane.clearHighlights();
2104
+ }
2105
+ this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex);
2106
+ }
2107
+ scrollToCurrentMatch() {
2108
+ if (!this.activePane || this.searchIndex < 0)
2109
+ return;
2110
+ const pane = this.panes.get(this.activePane);
2111
+ if (!pane)
2112
+ return;
2113
+ const match = this.searchMatches[this.searchIndex];
2114
+ pane.scrollToLine(match.line);
2115
+ }
2116
+ async shutdown() {
2117
+ if (this.destroyed)
2118
+ return;
2119
+ this.destroyed = true;
2120
+ if (this.resizeTimer) {
2121
+ clearTimeout(this.resizeTimer);
2122
+ this.resizeTimer = null;
2123
+ }
2124
+ for (const timer of this.inputWaitTimers.values()) {
2125
+ clearTimeout(timer);
2126
+ }
2127
+ this.inputWaitTimers.clear();
2128
+ await this.manager.stopAll();
2129
+ for (const pane of this.panes.values()) {
2130
+ pane.destroy();
2131
+ }
2132
+ if (!this.renderer.isDestroyed) {
2133
+ this.renderer.destroy();
2134
+ }
2135
+ }
2136
+ hasFailures() {
2137
+ return this.manager.getAllStates().some((s) => s.status === "failed");
2138
+ }
2139
+ }
2140
+
2141
+ // src/ui/prefix.ts
2142
+ var RESET = ANSI_RESET;
2143
+ var DIM = "\x1B[90m";
2144
+
2145
+ class PrefixDisplay {
2146
+ manager;
2147
+ colors;
2148
+ noColor;
2149
+ decoders = new Map;
2150
+ buffers = new Map;
2151
+ maxNameLen;
2152
+ logWriter;
2153
+ killOthers;
2154
+ timestamps;
2155
+ stopping = false;
2156
+ constructor(manager, config, options = {}) {
2157
+ this.manager = manager;
2158
+ this.logWriter = options.logWriter;
2159
+ this.killOthers = options.killOthers ?? false;
2160
+ this.timestamps = options.timestamps ?? false;
2161
+ this.noColor = "NO_COLOR" in process.env;
2162
+ const names = manager.getProcessNames();
2163
+ this.maxNameLen = Math.max(...names.map((n) => n.length));
2164
+ this.colors = buildProcessColorMap(names, config);
2165
+ for (const name of names) {
2166
+ this.decoders.set(name, new TextDecoder("utf-8", { fatal: false }));
2167
+ this.buffers.set(name, "");
2168
+ }
2169
+ }
2170
+ async start() {
2171
+ const handler = (event) => {
2172
+ this.logWriter?.handleEvent(event);
2173
+ this.handleEvent(event);
2174
+ };
2175
+ this.manager.on(handler);
2176
+ process.on("SIGINT", () => this.shutdown());
2177
+ process.on("SIGTERM", () => this.shutdown());
2178
+ process.on("uncaughtException", (err) => {
2179
+ process.stderr.write(`numux: unexpected error: ${err?.stack ?? err}
2180
+ `);
2181
+ this.shutdown();
2182
+ });
2183
+ process.on("unhandledRejection", (reason) => {
2184
+ const message = reason instanceof Error ? reason.message : String(reason);
2185
+ process.stderr.write(`numux: unhandled rejection: ${message}
2186
+ `);
2187
+ this.shutdown();
2188
+ });
2189
+ const cols = process.stdout.columns || 80;
2190
+ const rows = process.stdout.rows || 24;
2191
+ await this.manager.startAll(cols, rows);
2192
+ this.checkAllDone();
2193
+ }
2194
+ handleEvent(event) {
2195
+ if (event.type === "output") {
2196
+ this.handleOutput(event.name, event.data);
2197
+ } else if (event.type === "status") {
2198
+ this.handleStatus(event.name, event.status);
2199
+ } else if (event.type === "exit") {
2200
+ this.flushBuffer(event.name);
2201
+ if (this.killOthers) {
2202
+ this.killAllAndExit(event.name);
2203
+ } else {
2204
+ this.checkAllDone();
2205
+ }
2206
+ }
2207
+ }
2208
+ handleOutput(name, data) {
2209
+ const decoder = this.decoders.get(name) ?? new TextDecoder;
2210
+ const text = decoder.decode(data, { stream: true });
2211
+ const buffer = (this.buffers.get(name) ?? "") + text;
2212
+ const lines = buffer.split(/\r?\n/);
2213
+ this.buffers.set(name, lines.pop() ?? "");
2214
+ for (const line of lines) {
2215
+ this.printLine(name, line);
2216
+ }
2217
+ }
2218
+ handleStatus(name, status) {
2219
+ if (status === "ready" || status === "failed" || status === "finished" || status === "stopped" || status === "skipped") {
2220
+ if (this.noColor) {
2221
+ this.printLine(name, `\u2192 ${status}`);
2222
+ } else {
2223
+ const ansi = STATUS_ANSI[status];
2224
+ const statusText = ansi ? `${ansi}${status}${RESET}` : status;
2225
+ this.printLine(name, `${DIM}\u2192 ${statusText}${DIM}${RESET}`);
2226
+ }
2227
+ }
2228
+ }
2229
+ formatTimestamp() {
2230
+ const now = new Date;
2231
+ const h = String(now.getHours()).padStart(2, "0");
2232
+ const m = String(now.getMinutes()).padStart(2, "0");
2233
+ const s = String(now.getSeconds()).padStart(2, "0");
2234
+ return `${h}:${m}:${s}`;
2235
+ }
2236
+ printLine(name, line) {
2237
+ const padded = name.padEnd(this.maxNameLen);
2238
+ const ts = this.timestamps ? `${DIM}[${this.formatTimestamp()}]${RESET} ` : "";
2239
+ const tsPlain = this.timestamps ? `[${this.formatTimestamp()}] ` : "";
2240
+ if (this.noColor) {
2241
+ process.stdout.write(`${tsPlain}[${padded}] ${stripAnsi(line)}
2242
+ `);
2243
+ } else {
2244
+ const color = this.colors.get(name) ?? "";
2245
+ process.stdout.write(`${ts}${color}[${padded}]${RESET} ${line}
2246
+ `);
2247
+ }
2248
+ }
2249
+ flushBuffer(name) {
2250
+ const remaining = this.buffers.get(name) ?? "";
2251
+ if (remaining.length > 0) {
2252
+ this.printLine(name, remaining);
2253
+ this.buffers.set(name, "");
2254
+ }
2255
+ }
2256
+ checkAllDone() {
2257
+ if (this.stopping)
2258
+ return;
2259
+ const states = this.manager.getAllStates();
2260
+ const allDone = states.every((s) => s.status === "stopped" || s.status === "finished" || s.status === "failed" || s.status === "skipped");
2261
+ if (allDone) {
2262
+ this.printSummary();
2263
+ this.logWriter?.close();
2264
+ const anyFailed = states.some((s) => s.status === "failed");
2265
+ process.exit(anyFailed ? 1 : 0);
2266
+ }
2267
+ }
2268
+ killAllAndExit(exitedName) {
2269
+ if (this.stopping)
2270
+ return;
2271
+ this.stopping = true;
2272
+ const state = this.manager.getState(exitedName);
2273
+ const code = state?.exitCode ?? 1;
2274
+ this.manager.stopAll().then(() => {
2275
+ for (const name of this.manager.getProcessNames()) {
2276
+ this.flushBuffer(name);
2277
+ }
2278
+ this.printSummary();
2279
+ this.logWriter?.close();
2280
+ process.exit(code === 0 ? 0 : 1);
2281
+ });
2282
+ }
2283
+ printSummary() {
2284
+ const states = this.manager.getAllStates();
2285
+ const namePad = Math.max(...states.map((s) => s.name.length));
2286
+ process.stdout.write(`
2287
+ `);
2288
+ for (const s of states) {
2289
+ const name = s.name.padEnd(namePad);
2290
+ const exitStr = s.exitCode !== null ? `exit ${s.exitCode}` : "";
2291
+ if (this.noColor) {
2292
+ process.stdout.write(` ${name} ${s.status}${exitStr ? ` (${exitStr})` : ""}
2293
+ `);
2294
+ } else {
2295
+ const ansi = STATUS_ANSI[s.status] ?? "";
2296
+ const statusText = ansi ? `${ansi}${s.status}${RESET}` : s.status;
2297
+ process.stdout.write(` ${name} ${statusText}${exitStr ? ` ${DIM}(${exitStr})${RESET}` : ""}
2298
+ `);
2299
+ }
2300
+ }
2301
+ }
2302
+ async shutdown() {
2303
+ if (this.stopping)
2304
+ return;
2305
+ this.stopping = true;
2306
+ await this.manager.stopAll();
2307
+ for (const name of this.manager.getProcessNames()) {
2308
+ this.flushBuffer(name);
2309
+ }
2310
+ this.logWriter?.close();
2311
+ const anyFailed = this.manager.getAllStates().some((s) => s.status === "failed");
2312
+ process.exit(anyFailed ? 1 : 0);
2313
+ }
2314
+ }
2315
+
2316
+ // src/utils/log-writer.ts
2317
+ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "fs";
2318
+ import { join } from "path";
2319
+ class LogWriter {
2320
+ dir;
2321
+ files = new Map;
2322
+ decoder = new TextDecoder;
2323
+ encoder = new TextEncoder;
2324
+ constructor(dir) {
2325
+ this.dir = dir;
2326
+ mkdirSync2(dir, { recursive: true });
2327
+ }
2328
+ errored = false;
2329
+ handleEvent = (event) => {
2330
+ if (event.type !== "output" || this.errored)
2331
+ return;
2332
+ try {
2333
+ let fd = this.files.get(event.name);
2334
+ if (fd === undefined) {
2335
+ const path = join(this.dir, `${event.name}.log`);
2336
+ fd = openSync(path, "w");
2337
+ this.files.set(event.name, fd);
2338
+ }
2339
+ const text = this.decoder.decode(event.data, { stream: true });
2340
+ const clean = stripAnsi(text);
2341
+ writeSync(fd, this.encoder.encode(clean));
2342
+ } catch {
2343
+ this.errored = true;
2344
+ process.stderr.write(`numux: log writing failed for ${this.dir}, disabling log output
2345
+ `);
2346
+ }
2347
+ };
2348
+ close() {
2349
+ for (const fd of this.files.values()) {
2350
+ closeSync(fd);
2351
+ }
2352
+ this.files.clear();
2353
+ }
2354
+ }
2355
+
2356
+ // src/utils/shutdown.ts
2357
+ function setupShutdownHandlers(app, logWriter) {
2358
+ let shuttingDown = false;
2359
+ const shutdown = () => {
2360
+ if (shuttingDown) {
2361
+ process.exit(1);
2362
+ }
2363
+ shuttingDown = true;
2364
+ app.shutdown().finally(() => {
2365
+ logWriter?.close();
2366
+ process.exit(app.hasFailures() ? 1 : 0);
2367
+ });
2368
+ };
2369
+ process.on("SIGINT", shutdown);
2370
+ process.on("SIGTERM", shutdown);
2371
+ process.on("uncaughtException", (err) => {
2372
+ log("Uncaught exception:", err?.message ?? err);
2373
+ process.stderr.write(`numux: unexpected error: ${err?.stack ?? err}
2374
+ `);
2375
+ app.shutdown().finally(() => {
2376
+ logWriter?.close();
2377
+ process.exit(1);
2378
+ });
2379
+ });
2380
+ process.on("unhandledRejection", (reason) => {
2381
+ const message = reason instanceof Error ? reason.message : String(reason);
2382
+ log("Unhandled rejection:", message);
2383
+ process.stderr.write(`numux: unhandled rejection: ${message}
2384
+ `);
2385
+ app.shutdown().finally(() => {
2386
+ logWriter?.close();
2387
+ process.exit(1);
2388
+ });
2389
+ });
2390
+ }
2391
+
2392
+ // src/index.ts
2393
+ var HELP = `numux \u2014 terminal multiplexer with dependency orchestration
2394
+
2395
+ Usage:
2396
+ numux Run processes from config file
2397
+ numux <cmd1> <cmd2> ... Run ad-hoc commands in parallel
2398
+ numux -n name1=cmd1 -n name2=cmd2 Named ad-hoc commands
2399
+ numux init Create a starter config file
2400
+ numux validate Validate config and show process graph
2401
+ numux exec <name> [--] <cmd> Run a command in a process's environment
2402
+ numux completions <shell> Generate shell completions (bash, zsh, fish)
2403
+
2404
+ Options:
2405
+ -n, --name <name=command> Add a named process
2406
+ -c, --color <colors> Comma-separated colors for processes (hex, e.g. #ff0,#0f0)
2407
+ --config <path> Config file path (default: auto-detect)
2408
+ -p, --prefix Prefixed output mode (no TUI, for CI/scripts)
2409
+ --only <a,b,...> Only run these processes (+ their dependencies)
2410
+ --exclude <a,b,...> Exclude these processes
2411
+ --kill-others Kill all processes when any exits
2412
+ --no-restart Disable auto-restart for crashed processes
2413
+ --no-watch Disable file watching even if config has watch patterns
2414
+ -t, --timestamps Add timestamps to prefixed output lines
2415
+ --log-dir <path> Write per-process logs to directory
2416
+ --debug Enable debug logging to .numux/debug.log
2417
+ -h, --help Show this help
2418
+ -v, --version Show version
2419
+
2420
+ Config files (auto-detected):
2421
+ numux.config.ts, numux.config.js`;
2422
+ var INIT_TEMPLATE = `import { defineConfig } from 'numux'
2423
+
2424
+ export default defineConfig({
2425
+ // Global options (inherited by all processes):
2426
+ // cwd: './packages/backend',
2427
+ // env: { NODE_ENV: 'development' },
2428
+ // envFile: '.env',
2429
+
2430
+ processes: {
2431
+ // dev: 'npm run dev',
2432
+ // api: {
2433
+ // command: 'npm run dev:api',
2434
+ // readyPattern: 'listening on port',
2435
+ // watch: 'src/**/*.ts',
2436
+ // },
2437
+ // web: {
2438
+ // command: 'npm run dev:web',
2439
+ // dependsOn: ['api'],
2440
+ // },
2441
+ },
2442
+ })
2443
+ `;
2444
+ async function main() {
2445
+ const parsed = parseArgs(process.argv);
2446
+ if (parsed.help) {
2447
+ console.info(HELP);
2448
+ process.exit(0);
2449
+ }
2450
+ if (parsed.version) {
2451
+ const pkg = await Promise.resolve().then(() => __toESM(require_package(), 1));
2452
+ console.info(`numux v${(pkg.default ?? pkg).version}`);
2453
+ process.exit(0);
2454
+ }
2455
+ if (parsed.init) {
2456
+ const target = resolve7("numux.config.ts");
2457
+ if (existsSync4(target)) {
2458
+ console.error(`Already exists: ${target}`);
2459
+ process.exit(1);
2460
+ }
2461
+ writeFileSync(target, INIT_TEMPLATE);
2462
+ console.info(`Created ${target}`);
2463
+ process.exit(0);
2464
+ }
2465
+ if (parsed.completions) {
2466
+ console.info(generateCompletions(parsed.completions));
2467
+ process.exit(0);
2468
+ }
2469
+ if (parsed.validate) {
2470
+ const raw = expandScriptPatterns(await loadConfig(parsed.configPath));
2471
+ const warnings2 = [];
2472
+ let config2 = validateConfig(raw, warnings2);
2473
+ if (parsed.only || parsed.exclude) {
2474
+ config2 = filterConfig(config2, parsed.only, parsed.exclude);
2475
+ }
2476
+ const tiers = resolveDependencyTiers(config2);
2477
+ const names = Object.keys(config2.processes);
2478
+ const filterNote = parsed.only || parsed.exclude ? " (filtered)" : "";
2479
+ console.info(`Config valid: ${names.length} process${names.length === 1 ? "" : "es"}${filterNote}
2480
+ `);
2481
+ for (let i = 0;i < tiers.length; i++) {
2482
+ console.info(`Tier ${i}:`);
2483
+ for (const name of tiers[i]) {
2484
+ const proc = config2.processes[name];
2485
+ const flags = [];
2486
+ if (proc.dependsOn?.length)
2487
+ flags.push(`depends on: ${proc.dependsOn.join(", ")}`);
2488
+ if (proc.readyPattern)
2489
+ flags.push(`ready: /${proc.readyPattern}/`);
2490
+ if (proc.persistent === false)
2491
+ flags.push("one-shot");
2492
+ if (proc.delay)
2493
+ flags.push(`delay: ${proc.delay}ms`);
2494
+ if (proc.condition)
2495
+ flags.push(`if: ${proc.condition}`);
2496
+ if (proc.watch) {
2497
+ const patterns = Array.isArray(proc.watch) ? proc.watch : [proc.watch];
2498
+ flags.push(`watch: ${patterns.join(", ")}`);
2499
+ }
2500
+ const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
2501
+ console.info(` ${name}: ${proc.command}${suffix}`);
2502
+ }
2503
+ }
2504
+ printWarnings(warnings2);
2505
+ process.exit(0);
2506
+ }
2507
+ if (parsed.exec) {
2508
+ const raw = expandScriptPatterns(await loadConfig(parsed.configPath));
2509
+ const config2 = validateConfig(raw);
2510
+ const proc = config2.processes[parsed.execName];
2511
+ if (!proc) {
2512
+ const names = Object.keys(config2.processes);
2513
+ throw new Error(`Unknown process "${parsed.execName}". Available: ${names.join(", ")}`);
2514
+ }
2515
+ const cwd = proc.cwd ? resolve7(proc.cwd) : process.cwd();
2516
+ const envFromFile = proc.envFile ? loadEnvFiles(proc.envFile, cwd) : {};
2517
+ const env = {
2518
+ ...process.env,
2519
+ ...envFromFile,
2520
+ ...proc.env
2521
+ };
2522
+ const child = Bun.spawn(["sh", "-c", parsed.execCommand], {
2523
+ cwd,
2524
+ env,
2525
+ stdout: "inherit",
2526
+ stdin: "inherit",
2527
+ stderr: "inherit"
2528
+ });
2529
+ process.exit(await child.exited);
2530
+ }
2531
+ if (parsed.debug) {
2532
+ enableDebugLog();
2533
+ }
2534
+ let config;
2535
+ const warnings = [];
2536
+ if (parsed.commands.length > 0 || parsed.named.length > 0) {
2537
+ const isScriptPattern = (c) => c.startsWith("npm:") || /[*?[]/.test(c);
2538
+ const hasNpmPatterns = parsed.commands.some(isScriptPattern);
2539
+ if (hasNpmPatterns) {
2540
+ const npmPatterns = parsed.commands.filter(isScriptPattern);
2541
+ const otherCommands = parsed.commands.filter((c) => !isScriptPattern(c));
2542
+ const processes = {};
2543
+ for (const pattern of npmPatterns) {
2544
+ const entry = {};
2545
+ if (parsed.colors?.length)
2546
+ entry.color = parsed.colors;
2547
+ processes[pattern] = entry;
2548
+ }
2549
+ for (let i = 0;i < otherCommands.length; i++) {
2550
+ const cmd = otherCommands[i];
2551
+ let name = cmd.split(/\s+/)[0].split("/").pop();
2552
+ if (processes[name])
2553
+ name = `${name}-${i}`;
2554
+ processes[name] = cmd;
2555
+ }
2556
+ for (const { name, command } of parsed.named) {
2557
+ processes[name] = command;
2558
+ }
2559
+ const expanded = expandScriptPatterns({ processes });
2560
+ config = validateConfig(expanded, warnings);
2561
+ } else {
2562
+ config = buildConfigFromArgs(parsed.commands, parsed.named, {
2563
+ noRestart: parsed.noRestart,
2564
+ colors: parsed.colors
2565
+ });
2566
+ }
2567
+ } else {
2568
+ const raw = expandScriptPatterns(await loadConfig(parsed.configPath));
2569
+ config = validateConfig(raw, warnings);
2570
+ if (parsed.noRestart) {
2571
+ for (const proc of Object.values(config.processes)) {
2572
+ proc.maxRestarts = 0;
2573
+ }
2574
+ }
2575
+ }
2576
+ if (parsed.noWatch) {
2577
+ for (const proc of Object.values(config.processes)) {
2578
+ delete proc.watch;
2579
+ }
2580
+ }
2581
+ if (parsed.only || parsed.exclude) {
2582
+ config = filterConfig(config, parsed.only, parsed.exclude);
2583
+ }
2584
+ const manager = new ProcessManager(config);
2585
+ let logWriter;
2586
+ if (parsed.logDir) {
2587
+ logWriter = new LogWriter(parsed.logDir);
2588
+ }
2589
+ printWarnings(warnings);
2590
+ if (parsed.prefix) {
2591
+ const display = new PrefixDisplay(manager, config, {
2592
+ logWriter,
2593
+ killOthers: parsed.killOthers,
2594
+ timestamps: parsed.timestamps
2595
+ });
2596
+ await display.start();
2597
+ } else {
2598
+ if (logWriter) {
2599
+ manager.on(logWriter.handleEvent);
2600
+ }
2601
+ const app = new App(manager, config);
2602
+ setupShutdownHandlers(app, logWriter);
2603
+ await app.start();
2604
+ }
2605
+ }
2606
+ function printWarnings(warnings) {
2607
+ for (const w of warnings) {
2608
+ console.warn(`Warning: process "${w.process}": ${w.message}`);
2609
+ }
2610
+ }
2611
+ main().catch((err) => {
2612
+ console.error(err instanceof Error ? err.message : err);
2613
+ process.exit(1);
2614
+ });