numux 0.0.1 → 1.0.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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Environment variable interpolation for config values.
3
+ * Supports ${VAR}, ${VAR:-default}, and ${VAR:?error} syntax.
4
+ */
5
+
6
+ // Matches ${VAR}, ${VAR:-default}, ${VAR:?error message}
7
+ const VAR_RE = /\$\{([^}:]+)(?::([-?])([^}]*))?\}/g
8
+
9
+ /** Recursively interpolate environment variables in all string values of a config object */
10
+ export function interpolateConfig<T>(config: T): T {
11
+ return interpolateValue(config) as T
12
+ }
13
+
14
+ function interpolateValue(value: unknown): unknown {
15
+ if (typeof value === 'string') {
16
+ return interpolateString(value)
17
+ }
18
+ if (Array.isArray(value)) {
19
+ return value.map(interpolateValue)
20
+ }
21
+ if (value && typeof value === 'object') {
22
+ const result: Record<string, unknown> = {}
23
+ for (const [k, v] of Object.entries(value)) {
24
+ result[k] = interpolateValue(v)
25
+ }
26
+ return result
27
+ }
28
+ return value
29
+ }
30
+
31
+ function interpolateString(str: string): string {
32
+ return str.replace(VAR_RE, (_match, name: string, operator?: string, operand?: string) => {
33
+ const value = process.env[name]
34
+
35
+ if (value !== undefined && value !== '') {
36
+ return value
37
+ }
38
+
39
+ if (operator === '-') {
40
+ return operand ?? ''
41
+ }
42
+
43
+ if (operator === '?') {
44
+ throw new Error(operand || `Required variable ${name} is not set`)
45
+ }
46
+
47
+ // Unset with no operator → empty string
48
+ return ''
49
+ })
50
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { extname, resolve } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+ import type { NumuxConfig } from '../types'
5
+ import { log } from '../utils/logger'
6
+ import { interpolateConfig } from './interpolate'
7
+
8
+ const CONFIG_FILES = [
9
+ 'numux.config.ts',
10
+ 'numux.config.js',
11
+ 'numux.config.yaml',
12
+ 'numux.config.yml',
13
+ 'numux.config.json'
14
+ ] as const
15
+
16
+ export async function loadConfig(configPath?: string, cwd?: string): Promise<NumuxConfig> {
17
+ if (configPath) {
18
+ return loadExplicitConfig(configPath)
19
+ }
20
+ return autoDetectConfig(cwd ?? process.cwd())
21
+ }
22
+
23
+ async function loadFile(path: string): Promise<NumuxConfig> {
24
+ const ext = extname(path)
25
+ let config: NumuxConfig
26
+ if (ext === '.yaml' || ext === '.yml') {
27
+ const content = readFileSync(path, 'utf-8')
28
+ try {
29
+ config = parseYaml(content) as NumuxConfig
30
+ } catch (err) {
31
+ throw new Error(`Failed to parse ${path}: ${err instanceof Error ? err.message : err}`, { cause: err })
32
+ }
33
+ } else {
34
+ try {
35
+ const mod = await import(path)
36
+ config = mod.default ?? mod
37
+ } catch (err) {
38
+ throw new Error(`Failed to load ${path}: ${err instanceof Error ? err.message : err}`, { cause: err })
39
+ }
40
+ }
41
+ return interpolateConfig(config)
42
+ }
43
+
44
+ async function loadExplicitConfig(configPath: string): Promise<NumuxConfig> {
45
+ const path = resolve(configPath)
46
+ if (!existsSync(path)) {
47
+ throw new Error(`Config file not found: ${path}`)
48
+ }
49
+ log(`Loading explicit config: ${path}`)
50
+ return loadFile(path)
51
+ }
52
+
53
+ async function autoDetectConfig(cwd: string): Promise<NumuxConfig> {
54
+ for (const file of CONFIG_FILES) {
55
+ const path = resolve(cwd, file)
56
+ if (existsSync(path)) {
57
+ log(`Found config file: ${path}`)
58
+ return loadFile(path)
59
+ }
60
+ }
61
+
62
+ // Try package.json "numux" key
63
+ const pkgPath = resolve(cwd, 'package.json')
64
+ if (existsSync(pkgPath)) {
65
+ const pkg = await import(pkgPath)
66
+ const config = (pkg.default ?? pkg).numux
67
+ if (config) {
68
+ log('Found config in package.json "numux" key')
69
+ return interpolateConfig(config as NumuxConfig)
70
+ }
71
+ }
72
+
73
+ throw new Error(
74
+ `No numux config found. Create one of: ${CONFIG_FILES.join(', ')} or add a "numux" key to package.json`
75
+ )
76
+ }
@@ -0,0 +1,67 @@
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
+ }
@@ -0,0 +1,140 @@
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' && !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}"`)
77
+ }
78
+
79
+ const persistent = typeof p.persistent === 'boolean' ? p.persistent : true
80
+ const readyPattern = typeof p.readyPattern === 'string' ? p.readyPattern : undefined
81
+
82
+ // Warn when readyPattern is set on non-persistent processes (it's ignored at runtime)
83
+ if (readyPattern && !persistent) {
84
+ warnings?.push({
85
+ process: name,
86
+ message: 'readyPattern is ignored on non-persistent processes (readiness is determined by exit code)'
87
+ })
88
+ }
89
+
90
+ // Validate env values are strings
91
+ if (p.env && typeof p.env === 'object') {
92
+ for (const [k, v] of Object.entries(p.env as Record<string, unknown>)) {
93
+ if (typeof v !== 'string') {
94
+ throw new Error(`Process "${name}".env.${k} must be a string, got ${typeof v}`)
95
+ }
96
+ }
97
+ }
98
+
99
+ const processCwd = typeof p.cwd === 'string' ? p.cwd : undefined
100
+ const processEnv = p.env && typeof p.env === 'object' ? (p.env as Record<string, string>) : undefined
101
+ const processEnvFile = validateEnvFile(p.envFile)
102
+
103
+ validated[name] = {
104
+ command: p.command,
105
+ cwd: processCwd ?? globalCwd,
106
+ env: globalEnv || processEnv ? { ...globalEnv, ...processEnv } : undefined,
107
+ envFile: processEnvFile ?? globalEnvFile,
108
+ dependsOn: Array.isArray(p.dependsOn) ? (p.dependsOn as string[]) : undefined,
109
+ readyPattern,
110
+ persistent,
111
+ maxRestarts: typeof p.maxRestarts === 'number' && p.maxRestarts >= 0 ? p.maxRestarts : undefined,
112
+ readyTimeout: typeof p.readyTimeout === 'number' && p.readyTimeout > 0 ? p.readyTimeout : undefined,
113
+ delay: typeof p.delay === 'number' && p.delay > 0 ? p.delay : undefined,
114
+ condition: typeof p.condition === 'string' && p.condition.trim() ? p.condition.trim() : undefined,
115
+ stopSignal: validateStopSignal(p.stopSignal),
116
+ color: typeof p.color === 'string' ? p.color : undefined,
117
+ watch: validateStringOrStringArray(p.watch),
118
+ interactive: typeof p.interactive === 'boolean' ? p.interactive : false
119
+ }
120
+ }
121
+
122
+ return { processes: validated }
123
+ }
124
+
125
+ function validateStringOrStringArray(value: unknown): string | string[] | undefined {
126
+ if (typeof value === 'string') return value
127
+ if (Array.isArray(value) && value.every(v => typeof v === 'string')) return value as string[]
128
+ return undefined
129
+ }
130
+
131
+ const validateEnvFile = validateStringOrStringArray
132
+
133
+ const VALID_STOP_SIGNALS = new Set(['SIGTERM', 'SIGINT', 'SIGHUP'])
134
+
135
+ function validateStopSignal(value: unknown): 'SIGTERM' | 'SIGINT' | 'SIGHUP' | undefined {
136
+ if (typeof value === 'string' && VALID_STOP_SIGNALS.has(value)) {
137
+ return value as 'SIGTERM' | 'SIGINT' | 'SIGHUP'
138
+ }
139
+ return undefined
140
+ }
package/src/config.ts ADDED
@@ -0,0 +1,8 @@
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 ADDED
@@ -0,0 +1,229 @@
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 { loadConfig } from './config/loader'
7
+ import { resolveDependencyTiers } from './config/resolver'
8
+ import { type ValidationWarning, validateConfig } from './config/validator'
9
+ import { ProcessManager } from './process/manager'
10
+ import type { ResolvedNumuxConfig } from './types'
11
+ import { App } from './ui/app'
12
+ import { PrefixDisplay } from './ui/prefix'
13
+ import { loadEnvFiles } from './utils/env-file'
14
+ import { LogWriter } from './utils/log-writer'
15
+ import { enableDebugLog } from './utils/logger'
16
+ import { setupShutdownHandlers } from './utils/shutdown'
17
+
18
+ const HELP = `numux — terminal multiplexer with dependency orchestration
19
+
20
+ Usage:
21
+ numux Run processes from config file
22
+ numux <cmd1> <cmd2> ... Run ad-hoc commands in parallel
23
+ numux -n name1=cmd1 -n name2=cmd2 Named ad-hoc commands
24
+ numux init Create a starter config file
25
+ numux validate Validate config and show process graph
26
+ numux exec <name> [--] <cmd> Run a command in a process's environment
27
+ numux completions <shell> Generate shell completions (bash, zsh, fish)
28
+
29
+ Options:
30
+ -n, --name <name=command> Add a named process
31
+ -c, --config <path> Config file path (default: auto-detect)
32
+ -p, --prefix Prefixed output mode (no TUI, for CI/scripts)
33
+ --only <a,b,...> Only run these processes (+ their dependencies)
34
+ --exclude <a,b,...> Exclude these processes
35
+ --kill-others Kill all processes when any exits
36
+ --no-restart Disable auto-restart for crashed processes
37
+ --no-watch Disable file watching even if config has watch patterns
38
+ -t, --timestamps Add timestamps to prefixed output lines
39
+ --log-dir <path> Write per-process logs to directory
40
+ --debug Enable debug logging to .numux/debug.log
41
+ -h, --help Show this help
42
+ -v, --version Show version
43
+
44
+ Config files (auto-detected):
45
+ numux.config.ts, numux.config.js, numux.config.yaml,
46
+ numux.config.yml, numux.config.json,
47
+ or "numux" key in package.json`
48
+
49
+ const INIT_TEMPLATE = `import { defineConfig } from 'numux'
50
+
51
+ export default defineConfig({
52
+ // Global options (inherited by all processes):
53
+ // cwd: './packages/backend',
54
+ // env: { NODE_ENV: 'development' },
55
+ // envFile: '.env',
56
+
57
+ processes: {
58
+ // dev: 'npm run dev',
59
+ // api: {
60
+ // command: 'npm run dev:api',
61
+ // readyPattern: 'listening on port',
62
+ // watch: 'src/**/*.ts',
63
+ // },
64
+ // web: {
65
+ // command: 'npm run dev:web',
66
+ // dependsOn: ['api'],
67
+ // },
68
+ },
69
+ })
70
+ `
71
+
72
+ async function main() {
73
+ const parsed = parseArgs(process.argv)
74
+
75
+ if (parsed.help) {
76
+ console.info(HELP)
77
+ process.exit(0)
78
+ }
79
+
80
+ if (parsed.version) {
81
+ const pkg = await import('../package.json')
82
+ console.info(`numux v${(pkg.default ?? pkg).version}`)
83
+ process.exit(0)
84
+ }
85
+
86
+ if (parsed.init) {
87
+ const target = resolve('numux.config.ts')
88
+ if (existsSync(target)) {
89
+ console.error(`Already exists: ${target}`)
90
+ process.exit(1)
91
+ }
92
+ writeFileSync(target, INIT_TEMPLATE)
93
+ console.info(`Created ${target}`)
94
+ process.exit(0)
95
+ }
96
+
97
+ if (parsed.completions) {
98
+ console.info(generateCompletions(parsed.completions))
99
+ process.exit(0)
100
+ }
101
+
102
+ if (parsed.validate) {
103
+ const raw = await loadConfig(parsed.configPath)
104
+ const warnings: ValidationWarning[] = []
105
+ let config = validateConfig(raw, warnings)
106
+
107
+ if (parsed.only || parsed.exclude) {
108
+ config = filterConfig(config, parsed.only, parsed.exclude)
109
+ }
110
+
111
+ const tiers = resolveDependencyTiers(config)
112
+ const names = Object.keys(config.processes)
113
+ const filterNote = parsed.only || parsed.exclude ? ' (filtered)' : ''
114
+ console.info(`Config valid: ${names.length} process${names.length === 1 ? '' : 'es'}${filterNote}\n`)
115
+ for (let i = 0; i < tiers.length; i++) {
116
+ console.info(`Tier ${i}:`)
117
+ for (const name of tiers[i]) {
118
+ const proc = config.processes[name]
119
+ const flags: string[] = []
120
+ if (proc.dependsOn?.length) flags.push(`depends on: ${proc.dependsOn.join(', ')}`)
121
+ if (proc.readyPattern) flags.push(`ready: /${proc.readyPattern}/`)
122
+ if (proc.persistent === false) flags.push('one-shot')
123
+ if (proc.delay) flags.push(`delay: ${proc.delay}ms`)
124
+ if (proc.condition) flags.push(`if: ${proc.condition}`)
125
+ if (proc.watch) {
126
+ const patterns = Array.isArray(proc.watch) ? proc.watch : [proc.watch]
127
+ flags.push(`watch: ${patterns.join(', ')}`)
128
+ }
129
+ const suffix = flags.length > 0 ? ` (${flags.join(', ')})` : ''
130
+ console.info(` ${name}: ${proc.command}${suffix}`)
131
+ }
132
+ }
133
+ printWarnings(warnings)
134
+ process.exit(0)
135
+ }
136
+
137
+ if (parsed.exec) {
138
+ const raw = await loadConfig(parsed.configPath)
139
+ const config = validateConfig(raw)
140
+ const proc = config.processes[parsed.execName!]
141
+ if (!proc) {
142
+ const names = Object.keys(config.processes)
143
+ throw new Error(`Unknown process "${parsed.execName}". Available: ${names.join(', ')}`)
144
+ }
145
+
146
+ const cwd = proc.cwd ? resolve(proc.cwd) : process.cwd()
147
+ const envFromFile = proc.envFile ? loadEnvFiles(proc.envFile, cwd) : {}
148
+ const env: Record<string, string> = {
149
+ ...(process.env as Record<string, string>),
150
+ ...envFromFile,
151
+ ...proc.env
152
+ }
153
+
154
+ const child = Bun.spawn(['sh', '-c', parsed.execCommand!], {
155
+ cwd,
156
+ env,
157
+ stdout: 'inherit',
158
+ stdin: 'inherit',
159
+ stderr: 'inherit'
160
+ })
161
+ process.exit(await child.exited)
162
+ }
163
+
164
+ if (parsed.debug) {
165
+ enableDebugLog()
166
+ }
167
+
168
+ let config: ResolvedNumuxConfig
169
+ const warnings: ValidationWarning[] = []
170
+
171
+ if (parsed.commands.length > 0 || parsed.named.length > 0) {
172
+ config = buildConfigFromArgs(parsed.commands, parsed.named, { noRestart: parsed.noRestart })
173
+ } else {
174
+ const raw = await loadConfig(parsed.configPath)
175
+ config = validateConfig(raw, warnings)
176
+
177
+ if (parsed.noRestart) {
178
+ for (const proc of Object.values(config.processes)) {
179
+ proc.maxRestarts = 0
180
+ }
181
+ }
182
+ }
183
+
184
+ if (parsed.noWatch) {
185
+ for (const proc of Object.values(config.processes)) {
186
+ delete proc.watch
187
+ }
188
+ }
189
+
190
+ if (parsed.only || parsed.exclude) {
191
+ config = filterConfig(config, parsed.only, parsed.exclude)
192
+ }
193
+
194
+ const manager = new ProcessManager(config)
195
+
196
+ let logWriter: LogWriter | undefined
197
+ if (parsed.logDir) {
198
+ logWriter = new LogWriter(parsed.logDir)
199
+ }
200
+
201
+ printWarnings(warnings)
202
+
203
+ if (parsed.prefix) {
204
+ const display = new PrefixDisplay(manager, config, {
205
+ logWriter,
206
+ killOthers: parsed.killOthers,
207
+ timestamps: parsed.timestamps
208
+ })
209
+ await display.start()
210
+ } else {
211
+ if (logWriter) {
212
+ manager.on(logWriter.handleEvent)
213
+ }
214
+ const app = new App(manager, config)
215
+ setupShutdownHandlers(app, logWriter)
216
+ await app.start()
217
+ }
218
+ }
219
+
220
+ function printWarnings(warnings: ValidationWarning[]): void {
221
+ for (const w of warnings) {
222
+ console.warn(`Warning: process "${w.process}": ${w.message}`)
223
+ }
224
+ }
225
+
226
+ main().catch(err => {
227
+ console.error(err instanceof Error ? err.message : err)
228
+ process.exit(1)
229
+ })