numux 1.9.0 → 1.10.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 (3) hide show
  1. package/README.md +45 -0
  2. package/dist/numux.js +124 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -98,6 +98,22 @@ numux completions fish | source
98
98
  numux completions fish > ~/.config/fish/completions/numux.fish
99
99
  ```
100
100
 
101
+ ### Workspaces
102
+
103
+ Run a `package.json` script across all workspaces in a monorepo:
104
+
105
+ ```sh
106
+ numux -w dev
107
+ ```
108
+
109
+ Reads the `workspaces` field from your root `package.json`, finds which workspaces have the given script, and spawns `<pm> run <script>` in each. The package manager is auto-detected from `packageManager` field or lockfiles.
110
+
111
+ Composes with other flags:
112
+
113
+ ```sh
114
+ numux -w dev -n redis="redis-server" --colors
115
+ ```
116
+
101
117
  ### Ad-hoc commands
102
118
 
103
119
  ```sh
@@ -108,10 +124,39 @@ numux "bun dev:api" "bun dev:web"
108
124
  numux -n api="bun dev:api" -n web="bun dev:web"
109
125
  ```
110
126
 
127
+ ### Script patterns
128
+
129
+ Run multiple package.json scripts matching a glob pattern:
130
+
131
+ ```sh
132
+ numux 'dev:*' # all scripts matching dev:*
133
+ numux 'npm:*:dev' # explicit npm: prefix (same behavior)
134
+ ```
135
+
136
+ Extra arguments after the pattern are forwarded to each matched command:
137
+
138
+ ```sh
139
+ numux 'lint:* --fix' # → bun run lint:js --fix, bun run lint:ts --fix
140
+ ```
141
+
142
+ In a config file, use the pattern as the process name:
143
+
144
+ ```ts
145
+ export default defineConfig({
146
+ processes: {
147
+ 'dev:*': { color: ['green', 'cyan'] },
148
+ 'lint:* --fix': {},
149
+ },
150
+ })
151
+ ```
152
+
153
+ Template properties (color, env, dependsOn, etc.) are inherited by all matched processes. Colors given as an array are distributed round-robin.
154
+
111
155
  ### Options
112
156
 
113
157
  | Flag | Description |
114
158
  |------|-------------|
159
+ | `-w, --workspace <script>` | Run a script across all workspaces |
115
160
  | `-c, --config <path>` | Explicit config file path |
116
161
  | `-n, --name <name=cmd>` | Add a named process (repeatable) |
117
162
  | `-p, --prefix` | Prefixed output mode (no TUI, for CI/scripts) |
package/dist/numux.js CHANGED
@@ -22,7 +22,7 @@ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports,
22
22
  var require_package = __commonJS((exports, module) => {
23
23
  module.exports = {
24
24
  name: "numux",
25
- version: "1.9.0",
25
+ version: "1.10.0",
26
26
  description: "Terminal multiplexer with dependency orchestration",
27
27
  type: "module",
28
28
  license: "MIT",
@@ -75,8 +75,8 @@ var require_package = __commonJS((exports, module) => {
75
75
  });
76
76
 
77
77
  // src/index.ts
78
- import { existsSync as existsSync4, writeFileSync } from "fs";
79
- import { resolve as resolve7 } from "path";
78
+ import { existsSync as existsSync5, writeFileSync } from "fs";
79
+ import { resolve as resolve8 } from "path";
80
80
 
81
81
  // src/cli.ts
82
82
  function parseArgs(argv) {
@@ -122,6 +122,8 @@ function parseArgs(argv) {
122
122
  result.timestamps = true;
123
123
  } else if (arg === "--no-restart") {
124
124
  result.noRestart = true;
125
+ } else if (arg === "-w" || arg === "--workspace") {
126
+ result.workspace = consumeValue(arg);
125
127
  } else if (arg === "--no-watch") {
126
128
  result.noWatch = true;
127
129
  } else if (arg === "--colors") {
@@ -274,7 +276,7 @@ _numux() {
274
276
  return ;;
275
277
  --only|--exclude)
276
278
  return ;;
277
- -n|--name)
279
+ -n|--name|-w|--workspace)
278
280
  return ;;
279
281
  completions)
280
282
  COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
@@ -282,7 +284,7 @@ _numux() {
282
284
  esac
283
285
 
284
286
  if [[ "$cur" == -* ]]; then
285
- COMPREPLY=( $(compgen -W "-h --help -v --version -c --color --colors --config -n --name -p --prefix --only --exclude --kill-others --no-restart --no-watch -t --timestamps --log-dir --debug" -- "$cur") )
287
+ COMPREPLY=( $(compgen -W "-h --help -v --version -w --workspace -c --color --colors --config -n --name -p --prefix --only --exclude --kill-others --no-restart --no-watch -t --timestamps --log-dir --debug" -- "$cur") )
286
288
  else
287
289
  local subcmds="init validate exec completions"
288
290
  COMPREPLY=( $(compgen -W "$subcmds" -- "$cur") )
@@ -306,6 +308,7 @@ _numux() {
306
308
  _arguments -s \\
307
309
  '(-h --help)'{-h,--help}'[Show help]' \\
308
310
  '(-v --version)'{-v,--version}'[Show version]' \\
311
+ '(-w --workspace)'{-w,--workspace}'[Run script across all workspaces]:script' \\
309
312
  '(-c --color)'{-c,--color}'[Comma-separated colors for processes]' \\
310
313
  '--colors[Auto-assign colors based on process name]' \\
311
314
  '--config[Config file path]:file:_files' \\
@@ -352,6 +355,7 @@ complete -c numux -s v -l version -d 'Show version'
352
355
  complete -c numux -s c -l color -r -d 'Comma-separated colors for processes'
353
356
  complete -c numux -l colors -d 'Auto-assign colors based on process name'
354
357
  complete -c numux -l config -rF -d 'Config file path'
358
+ complete -c numux -s w -l workspace -r -d 'Run script across all workspaces'
355
359
  complete -c numux -s n -l name -r -d 'Named process (name=command)'
356
360
  complete -c numux -s p -l prefix -d 'Prefixed output mode'
357
361
  complete -c numux -l only -r -d 'Only run these processes'
@@ -390,6 +394,12 @@ function detectPackageManager(pkgJson, cwd) {
390
394
  function isGlobPattern(name) {
391
395
  return /[*?[]/.test(name);
392
396
  }
397
+ function splitPatternArgs(raw) {
398
+ const i = raw.indexOf(" ");
399
+ if (i === -1)
400
+ return { glob: raw, extraArgs: "" };
401
+ return { glob: raw.slice(0, i), extraArgs: raw.slice(i) };
402
+ }
393
403
  function expandScriptPatterns(config, cwd) {
394
404
  const entries = Object.entries(config.processes);
395
405
  const hasWildcard = entries.some(([name]) => name.startsWith("npm:") || isGlobPattern(name));
@@ -413,15 +423,16 @@ function expandScriptPatterns(config, cwd) {
413
423
  expanded[name] = value;
414
424
  continue;
415
425
  }
416
- const pattern = name.startsWith("npm:") ? name.slice(4) : name;
426
+ const rawPattern = name.startsWith("npm:") ? name.slice(4) : name;
427
+ const { glob: globPattern, extraArgs } = splitPatternArgs(rawPattern);
417
428
  const template = value ?? {};
418
429
  if (template.command) {
419
430
  throw new Error(`"${name}": wildcard processes cannot have a "command" field (commands come from package.json scripts)`);
420
431
  }
421
- const glob = new Bun.Glob(pattern);
432
+ const glob = new Bun.Glob(globPattern);
422
433
  const matches = scriptNames.filter((s) => glob.match(s));
423
434
  if (matches.length === 0) {
424
- throw new Error(`"${name}": no scripts matched pattern "${pattern}". Available scripts: ${scriptNames.join(", ")}`);
435
+ throw new Error(`"${name}": no scripts matched pattern "${globPattern}". Available scripts: ${scriptNames.join(", ")}`);
425
436
  }
426
437
  const colors = Array.isArray(template.color) ? template.color : undefined;
427
438
  const singleColor = typeof template.color === "string" ? template.color : undefined;
@@ -434,7 +445,7 @@ function expandScriptPatterns(config, cwd) {
434
445
  const { color: _color, ...rest } = template;
435
446
  expanded[scriptName] = {
436
447
  ...rest,
437
- command: `${pm} run ${scriptName}`,
448
+ command: `${pm} run ${scriptName}${extraArgs}`,
438
449
  ...color ? { color } : {}
439
450
  };
440
451
  }
@@ -847,8 +858,71 @@ function validateStopSignal(value) {
847
858
  return;
848
859
  }
849
860
 
861
+ // src/config/workspaces.ts
862
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
863
+ import { basename, resolve as resolve4 } from "path";
864
+ function resolveWorkspaceProcesses(script, cwd) {
865
+ const pkgPath = resolve4(cwd, "package.json");
866
+ if (!existsSync4(pkgPath)) {
867
+ throw new Error(`No package.json found in ${cwd}`);
868
+ }
869
+ const pkgJson = JSON.parse(readFileSync2(pkgPath, "utf-8"));
870
+ const pm = detectPackageManager(pkgJson, cwd);
871
+ const raw = pkgJson.workspaces;
872
+ let patterns;
873
+ if (Array.isArray(raw)) {
874
+ patterns = raw;
875
+ } else if (raw && typeof raw === "object" && Array.isArray(raw.packages)) {
876
+ patterns = raw.packages;
877
+ } else {
878
+ throw new Error('No "workspaces" field found in package.json');
879
+ }
880
+ const dirs = [];
881
+ for (const pattern of patterns) {
882
+ const glob = new Bun.Glob(pattern);
883
+ for (const match of glob.scanSync({ cwd, onlyFiles: false })) {
884
+ const abs = resolve4(cwd, match);
885
+ const wsPkgPath = resolve4(abs, "package.json");
886
+ if (existsSync4(wsPkgPath)) {
887
+ dirs.push(abs);
888
+ }
889
+ }
890
+ }
891
+ const processes = {};
892
+ const usedNames = new Set;
893
+ for (const dir of dirs) {
894
+ const wsPkgPath = resolve4(dir, "package.json");
895
+ const wsPkg = JSON.parse(readFileSync2(wsPkgPath, "utf-8"));
896
+ const scripts = wsPkg.scripts;
897
+ if (!scripts?.[script])
898
+ continue;
899
+ let name;
900
+ if (typeof wsPkg.name === "string" && wsPkg.name) {
901
+ name = wsPkg.name.replace(/^@[^/]+\//, "");
902
+ } else {
903
+ name = basename(dir);
904
+ }
905
+ if (usedNames.has(name)) {
906
+ let suffix = 1;
907
+ while (usedNames.has(`${name}-${suffix}`))
908
+ suffix++;
909
+ name = `${name}-${suffix}`;
910
+ }
911
+ usedNames.add(name);
912
+ processes[name] = {
913
+ command: `${pm} run ${script}`,
914
+ cwd: dir,
915
+ persistent: true
916
+ };
917
+ }
918
+ if (Object.keys(processes).length === 0) {
919
+ throw new Error(`No workspaces have a "${script}" script`);
920
+ }
921
+ return processes;
922
+ }
923
+
850
924
  // src/process/manager.ts
851
- import { resolve as resolve6 } from "path";
925
+ import { resolve as resolve7 } from "path";
852
926
 
853
927
  // src/utils/watcher.ts
854
928
  import { watch } from "fs";
@@ -896,11 +970,11 @@ class FileWatcher {
896
970
  }
897
971
 
898
972
  // src/process/runner.ts
899
- import { resolve as resolve5 } from "path";
973
+ import { resolve as resolve6 } from "path";
900
974
 
901
975
  // src/utils/env-file.ts
902
- import { readFileSync as readFileSync2 } from "fs";
903
- import { resolve as resolve4 } from "path";
976
+ import { readFileSync as readFileSync3 } from "fs";
977
+ import { resolve as resolve5 } from "path";
904
978
  function parseEnvFile(content) {
905
979
  const vars = {};
906
980
  for (const line of content.split(/\r?\n/)) {
@@ -928,10 +1002,10 @@ function loadEnvFiles(envFile, cwd) {
928
1002
  const files = Array.isArray(envFile) ? envFile : [envFile];
929
1003
  const merged = {};
930
1004
  for (const file of files) {
931
- const path = resolve4(cwd, file);
1005
+ const path = resolve5(cwd, file);
932
1006
  let content;
933
1007
  try {
934
- content = readFileSync2(path, "utf-8");
1008
+ content = readFileSync3(path, "utf-8");
935
1009
  } catch (err) {
936
1010
  const code = err.code;
937
1011
  if (code === "ENOENT") {
@@ -1046,7 +1120,7 @@ class ProcessRunner {
1046
1120
  this.stopping = false;
1047
1121
  log(`[${this.name}] Starting (gen ${gen}): ${this.config.command}`);
1048
1122
  this.handler.onStatus("starting");
1049
- const cwd = this.config.cwd ? resolve5(this.config.cwd) : process.cwd();
1123
+ const cwd = this.config.cwd ? resolve6(this.config.cwd) : process.cwd();
1050
1124
  try {
1051
1125
  const envFromFile = this.config.envFile ? loadEnvFiles(this.config.envFile, cwd) : {};
1052
1126
  const noColor = "NO_COLOR" in process.env;
@@ -1308,12 +1382,12 @@ class ProcessManager {
1308
1382
  this.updateStatus(name, "skipped");
1309
1383
  continue;
1310
1384
  }
1311
- const { promise, resolve: resolve7 } = Promise.withResolvers();
1385
+ const { promise, resolve: resolve8 } = Promise.withResolvers();
1312
1386
  readyPromises.push(promise);
1313
- this.pendingReadyResolvers.set(name, resolve7);
1387
+ this.pendingReadyResolvers.set(name, resolve8);
1314
1388
  this.createRunner(name, () => {
1315
1389
  this.pendingReadyResolvers.delete(name);
1316
- resolve7();
1390
+ resolve8();
1317
1391
  });
1318
1392
  this.startProcess(name, cols, rows);
1319
1393
  }
@@ -1421,7 +1495,7 @@ class ProcessManager {
1421
1495
  if (!this.fileWatcher)
1422
1496
  this.fileWatcher = new FileWatcher;
1423
1497
  const patterns = Array.isArray(proc.watch) ? proc.watch : [proc.watch];
1424
- const cwd = proc.cwd ? resolve6(proc.cwd) : process.cwd();
1498
+ const cwd = proc.cwd ? resolve7(proc.cwd) : process.cwd();
1425
1499
  this.fileWatcher.watch(name, patterns, cwd, (changedFile) => {
1426
1500
  const state = this.states.get(name);
1427
1501
  if (!state)
@@ -1527,8 +1601,8 @@ class ProcessManager {
1527
1601
  clearTimeout(timer);
1528
1602
  }
1529
1603
  this.restartTimers.clear();
1530
- for (const resolve7 of this.pendingReadyResolvers.values()) {
1531
- resolve7();
1604
+ for (const resolve8 of this.pendingReadyResolvers.values()) {
1605
+ resolve8();
1532
1606
  }
1533
1607
  this.pendingReadyResolvers.clear();
1534
1608
  const reversed = [...this.tiers].reverse();
@@ -2352,7 +2426,6 @@ class PrefixDisplay {
2352
2426
  noColor;
2353
2427
  decoders = new Map;
2354
2428
  buffers = new Map;
2355
- maxNameLen;
2356
2429
  logWriter;
2357
2430
  killOthers;
2358
2431
  timestamps;
@@ -2364,7 +2437,6 @@ class PrefixDisplay {
2364
2437
  this.timestamps = options.timestamps ?? false;
2365
2438
  this.noColor = "NO_COLOR" in process.env;
2366
2439
  const names = manager.getProcessNames();
2367
- this.maxNameLen = Math.max(...names.map((n) => n.length));
2368
2440
  this.colors = buildProcessColorMap(names, config);
2369
2441
  for (const name of names) {
2370
2442
  this.decoders.set(name, new TextDecoder("utf-8", { fatal: false }));
@@ -2438,15 +2510,14 @@ class PrefixDisplay {
2438
2510
  return `${h}:${m}:${s}`;
2439
2511
  }
2440
2512
  printLine(name, line) {
2441
- const padded = name.padEnd(this.maxNameLen);
2442
2513
  const ts = this.timestamps ? `${DIM}[${this.formatTimestamp()}]${RESET} ` : "";
2443
2514
  const tsPlain = this.timestamps ? `[${this.formatTimestamp()}] ` : "";
2444
2515
  if (this.noColor) {
2445
- process.stdout.write(`${tsPlain}[${padded}] ${stripAnsi(line)}
2516
+ process.stdout.write(`${tsPlain}[${name}] ${stripAnsi(line)}
2446
2517
  `);
2447
2518
  } else {
2448
2519
  const color = this.colors.get(name) ?? "";
2449
- process.stdout.write(`${ts}${color}[${padded}]${RESET} ${line}
2520
+ process.stdout.write(`${ts}${color}[${name}]${RESET} ${line}
2450
2521
  `);
2451
2522
  }
2452
2523
  }
@@ -2600,12 +2671,14 @@ Usage:
2600
2671
  numux Run processes from config file
2601
2672
  numux <cmd1> <cmd2> ... Run ad-hoc commands in parallel
2602
2673
  numux -n name1=cmd1 -n name2=cmd2 Named ad-hoc commands
2674
+ numux -w <script> Run a script across all workspaces
2603
2675
  numux init Create a starter config file
2604
2676
  numux validate Validate config and show process graph
2605
2677
  numux exec <name> [--] <cmd> Run a command in a process's environment
2606
2678
  numux completions <shell> Generate shell completions (bash, zsh, fish)
2607
2679
 
2608
2680
  Options:
2681
+ -w, --workspace <script> Run a package.json script across all workspaces
2609
2682
  -n, --name <name=command> Add a named process
2610
2683
  -c, --color <colors> Comma-separated colors (hex or names: black, red, green, yellow, blue, magenta, cyan, white, gray, orange, purple)
2611
2684
  --colors Auto-assign colors to processes based on their name
@@ -2658,8 +2731,8 @@ async function main() {
2658
2731
  process.exit(0);
2659
2732
  }
2660
2733
  if (parsed.init) {
2661
- const target = resolve7("numux.config.ts");
2662
- if (existsSync4(target)) {
2734
+ const target = resolve8("numux.config.ts");
2735
+ if (existsSync5(target)) {
2663
2736
  console.error(`Already exists: ${target}`);
2664
2737
  process.exit(1);
2665
2738
  }
@@ -2717,7 +2790,7 @@ async function main() {
2717
2790
  const names = Object.keys(config2.processes);
2718
2791
  throw new Error(`Unknown process "${parsed.execName}". Available: ${names.join(", ")}`);
2719
2792
  }
2720
- const cwd = proc.cwd ? resolve7(proc.cwd) : process.cwd();
2793
+ const cwd = proc.cwd ? resolve8(proc.cwd) : process.cwd();
2721
2794
  const envFromFile = proc.envFile ? loadEnvFiles(proc.envFile, cwd) : {};
2722
2795
  const env = {
2723
2796
  ...process.env,
@@ -2738,7 +2811,7 @@ async function main() {
2738
2811
  }
2739
2812
  let config;
2740
2813
  const warnings = [];
2741
- if (parsed.commands.length > 0 || parsed.named.length > 0) {
2814
+ if (parsed.commands.length > 0 || parsed.named.length > 0 || parsed.workspace) {
2742
2815
  const isScriptPattern = (c) => c.startsWith("npm:") || /[*?[]/.test(c);
2743
2816
  const hasNpmPatterns = parsed.commands.some(isScriptPattern);
2744
2817
  if (hasNpmPatterns) {
@@ -2769,6 +2842,21 @@ async function main() {
2769
2842
  colors: parsed.colors
2770
2843
  });
2771
2844
  }
2845
+ if (parsed.workspace) {
2846
+ const wsProcesses = resolveWorkspaceProcesses(parsed.workspace, process.cwd());
2847
+ for (const [name, proc] of Object.entries(wsProcesses)) {
2848
+ let finalName = name;
2849
+ if (config.processes[finalName]) {
2850
+ let suffix = 1;
2851
+ while (config.processes[`${finalName}-${suffix}`])
2852
+ suffix++;
2853
+ finalName = `${finalName}-${suffix}`;
2854
+ }
2855
+ if (parsed.noRestart)
2856
+ proc.maxRestarts = 0;
2857
+ config.processes[finalName] = proc;
2858
+ }
2859
+ }
2772
2860
  } else {
2773
2861
  const raw = expandScriptPatterns(await loadConfig(parsed.configPath));
2774
2862
  config = validateConfig(raw, warnings);
@@ -2800,6 +2888,11 @@ async function main() {
2800
2888
  }
2801
2889
  printWarnings(warnings);
2802
2890
  if (parsed.prefix) {
2891
+ if (!parsed.noRestart) {
2892
+ for (const proc of Object.values(config.processes)) {
2893
+ proc.maxRestarts ??= 0;
2894
+ }
2895
+ }
2803
2896
  const display = new PrefixDisplay(manager, config, {
2804
2897
  logWriter,
2805
2898
  killOthers: parsed.killOthers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "numux",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",