numux 1.4.0 → 1.5.1

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.
@@ -1,67 +0,0 @@
1
- import type { ResolvedNumuxConfig } from '../types'
2
-
3
- /**
4
- * Kahn's topological sort — groups processes into tiers.
5
- * Tier 0: no deps, Tier 1: deps all in tier 0, etc.
6
- * Throws if a cycle is detected.
7
- */
8
- export function resolveDependencyTiers(config: ResolvedNumuxConfig): string[][] {
9
- const names = Object.keys(config.processes)
10
- const inDegree = new Map<string, number>()
11
- const dependents = new Map<string, string[]>()
12
-
13
- for (const name of names) {
14
- inDegree.set(name, 0)
15
- dependents.set(name, [])
16
- }
17
-
18
- for (const name of names) {
19
- const deps = config.processes[name].dependsOn ?? []
20
- inDegree.set(name, deps.length)
21
- for (const dep of deps) {
22
- dependents.get(dep)!.push(name)
23
- }
24
- }
25
-
26
- const tiers: string[][] = []
27
- const remaining = new Set(names)
28
-
29
- while (remaining.size > 0) {
30
- const tier = [...remaining].filter(n => inDegree.get(n) === 0)
31
-
32
- if (tier.length === 0) {
33
- const cycle = findCycle(remaining, config)
34
- throw new Error(`Dependency cycle detected: ${cycle.join(' → ')} → ${cycle[0]}`)
35
- }
36
-
37
- tiers.push(tier)
38
-
39
- for (const name of tier) {
40
- remaining.delete(name)
41
- for (const dep of dependents.get(name)!) {
42
- inDegree.set(dep, inDegree.get(dep)! - 1)
43
- }
44
- }
45
- }
46
-
47
- return tiers
48
- }
49
-
50
- /** Trace from any node in `remaining` to find one cycle. */
51
- function findCycle(remaining: Set<string>, config: ResolvedNumuxConfig): string[] {
52
- const start = remaining.values().next().value!
53
- const visited = new Set<string>()
54
- const path: string[] = []
55
-
56
- let current = start
57
- while (!visited.has(current)) {
58
- visited.add(current)
59
- path.push(current)
60
- const deps = (config.processes[current].dependsOn ?? []).filter(d => remaining.has(d))
61
- current = deps[0]
62
- }
63
-
64
- // `current` is where the cycle starts — trim the path to just the cycle
65
- const cycleStart = path.indexOf(current)
66
- return path.slice(cycleStart)
67
- }
@@ -1,150 +0,0 @@
1
- import type { NumuxProcessConfig, ResolvedNumuxConfig } from '../types'
2
- import { HEX_COLOR_RE } from '../utils/color'
3
-
4
- export type ValidationWarning = { process: string; message: string }
5
-
6
- export function validateConfig(raw: unknown, warnings?: ValidationWarning[]): ResolvedNumuxConfig {
7
- if (!raw || typeof raw !== 'object') {
8
- throw new Error('Config must be an object')
9
- }
10
-
11
- const config = raw as Record<string, unknown>
12
- if (!config.processes || typeof config.processes !== 'object') {
13
- throw new Error('Config must have a "processes" object')
14
- }
15
-
16
- const processes = config.processes as Record<string, unknown>
17
- const names = Object.keys(processes)
18
-
19
- if (names.length === 0) {
20
- throw new Error('Config must define at least one process')
21
- }
22
-
23
- // Extract global options
24
- const globalCwd = typeof config.cwd === 'string' ? config.cwd : undefined
25
- const globalEnvFile = validateStringOrStringArray(config.envFile)
26
- let globalEnv: Record<string, string> | undefined
27
- if (config.env && typeof config.env === 'object') {
28
- for (const [k, v] of Object.entries(config.env as Record<string, unknown>)) {
29
- if (typeof v !== 'string') {
30
- throw new Error(`env.${k} must be a string, got ${typeof v}`)
31
- }
32
- }
33
- globalEnv = config.env as Record<string, string>
34
- }
35
-
36
- const validated: Record<string, NumuxProcessConfig> = {}
37
-
38
- for (const name of names) {
39
- let proc = processes[name]
40
-
41
- // String shorthand: "command" → { command: "command" }
42
- if (typeof proc === 'string') {
43
- proc = { command: proc }
44
- }
45
-
46
- if (!proc || typeof proc !== 'object') {
47
- throw new Error(`Process "${name}" must be an object or a command string`)
48
- }
49
-
50
- const p = proc as Record<string, unknown>
51
-
52
- if (typeof p.command !== 'string' || !p.command.trim()) {
53
- throw new Error(`Process "${name}" must have a non-empty "command" string`)
54
- }
55
-
56
- // Validate dependsOn references
57
- if (p.dependsOn !== undefined) {
58
- if (!Array.isArray(p.dependsOn)) {
59
- throw new Error(`Process "${name}".dependsOn must be an array`)
60
- }
61
- for (const dep of p.dependsOn) {
62
- if (typeof dep !== 'string') {
63
- throw new Error(`Process "${name}".dependsOn entries must be strings`)
64
- }
65
- if (!names.includes(dep)) {
66
- throw new Error(`Process "${name}" depends on unknown process "${dep}"`)
67
- }
68
- if (dep === name) {
69
- throw new Error(`Process "${name}" cannot depend on itself`)
70
- }
71
- }
72
- }
73
-
74
- // Validate color hex format
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
- }
87
- }
88
-
89
- const persistent = typeof p.persistent === 'boolean' ? p.persistent : true
90
- const readyPattern = typeof p.readyPattern === 'string' ? p.readyPattern : undefined
91
-
92
- // Warn when readyPattern is set on non-persistent processes (it's ignored at runtime)
93
- if (readyPattern && !persistent) {
94
- warnings?.push({
95
- process: name,
96
- message: 'readyPattern is ignored on non-persistent processes (readiness is determined by exit code)'
97
- })
98
- }
99
-
100
- // Validate env values are strings
101
- if (p.env && typeof p.env === 'object') {
102
- for (const [k, v] of Object.entries(p.env as Record<string, unknown>)) {
103
- if (typeof v !== 'string') {
104
- throw new Error(`Process "${name}".env.${k} must be a string, got ${typeof v}`)
105
- }
106
- }
107
- }
108
-
109
- const processCwd = typeof p.cwd === 'string' ? p.cwd : undefined
110
- const processEnv = p.env && typeof p.env === 'object' ? (p.env as Record<string, string>) : undefined
111
- const processEnvFile = validateEnvFile(p.envFile)
112
-
113
- validated[name] = {
114
- command: p.command,
115
- cwd: processCwd ?? globalCwd,
116
- env: globalEnv || processEnv ? { ...globalEnv, ...processEnv } : undefined,
117
- envFile: processEnvFile ?? globalEnvFile,
118
- dependsOn: Array.isArray(p.dependsOn) ? (p.dependsOn as string[]) : undefined,
119
- readyPattern,
120
- persistent,
121
- maxRestarts: typeof p.maxRestarts === 'number' && p.maxRestarts >= 0 ? p.maxRestarts : undefined,
122
- readyTimeout: typeof p.readyTimeout === 'number' && p.readyTimeout > 0 ? p.readyTimeout : undefined,
123
- delay: typeof p.delay === 'number' && p.delay > 0 ? p.delay : undefined,
124
- condition: typeof p.condition === 'string' && p.condition.trim() ? p.condition.trim() : undefined,
125
- stopSignal: validateStopSignal(p.stopSignal),
126
- color: typeof p.color === 'string' ? p.color : Array.isArray(p.color) ? (p.color as string[]) : undefined,
127
- watch: validateStringOrStringArray(p.watch),
128
- interactive: typeof p.interactive === 'boolean' ? p.interactive : false
129
- }
130
- }
131
-
132
- return { processes: validated }
133
- }
134
-
135
- function validateStringOrStringArray(value: unknown): string | string[] | undefined {
136
- if (typeof value === 'string') return value
137
- if (Array.isArray(value) && value.every(v => typeof v === 'string')) return value as string[]
138
- return undefined
139
- }
140
-
141
- const validateEnvFile = validateStringOrStringArray
142
-
143
- const VALID_STOP_SIGNALS = new Set(['SIGTERM', 'SIGINT', 'SIGHUP'])
144
-
145
- function validateStopSignal(value: unknown): 'SIGTERM' | 'SIGINT' | 'SIGHUP' | undefined {
146
- if (typeof value === 'string' && VALID_STOP_SIGNALS.has(value)) {
147
- return value as 'SIGTERM' | 'SIGINT' | 'SIGHUP'
148
- }
149
- return undefined
150
- }
package/src/config.ts DELETED
@@ -1,8 +0,0 @@
1
- import type { NumuxConfig } from './types'
2
-
3
- export type { NumuxConfig, NumuxProcessConfig } from './types'
4
-
5
- /** Type-safe helper for numux.config.ts files. */
6
- export function defineConfig(config: NumuxConfig): NumuxConfig {
7
- return config
8
- }
package/src/index.ts DELETED
@@ -1,258 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { existsSync, writeFileSync } from 'node:fs'
3
- import { resolve } from 'node:path'
4
- import { buildConfigFromArgs, filterConfig, parseArgs } from './cli'
5
- import { generateCompletions } from './completions'
6
- import { expandScriptPatterns } from './config/expand-scripts'
7
- import { loadConfig } from './config/loader'
8
- import { resolveDependencyTiers } from './config/resolver'
9
- import { type ValidationWarning, validateConfig } from './config/validator'
10
- import { ProcessManager } from './process/manager'
11
- import type { NumuxProcessConfig, ResolvedNumuxConfig } from './types'
12
- import { App } from './ui/app'
13
- import { PrefixDisplay } from './ui/prefix'
14
- import { loadEnvFiles } from './utils/env-file'
15
- import { LogWriter } from './utils/log-writer'
16
- import { enableDebugLog } from './utils/logger'
17
- import { setupShutdownHandlers } from './utils/shutdown'
18
-
19
- const HELP = `numux — terminal multiplexer with dependency orchestration
20
-
21
- Usage:
22
- numux Run processes from config file
23
- numux <cmd1> <cmd2> ... Run ad-hoc commands in parallel
24
- numux -n name1=cmd1 -n name2=cmd2 Named ad-hoc commands
25
- numux init Create a starter config file
26
- numux validate Validate config and show process graph
27
- numux exec <name> [--] <cmd> Run a command in a process's environment
28
- numux completions <shell> Generate shell completions (bash, zsh, fish)
29
-
30
- Options:
31
- -n, --name <name=command> Add a named process
32
- -c, --color <colors> Comma-separated colors for processes (hex, e.g. #ff0,#0f0)
33
- --config <path> Config file path (default: auto-detect)
34
- -p, --prefix Prefixed output mode (no TUI, for CI/scripts)
35
- --only <a,b,...> Only run these processes (+ their dependencies)
36
- --exclude <a,b,...> Exclude these processes
37
- --kill-others Kill all processes when any exits
38
- --no-restart Disable auto-restart for crashed processes
39
- --no-watch Disable file watching even if config has watch patterns
40
- -t, --timestamps Add timestamps to prefixed output lines
41
- --log-dir <path> Write per-process logs to directory
42
- --debug Enable debug logging to .numux/debug.log
43
- -h, --help Show this help
44
- -v, --version Show version
45
-
46
- Config files (auto-detected):
47
- numux.config.ts, numux.config.js, numux.config.yaml,
48
- numux.config.yml, numux.config.json,
49
- or "numux" key in package.json`
50
-
51
- const INIT_TEMPLATE = `import { defineConfig } from 'numux'
52
-
53
- export default defineConfig({
54
- // Global options (inherited by all processes):
55
- // cwd: './packages/backend',
56
- // env: { NODE_ENV: 'development' },
57
- // envFile: '.env',
58
-
59
- processes: {
60
- // dev: 'npm run dev',
61
- // api: {
62
- // command: 'npm run dev:api',
63
- // readyPattern: 'listening on port',
64
- // watch: 'src/**/*.ts',
65
- // },
66
- // web: {
67
- // command: 'npm run dev:web',
68
- // dependsOn: ['api'],
69
- // },
70
- },
71
- })
72
- `
73
-
74
- async function main() {
75
- const parsed = parseArgs(process.argv)
76
-
77
- if (parsed.help) {
78
- console.info(HELP)
79
- process.exit(0)
80
- }
81
-
82
- if (parsed.version) {
83
- const pkg = await import('../package.json')
84
- console.info(`numux v${(pkg.default ?? pkg).version}`)
85
- process.exit(0)
86
- }
87
-
88
- if (parsed.init) {
89
- const target = resolve('numux.config.ts')
90
- if (existsSync(target)) {
91
- console.error(`Already exists: ${target}`)
92
- process.exit(1)
93
- }
94
- writeFileSync(target, INIT_TEMPLATE)
95
- console.info(`Created ${target}`)
96
- process.exit(0)
97
- }
98
-
99
- if (parsed.completions) {
100
- console.info(generateCompletions(parsed.completions))
101
- process.exit(0)
102
- }
103
-
104
- if (parsed.validate) {
105
- const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
106
- const warnings: ValidationWarning[] = []
107
- let config = validateConfig(raw, warnings)
108
-
109
- if (parsed.only || parsed.exclude) {
110
- config = filterConfig(config, parsed.only, parsed.exclude)
111
- }
112
-
113
- const tiers = resolveDependencyTiers(config)
114
- const names = Object.keys(config.processes)
115
- const filterNote = parsed.only || parsed.exclude ? ' (filtered)' : ''
116
- console.info(`Config valid: ${names.length} process${names.length === 1 ? '' : 'es'}${filterNote}\n`)
117
- for (let i = 0; i < tiers.length; i++) {
118
- console.info(`Tier ${i}:`)
119
- for (const name of tiers[i]) {
120
- const proc = config.processes[name]
121
- const flags: string[] = []
122
- if (proc.dependsOn?.length) flags.push(`depends on: ${proc.dependsOn.join(', ')}`)
123
- if (proc.readyPattern) flags.push(`ready: /${proc.readyPattern}/`)
124
- if (proc.persistent === false) flags.push('one-shot')
125
- if (proc.delay) flags.push(`delay: ${proc.delay}ms`)
126
- if (proc.condition) flags.push(`if: ${proc.condition}`)
127
- if (proc.watch) {
128
- const patterns = Array.isArray(proc.watch) ? proc.watch : [proc.watch]
129
- flags.push(`watch: ${patterns.join(', ')}`)
130
- }
131
- const suffix = flags.length > 0 ? ` (${flags.join(', ')})` : ''
132
- console.info(` ${name}: ${proc.command}${suffix}`)
133
- }
134
- }
135
- printWarnings(warnings)
136
- process.exit(0)
137
- }
138
-
139
- if (parsed.exec) {
140
- const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
141
- const config = validateConfig(raw)
142
- const proc = config.processes[parsed.execName!]
143
- if (!proc) {
144
- const names = Object.keys(config.processes)
145
- throw new Error(`Unknown process "${parsed.execName}". Available: ${names.join(', ')}`)
146
- }
147
-
148
- const cwd = proc.cwd ? resolve(proc.cwd) : process.cwd()
149
- const envFromFile = proc.envFile ? loadEnvFiles(proc.envFile, cwd) : {}
150
- const env: Record<string, string> = {
151
- ...(process.env as Record<string, string>),
152
- ...envFromFile,
153
- ...proc.env
154
- }
155
-
156
- const child = Bun.spawn(['sh', '-c', parsed.execCommand!], {
157
- cwd,
158
- env,
159
- stdout: 'inherit',
160
- stdin: 'inherit',
161
- stderr: 'inherit'
162
- })
163
- process.exit(await child.exited)
164
- }
165
-
166
- if (parsed.debug) {
167
- enableDebugLog()
168
- }
169
-
170
- let config: ResolvedNumuxConfig
171
- const warnings: ValidationWarning[] = []
172
-
173
- if (parsed.commands.length > 0 || parsed.named.length > 0) {
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
- }
202
- } else {
203
- const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
204
- config = validateConfig(raw, warnings)
205
-
206
- if (parsed.noRestart) {
207
- for (const proc of Object.values(config.processes)) {
208
- proc.maxRestarts = 0
209
- }
210
- }
211
- }
212
-
213
- if (parsed.noWatch) {
214
- for (const proc of Object.values(config.processes)) {
215
- delete proc.watch
216
- }
217
- }
218
-
219
- if (parsed.only || parsed.exclude) {
220
- config = filterConfig(config, parsed.only, parsed.exclude)
221
- }
222
-
223
- const manager = new ProcessManager(config)
224
-
225
- let logWriter: LogWriter | undefined
226
- if (parsed.logDir) {
227
- logWriter = new LogWriter(parsed.logDir)
228
- }
229
-
230
- printWarnings(warnings)
231
-
232
- if (parsed.prefix) {
233
- const display = new PrefixDisplay(manager, config, {
234
- logWriter,
235
- killOthers: parsed.killOthers,
236
- timestamps: parsed.timestamps
237
- })
238
- await display.start()
239
- } else {
240
- if (logWriter) {
241
- manager.on(logWriter.handleEvent)
242
- }
243
- const app = new App(manager, config)
244
- setupShutdownHandlers(app, logWriter)
245
- await app.start()
246
- }
247
- }
248
-
249
- function printWarnings(warnings: ValidationWarning[]): void {
250
- for (const w of warnings) {
251
- console.warn(`Warning: process "${w.process}": ${w.message}`)
252
- }
253
- }
254
-
255
- main().catch(err => {
256
- console.error(err instanceof Error ? err.message : err)
257
- process.exit(1)
258
- })