numux 1.3.0 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "numux",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -19,6 +19,7 @@ export interface ParsedArgs {
19
19
  logDir?: string
20
20
  only?: string[]
21
21
  exclude?: string[]
22
+ colors?: string[]
22
23
  commands: string[]
23
24
  named: Array<{ name: string; command: string }>
24
25
  }
@@ -72,8 +73,13 @@ export function parseArgs(argv: string[]): ParsedArgs {
72
73
  result.noRestart = true
73
74
  } else if (arg === '--no-watch') {
74
75
  result.noWatch = true
75
- } else if (arg === '-c' || arg === '--config') {
76
+ } else if (arg === '--config') {
76
77
  result.configPath = consumeValue(arg)
78
+ } else if (arg === '-c' || arg === '--color') {
79
+ result.colors = consumeValue(arg)
80
+ .split(',')
81
+ .map(s => s.trim())
82
+ .filter(Boolean)
77
83
  } else if (arg === '--log-dir') {
78
84
  result.logDir = consumeValue(arg)
79
85
  } else if (arg === '--only') {
@@ -128,13 +134,16 @@ export function parseArgs(argv: string[]): ParsedArgs {
128
134
  export function buildConfigFromArgs(
129
135
  commands: string[],
130
136
  named: Array<{ name: string; command: string }>,
131
- options?: { noRestart?: boolean }
137
+ options?: { noRestart?: boolean; colors?: string[] }
132
138
  ): ResolvedNumuxConfig {
133
139
  const processes: ResolvedNumuxConfig['processes'] = {}
134
140
  const maxRestarts = options?.noRestart ? 0 : undefined
141
+ const colors = options?.colors
142
+ let colorIndex = 0
135
143
 
136
144
  for (const { name, command } of named) {
137
- processes[name] = { command, persistent: true, maxRestarts }
145
+ const color = colors?.[colorIndex++ % colors.length]
146
+ processes[name] = { command, persistent: true, maxRestarts, ...(color ? { color } : {}) }
138
147
  }
139
148
 
140
149
  for (let i = 0; i < commands.length; i++) {
@@ -144,7 +153,8 @@ export function buildConfigFromArgs(
144
153
  if (processes[name]) {
145
154
  name = `${name}-${i}`
146
155
  }
147
- processes[name] = { command: cmd, persistent: true, maxRestarts }
156
+ const color = colors?.[colorIndex++ % colors.length]
157
+ processes[name] = { command: cmd, persistent: true, maxRestarts, ...(color ? { color } : {}) }
148
158
  }
149
159
 
150
160
  return { processes }
@@ -22,7 +22,7 @@ _numux() {
22
22
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
23
23
 
24
24
  case "$prev" in
25
- -c|--config)
25
+ --config)
26
26
  COMPREPLY=( $(compgen -f -- "$cur") )
27
27
  return ;;
28
28
  --log-dir)
@@ -38,7 +38,7 @@ _numux() {
38
38
  esac
39
39
 
40
40
  if [[ "$cur" == -* ]]; then
41
- COMPREPLY=( $(compgen -W "-h --help -v --version -c --config -n --name -p --prefix --only --exclude --kill-others --no-restart --no-watch -t --timestamps --log-dir --debug" -- "$cur") )
41
+ 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") )
42
42
  else
43
43
  local subcmds="init validate exec completions"
44
44
  COMPREPLY=( $(compgen -W "$subcmds" -- "$cur") )
@@ -63,7 +63,8 @@ _numux() {
63
63
  _arguments -s \\
64
64
  '(-h --help)'{-h,--help}'[Show help]' \\
65
65
  '(-v --version)'{-v,--version}'[Show version]' \\
66
- '(-c --config)'{-c,--config}'[Config file path]:file:_files' \\
66
+ '(-c --color)'{-c,--color}'[Comma-separated colors for processes]' \\
67
+ '--config[Config file path]:file:_files' \\
67
68
  '(-n --name)'{-n,--name}'[Named process (name=command)]:named process' \\
68
69
  '(-p --prefix)'{-p,--prefix}'[Prefixed output mode]' \\
69
70
  '--only[Only run these processes]:processes' \\
@@ -105,7 +106,8 @@ complete -c numux -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish
105
106
  # Options
106
107
  complete -c numux -s h -l help -d 'Show help'
107
108
  complete -c numux -s v -l version -d 'Show version'
108
- complete -c numux -s c -l config -rF -d 'Config file path'
109
+ complete -c numux -s c -l color -r -d 'Comma-separated colors for processes'
110
+ complete -c numux -l config -rF -d 'Config file path'
109
111
  complete -c numux -s n -l name -r -d 'Named process (name=command)'
110
112
  complete -c numux -s p -l prefix -d 'Prefixed output mode'
111
113
  complete -c numux -l only -r -d 'Only run these processes'
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import type { NumuxConfig, NumuxProcessConfig } from '../types'
4
+
5
+ type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'
6
+
7
+ const LOCKFILE_PM: [string, PackageManager][] = [
8
+ ['bun.lockb', 'bun'],
9
+ ['bun.lock', 'bun'],
10
+ ['yarn.lock', 'yarn'],
11
+ ['pnpm-lock.yaml', 'pnpm'],
12
+ ['package-lock.json', 'npm']
13
+ ]
14
+
15
+ export function detectPackageManager(pkgJson: Record<string, unknown>, cwd: string): PackageManager {
16
+ const field = pkgJson.packageManager
17
+ if (typeof field === 'string') {
18
+ const name = field.split('@')[0] as PackageManager
19
+ if (['npm', 'yarn', 'pnpm', 'bun'].includes(name)) return name
20
+ }
21
+ for (const [file, pm] of LOCKFILE_PM) {
22
+ if (existsSync(resolve(cwd, file))) return pm
23
+ }
24
+ return 'npm'
25
+ }
26
+
27
+ export function expandScriptPatterns(config: NumuxConfig, cwd?: string): NumuxConfig {
28
+ const entries = Object.entries(config.processes)
29
+ const hasWildcard = entries.some(([name]) => name.startsWith('npm:'))
30
+ if (!hasWildcard) return config
31
+
32
+ const dir = config.cwd ?? cwd ?? process.cwd()
33
+ const pkgPath = resolve(dir, 'package.json')
34
+
35
+ if (!existsSync(pkgPath)) {
36
+ throw new Error(`npm: patterns require a package.json (looked in ${dir})`)
37
+ }
38
+
39
+ const pkgJson = JSON.parse(readFileSync(pkgPath, 'utf-8')) as Record<string, unknown>
40
+ const scripts = pkgJson.scripts as Record<string, string> | undefined
41
+ if (!scripts || typeof scripts !== 'object') {
42
+ throw new Error('package.json has no "scripts" field')
43
+ }
44
+
45
+ const scriptNames = Object.keys(scripts)
46
+ const pm = detectPackageManager(pkgJson, dir)
47
+
48
+ const expanded: Record<string, NumuxProcessConfig | string> = {}
49
+
50
+ for (const [name, value] of entries) {
51
+ if (!name.startsWith('npm:')) {
52
+ expanded[name] = value as NumuxProcessConfig | string
53
+ continue
54
+ }
55
+
56
+ const pattern = name.slice(4) // strip "npm:"
57
+ const template = (value ?? {}) as Partial<NumuxProcessConfig>
58
+
59
+ if (template.command) {
60
+ throw new Error(
61
+ `"${name}": wildcard processes cannot have a "command" field (commands come from package.json scripts)`
62
+ )
63
+ }
64
+
65
+ const glob = new Bun.Glob(pattern)
66
+ const matches = scriptNames.filter(s => glob.match(s))
67
+
68
+ if (matches.length === 0) {
69
+ throw new Error(
70
+ `"${name}": no scripts matched pattern "${pattern}". Available scripts: ${scriptNames.join(', ')}`
71
+ )
72
+ }
73
+
74
+ const colors = Array.isArray(template.color) ? template.color : undefined
75
+ const singleColor = typeof template.color === 'string' ? template.color : undefined
76
+
77
+ for (let i = 0; i < matches.length; i++) {
78
+ const scriptName = matches[i]
79
+
80
+ if (expanded[scriptName]) {
81
+ throw new Error(`"${name}": expanded script "${scriptName}" collides with an existing process name`)
82
+ }
83
+
84
+ const color = colors ? colors[i % colors.length] : singleColor
85
+
86
+ const { color: _color, ...rest } = template
87
+ expanded[scriptName] = {
88
+ ...rest,
89
+ command: `${pm} run ${scriptName}`,
90
+ ...(color ? { color } : {})
91
+ } as NumuxProcessConfig
92
+ }
93
+ }
94
+
95
+ return { ...config, processes: expanded }
96
+ }
@@ -72,8 +72,18 @@ export function validateConfig(raw: unknown, warnings?: ValidationWarning[]): Re
72
72
  }
73
73
 
74
74
  // Validate color hex format
75
- if (typeof p.color === 'string' && !HEX_COLOR_RE.test(p.color)) {
76
- throw new Error(`Process "${name}".color must be a valid hex color (e.g. "#ff8800"), got "${p.color}"`)
75
+ if (typeof p.color === 'string') {
76
+ if (!HEX_COLOR_RE.test(p.color)) {
77
+ throw new Error(`Process "${name}".color must be a valid hex color (e.g. "#ff8800"), got "${p.color}"`)
78
+ }
79
+ } else if (Array.isArray(p.color)) {
80
+ for (const c of p.color) {
81
+ if (typeof c !== 'string' || !HEX_COLOR_RE.test(c)) {
82
+ throw new Error(
83
+ `Process "${name}".color entries must be valid hex colors (e.g. "#ff8800"), got "${c}"`
84
+ )
85
+ }
86
+ }
77
87
  }
78
88
 
79
89
  const persistent = typeof p.persistent === 'boolean' ? p.persistent : true
@@ -113,7 +123,7 @@ export function validateConfig(raw: unknown, warnings?: ValidationWarning[]): Re
113
123
  delay: typeof p.delay === 'number' && p.delay > 0 ? p.delay : undefined,
114
124
  condition: typeof p.condition === 'string' && p.condition.trim() ? p.condition.trim() : undefined,
115
125
  stopSignal: validateStopSignal(p.stopSignal),
116
- color: typeof p.color === 'string' ? p.color : undefined,
126
+ color: typeof p.color === 'string' ? p.color : Array.isArray(p.color) ? (p.color as string[]) : undefined,
117
127
  watch: validateStringOrStringArray(p.watch),
118
128
  interactive: typeof p.interactive === 'boolean' ? p.interactive : false
119
129
  }
package/src/index.ts CHANGED
@@ -3,11 +3,12 @@ import { existsSync, writeFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
  import { buildConfigFromArgs, filterConfig, parseArgs } from './cli'
5
5
  import { generateCompletions } from './completions'
6
+ import { expandScriptPatterns } from './config/expand-scripts'
6
7
  import { loadConfig } from './config/loader'
7
8
  import { resolveDependencyTiers } from './config/resolver'
8
9
  import { type ValidationWarning, validateConfig } from './config/validator'
9
10
  import { ProcessManager } from './process/manager'
10
- import type { ResolvedNumuxConfig } from './types'
11
+ import type { NumuxProcessConfig, ResolvedNumuxConfig } from './types'
11
12
  import { App } from './ui/app'
12
13
  import { PrefixDisplay } from './ui/prefix'
13
14
  import { loadEnvFiles } from './utils/env-file'
@@ -28,7 +29,8 @@ Usage:
28
29
 
29
30
  Options:
30
31
  -n, --name <name=command> Add a named process
31
- -c, --config <path> Config file path (default: auto-detect)
32
+ -c, --color <colors> Comma-separated colors for processes (hex, e.g. #ff0,#0f0)
33
+ --config <path> Config file path (default: auto-detect)
32
34
  -p, --prefix Prefixed output mode (no TUI, for CI/scripts)
33
35
  --only <a,b,...> Only run these processes (+ their dependencies)
34
36
  --exclude <a,b,...> Exclude these processes
@@ -100,7 +102,7 @@ async function main() {
100
102
  }
101
103
 
102
104
  if (parsed.validate) {
103
- const raw = await loadConfig(parsed.configPath)
105
+ const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
104
106
  const warnings: ValidationWarning[] = []
105
107
  let config = validateConfig(raw, warnings)
106
108
 
@@ -135,7 +137,7 @@ async function main() {
135
137
  }
136
138
 
137
139
  if (parsed.exec) {
138
- const raw = await loadConfig(parsed.configPath)
140
+ const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
139
141
  const config = validateConfig(raw)
140
142
  const proc = config.processes[parsed.execName!]
141
143
  if (!proc) {
@@ -169,9 +171,36 @@ async function main() {
169
171
  const warnings: ValidationWarning[] = []
170
172
 
171
173
  if (parsed.commands.length > 0 || parsed.named.length > 0) {
172
- config = buildConfigFromArgs(parsed.commands, parsed.named, { noRestart: parsed.noRestart })
174
+ const hasNpmPatterns = parsed.commands.some(c => c.startsWith('npm:'))
175
+ if (hasNpmPatterns) {
176
+ // Expand npm: patterns into named processes, pass remaining commands as-is
177
+ const npmPatterns = parsed.commands.filter(c => c.startsWith('npm:'))
178
+ const otherCommands = parsed.commands.filter(c => !c.startsWith('npm:'))
179
+ const processes: Record<string, NumuxProcessConfig | string> = {}
180
+ for (const pattern of npmPatterns) {
181
+ const entry: Partial<NumuxProcessConfig> = {}
182
+ if (parsed.colors?.length) entry.color = parsed.colors
183
+ processes[pattern] = entry as NumuxProcessConfig
184
+ }
185
+ for (let i = 0; i < otherCommands.length; i++) {
186
+ const cmd = otherCommands[i]
187
+ let name = cmd.split(/\s+/)[0].split('/').pop()!
188
+ if (processes[name]) name = `${name}-${i}`
189
+ processes[name] = cmd
190
+ }
191
+ for (const { name, command } of parsed.named) {
192
+ processes[name] = command
193
+ }
194
+ const expanded = expandScriptPatterns({ processes })
195
+ config = validateConfig(expanded, warnings)
196
+ } else {
197
+ config = buildConfigFromArgs(parsed.commands, parsed.named, {
198
+ noRestart: parsed.noRestart,
199
+ colors: parsed.colors
200
+ })
201
+ }
173
202
  } else {
174
- const raw = await loadConfig(parsed.configPath)
203
+ const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
175
204
  config = validateConfig(raw, warnings)
176
205
 
177
206
  if (parsed.noRestart) {
package/src/types.ts CHANGED
@@ -11,17 +11,20 @@ export interface NumuxProcessConfig {
11
11
  delay?: number // ms to wait before starting the process (default: none)
12
12
  condition?: string // env var name (prefix with ! to negate); process skipped if condition is falsy
13
13
  stopSignal?: 'SIGTERM' | 'SIGINT' | 'SIGHUP' // signal for graceful stop (default: SIGTERM)
14
- color?: string
14
+ color?: string | string[]
15
15
  watch?: string | string[] // Glob patterns — restart process when matching files change
16
16
  interactive?: boolean // default false — when true, keyboard input is forwarded to the process
17
17
  }
18
18
 
19
- /** Raw config as authoredprocesses can be string shorthand or full objects */
19
+ /** Config for npm: wildcard entries command is derived from package.json scripts */
20
+ export type NumuxScriptPattern = Omit<NumuxProcessConfig, 'command'> & { command?: never }
21
+
22
+ /** Raw config as authored — processes can be string shorthand, full objects, or wildcard patterns */
20
23
  export interface NumuxConfig {
21
24
  cwd?: string // Global working directory, inherited by all processes
22
25
  env?: Record<string, string> // Global env vars, merged into each process (process-level overrides)
23
26
  envFile?: string | string[] // Global .env file(s), inherited by processes without their own envFile
24
- processes: Record<string, NumuxProcessConfig | string>
27
+ processes: Record<string, NumuxProcessConfig | NumuxScriptPattern | string>
25
28
  }
26
29
 
27
30
  /** Validated config with all shorthand expanded to full objects */
package/src/ui/tabs.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type CliRenderer,
3
+ type MouseEvent,
3
4
  type OptimizedBuffer,
4
5
  parseColor,
5
6
  type RGBA,
@@ -59,6 +60,18 @@ class ColoredSelectRenderable extends SelectRenderable {
59
60
  }
60
61
  }
61
62
 
63
+ protected onMouseEvent(event: MouseEvent): void {
64
+ if (event.type === 'down') {
65
+ const linesPerItem = (this as any).linesPerItem as number
66
+ const scrollOffset = (this as any).scrollOffset as number
67
+ const clickedIndex = scrollOffset + Math.floor(event.y / linesPerItem)
68
+ if (clickedIndex >= 0 && clickedIndex < this.options.length) {
69
+ this.setSelectedIndex(clickedIndex)
70
+ this.selectCurrent()
71
+ }
72
+ }
73
+ }
74
+
62
75
  private colorizeOptions(): void {
63
76
  const fb = this.frameBuffer!
64
77
  // Access internal layout state (private in TS, accessible at runtime)
@@ -51,13 +51,20 @@ const DEFAULT_ANSI_COLORS = [
51
51
  /** Default palette as hex colors (for styled text rendering) */
52
52
  const DEFAULT_HEX_COLORS = ['#00cccc', '#cccc00', '#cc00cc', '#0000cc', '#00cc00', '#ff5555', '#ffff55', '#ff55ff']
53
53
 
54
+ /** Resolve a color value (string or array) to a single hex string, or undefined. */
55
+ function resolveColor(color: string | string[] | undefined): string | undefined {
56
+ if (typeof color === 'string') return color
57
+ if (Array.isArray(color) && color.length > 0) return color[0]
58
+ return undefined
59
+ }
60
+
54
61
  /** Build a map of process names to ANSI color codes, using explicit config colors or a default palette. */
55
62
  export function buildProcessColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string> {
56
63
  const map = new Map<string, string>()
57
64
  if ('NO_COLOR' in process.env) return map
58
65
  let paletteIndex = 0
59
66
  for (const name of names) {
60
- const explicit = config.processes[name]?.color
67
+ const explicit = resolveColor(config.processes[name]?.color)
61
68
  if (explicit) {
62
69
  map.set(name, hexToAnsi(explicit))
63
70
  } else {
@@ -74,7 +81,7 @@ export function buildProcessHexColorMap(names: string[], config: ResolvedNumuxCo
74
81
  if ('NO_COLOR' in process.env) return map
75
82
  let paletteIndex = 0
76
83
  for (const name of names) {
77
- const explicit = config.processes[name]?.color
84
+ const explicit = resolveColor(config.processes[name]?.color)
78
85
  if (explicit) {
79
86
  map.set(name, explicit.startsWith('#') ? explicit : `#${explicit}`)
80
87
  } else {