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,379 +0,0 @@
1
- import { resolve } from 'node:path'
2
- import { resolveDependencyTiers } from '../config/resolver'
3
- import type { ProcessEvent, ProcessState, ProcessStatus, ResolvedNumuxConfig } from '../types'
4
- import { log } from '../utils/logger'
5
- import { FileWatcher } from '../utils/watcher'
6
- import { ProcessRunner } from './runner'
7
-
8
- type EventListener = (event: ProcessEvent) => void
9
-
10
- const BACKOFF_BASE_MS = 1000
11
- const BACKOFF_MAX_MS = 30_000
12
- const BACKOFF_RESET_MS = 10_000
13
-
14
- export class ProcessManager {
15
- private config: ResolvedNumuxConfig
16
- private runners = new Map<string, ProcessRunner>()
17
- private states = new Map<string, ProcessState>()
18
- private tiers: string[][]
19
- private listeners: EventListener[] = []
20
- private stopping = false
21
- private lastCols = 80
22
- private lastRows = 24
23
- private restartAttempts = new Map<string, number>()
24
- private restartTimers = new Map<string, ReturnType<typeof setTimeout>>()
25
- private startTimes = new Map<string, number>()
26
- private pendingReadyResolvers = new Map<string, () => void>()
27
- private fileWatcher?: FileWatcher
28
-
29
- constructor(config: ResolvedNumuxConfig) {
30
- this.config = config
31
- this.tiers = resolveDependencyTiers(config)
32
- log(`Resolved ${this.tiers.length} dependency tiers:`, this.tiers)
33
-
34
- for (const [name, proc] of Object.entries(config.processes)) {
35
- this.states.set(name, {
36
- name,
37
- config: proc,
38
- status: 'pending',
39
- exitCode: null,
40
- restartCount: 0
41
- })
42
- }
43
- }
44
-
45
- on(listener: EventListener): void {
46
- this.listeners.push(listener)
47
- }
48
-
49
- private emit(event: ProcessEvent): void {
50
- for (const listener of this.listeners) {
51
- listener(event)
52
- }
53
- }
54
-
55
- getState(name: string): ProcessState | undefined {
56
- return this.states.get(name)
57
- }
58
-
59
- getAllStates(): ProcessState[] {
60
- return [...this.states.values()]
61
- }
62
-
63
- /** Names in display order (topological) */
64
- getProcessNames(): string[] {
65
- return this.tiers.flat()
66
- }
67
-
68
- async startAll(cols: number, rows: number): Promise<void> {
69
- log('Starting all processes')
70
- this.lastCols = cols
71
- this.lastRows = rows
72
-
73
- for (const tier of this.tiers) {
74
- const readyPromises: Promise<void>[] = []
75
-
76
- for (const name of tier) {
77
- const proc = this.config.processes[name]
78
-
79
- // Evaluate condition
80
- if (proc.condition && !evaluateCondition(proc.condition)) {
81
- log(`Skipping ${name}: condition "${proc.condition}" not met`)
82
- this.updateStatus(name, 'skipped')
83
- continue
84
- }
85
-
86
- // Check if any dependency failed or was skipped
87
- const deps = proc.dependsOn ?? []
88
- const failedDep = deps.find(d => {
89
- const s = this.states.get(d)!.status
90
- return s === 'failed' || s === 'skipped'
91
- })
92
-
93
- if (failedDep) {
94
- log(`Skipping ${name}: dependency ${failedDep} failed`)
95
- this.updateStatus(name, 'skipped')
96
- continue
97
- }
98
-
99
- const { promise, resolve } = Promise.withResolvers<void>()
100
- readyPromises.push(promise)
101
-
102
- this.pendingReadyResolvers.set(name, resolve)
103
- this.createRunner(name, () => {
104
- this.pendingReadyResolvers.delete(name)
105
- resolve()
106
- })
107
- this.startProcess(name, cols, rows)
108
- }
109
-
110
- // Wait for all processes in this tier to become ready
111
- if (readyPromises.length > 0) {
112
- await Promise.all(readyPromises)
113
- }
114
- }
115
-
116
- this.setupWatchers()
117
- }
118
-
119
- private startProcess(name: string, cols: number, rows: number): void {
120
- const delay = this.config.processes[name].delay
121
- if (delay) {
122
- log(`[${name}] Delaying start by ${delay}ms`)
123
- const timer = setTimeout(() => {
124
- this.restartTimers.delete(name)
125
- if (this.stopping) return
126
- this.startTimes.set(name, Date.now())
127
- this.runners.get(name)!.start(cols, rows)
128
- }, delay)
129
- this.restartTimers.set(name, timer)
130
- } else {
131
- this.startTimes.set(name, Date.now())
132
- this.runners.get(name)!.start(cols, rows)
133
- }
134
- }
135
-
136
- private createRunner(name: string, onInitialReady?: () => void): void {
137
- let readyResolved = !onInitialReady
138
- const runner = new ProcessRunner(name, this.config.processes[name], {
139
- onStatus: status => this.updateStatus(name, status),
140
- onOutput: data => this.emit({ type: 'output', name, data }),
141
- onExit: code => {
142
- const state = this.states.get(name)!
143
- state.exitCode = code
144
- this.emit({ type: 'exit', name, code })
145
- if (!readyResolved) {
146
- readyResolved = true
147
- onInitialReady!()
148
- }
149
- this.scheduleAutoRestart(name, code)
150
- },
151
- onReady: () => {
152
- if (!readyResolved) {
153
- readyResolved = true
154
- onInitialReady!()
155
- }
156
- }
157
- })
158
- this.runners.set(name, runner)
159
- }
160
-
161
- private scheduleAutoRestart(name: string, exitCode: number | null): void {
162
- if (this.stopping) return
163
- const proc = this.config.processes[name]
164
- if (proc.persistent === false) return
165
- if (exitCode === 0) return
166
- // null exitCode means spawn failed — retrying won't help
167
- if (exitCode === null) return
168
- log(`Scheduling auto-restart for ${name} (exit code: ${exitCode})`)
169
-
170
- // Reset backoff if the process ran long enough
171
- const startTime = this.startTimes.get(name) ?? 0
172
- if (Date.now() - startTime > BACKOFF_RESET_MS) {
173
- this.restartAttempts.set(name, 0)
174
- }
175
-
176
- const attempt = this.restartAttempts.get(name) ?? 0
177
-
178
- // Enforce maxRestarts limit
179
- const maxRestarts = proc.maxRestarts
180
- if (maxRestarts !== undefined && attempt >= maxRestarts) {
181
- log(`[${name}] Reached maxRestarts limit (${maxRestarts}), not restarting`)
182
- const encoder = new TextEncoder()
183
- const msg = `\r\n\x1b[31m[numux] reached restart limit (${maxRestarts}), giving up\x1b[0m\r\n`
184
- this.emit({ type: 'output', name, data: encoder.encode(msg) })
185
- return
186
- }
187
-
188
- const delay = Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_MAX_MS)
189
- this.restartAttempts.set(name, attempt + 1)
190
-
191
- const encoder = new TextEncoder()
192
- const msg = `\r\n\x1b[33m[numux] restarting in ${(delay / 1000).toFixed(0)}s (attempt ${attempt + 1}${maxRestarts !== undefined ? `/${maxRestarts}` : ''})...\x1b[0m\r\n`
193
- this.emit({ type: 'output', name, data: encoder.encode(msg) })
194
-
195
- const timer = setTimeout(() => {
196
- this.restartTimers.delete(name)
197
- if (this.stopping) return
198
- const runner = this.runners.get(name)
199
- if (!runner) return
200
- const state = this.states.get(name)
201
- if (state) state.restartCount++
202
- this.startTimes.set(name, Date.now())
203
- runner.restart(this.lastCols, this.lastRows)
204
- }, delay)
205
- this.restartTimers.set(name, timer)
206
- }
207
-
208
- private setupWatchers(): void {
209
- const encoder = new TextEncoder()
210
- for (const [name, proc] of Object.entries(this.config.processes)) {
211
- if (!proc.watch) continue
212
- if (!this.fileWatcher) this.fileWatcher = new FileWatcher()
213
-
214
- const patterns = Array.isArray(proc.watch) ? proc.watch : [proc.watch]
215
- const cwd = proc.cwd ? resolve(proc.cwd) : process.cwd()
216
-
217
- this.fileWatcher.watch(name, patterns, cwd, changedFile => {
218
- const state = this.states.get(name)
219
- if (!state) return
220
- // Don't restart processes that are stopped/finished, pending, stopping, or skipped
221
- if (
222
- state.status === 'pending' ||
223
- state.status === 'stopped' ||
224
- state.status === 'finished' ||
225
- state.status === 'stopping' ||
226
- state.status === 'skipped'
227
- )
228
- return
229
-
230
- log(`[${name}] File changed: ${changedFile}, restarting`)
231
- const msg = `\r\n\x1b[36m[numux] file changed: ${changedFile}, restarting...\x1b[0m\r\n`
232
- this.emit({ type: 'output', name, data: encoder.encode(msg) })
233
- this.restart(name, this.lastCols, this.lastRows)
234
- })
235
- }
236
- }
237
-
238
- private updateStatus(name: string, status: ProcessStatus): void {
239
- const state = this.states.get(name)!
240
- state.status = status
241
- this.emit({ type: 'status', name, status })
242
- }
243
-
244
- restart(name: string, cols: number, rows: number): void {
245
- const state = this.states.get(name)
246
- if (!state) return
247
- if (state.status === 'pending' || state.status === 'stopping' || state.status === 'skipped') return
248
-
249
- const runner = this.runners.get(name)
250
- if (!runner) return
251
-
252
- // Cancel pending auto-restart and reset backoff
253
- const timer = this.restartTimers.get(name)
254
- if (timer) {
255
- clearTimeout(timer)
256
- this.restartTimers.delete(name)
257
- }
258
- this.restartAttempts.set(name, 0)
259
-
260
- state.exitCode = null
261
- state.restartCount++
262
- this.startTimes.set(name, Date.now())
263
- runner.restart(cols, rows)
264
- }
265
-
266
- /** Stop a single process. No-op if already stopped or not running. */
267
- async stop(name: string): Promise<void> {
268
- const state = this.states.get(name)
269
- if (!state) return
270
- if (
271
- state.status === 'pending' ||
272
- state.status === 'stopped' ||
273
- state.status === 'finished' ||
274
- state.status === 'stopping' ||
275
- state.status === 'skipped'
276
- )
277
- return
278
-
279
- // Cancel pending auto-restart
280
- const timer = this.restartTimers.get(name)
281
- if (timer) {
282
- clearTimeout(timer)
283
- this.restartTimers.delete(name)
284
- }
285
-
286
- const runner = this.runners.get(name)
287
- if (!runner) return
288
-
289
- // Always try to stop via runner — the process may still be alive even if
290
- // status is 'failed' (e.g. readyTimeout fired but process didn't exit).
291
- // runner.stop() is a no-op if proc is null (already exited).
292
- if (state.status === 'failed') {
293
- await runner.stop()
294
- this.updateStatus(name, 'stopped')
295
- return
296
- }
297
-
298
- await runner.stop()
299
- }
300
-
301
- /** Start a single process that is stopped or failed. */
302
- start(name: string, cols: number, rows: number): void {
303
- const state = this.states.get(name)
304
- if (!state) return
305
- if (state.status !== 'stopped' && state.status !== 'finished' && state.status !== 'failed') return
306
-
307
- // Cancel pending auto-restart and reset backoff
308
- const timer = this.restartTimers.get(name)
309
- if (timer) {
310
- clearTimeout(timer)
311
- this.restartTimers.delete(name)
312
- }
313
- this.restartAttempts.set(name, 0)
314
-
315
- state.exitCode = null
316
- state.restartCount++
317
- this.startTimes.set(name, Date.now())
318
- this.runners.get(name)?.restart(cols, rows)
319
- }
320
-
321
- /** Restart all processes. Restarts each runner in-place without dependency re-resolution. */
322
- restartAll(cols: number, rows: number): void {
323
- log('Restarting all processes')
324
- for (const name of this.tiers.flat()) {
325
- this.restart(name, cols, rows)
326
- }
327
- }
328
-
329
- resize(name: string, cols: number, rows: number): void {
330
- this.runners.get(name)?.resize(cols, rows)
331
- }
332
-
333
- resizeAll(cols: number, rows: number): void {
334
- this.lastCols = cols
335
- this.lastRows = rows
336
- for (const runner of this.runners.values()) {
337
- runner.resize(cols, rows)
338
- }
339
- }
340
-
341
- write(name: string, data: string): void {
342
- this.runners.get(name)?.write(data)
343
- }
344
-
345
- async stopAll(): Promise<void> {
346
- log('Stopping all processes')
347
- this.stopping = true
348
- // Close file watchers
349
- this.fileWatcher?.close()
350
- // Cancel all pending auto-restart and delay timers
351
- for (const timer of this.restartTimers.values()) {
352
- clearTimeout(timer)
353
- }
354
- this.restartTimers.clear()
355
- // Resolve any pending ready promises (e.g. processes waiting on delay)
356
- for (const resolve of this.pendingReadyResolvers.values()) {
357
- resolve()
358
- }
359
- this.pendingReadyResolvers.clear()
360
- // Stop in reverse tier order — use allSettled so one failure doesn't skip remaining tiers
361
- const reversed = [...this.tiers].reverse()
362
- for (const tier of reversed) {
363
- await Promise.allSettled(tier.map(name => this.runners.get(name)?.stop()).filter(Boolean))
364
- }
365
- }
366
- }
367
-
368
- const FALSY_VALUES = new Set(['', '0', 'false', 'no', 'off'])
369
-
370
- /** Evaluate a condition string against environment variables.
371
- * `"VAR"` → truthy if VAR is set and not a falsy value.
372
- * `"!VAR"` → truthy if VAR is unset or a falsy value. */
373
- function evaluateCondition(condition: string): boolean {
374
- const negated = condition.startsWith('!')
375
- const varName = negated ? condition.slice(1) : condition
376
- const value = process.env[varName]
377
- const isTruthy = value !== undefined && !FALSY_VALUES.has(value.toLowerCase())
378
- return negated ? !isTruthy : isTruthy
379
- }
@@ -1,45 +0,0 @@
1
- import type { NumuxProcessConfig } from '../types'
2
-
3
- /** Keep the last 64 KB of output for pattern matching */
4
- const BUFFER_CAP = 65_536
5
-
6
- /**
7
- * Determines when a process should be considered "ready"
8
- * based on its configuration.
9
- */
10
- export function createReadinessChecker(config: NumuxProcessConfig) {
11
- const pattern = config.readyPattern ? new RegExp(config.readyPattern) : null
12
- const persistent = config.persistent !== false
13
- let outputBuffer = ''
14
-
15
- return {
16
- /**
17
- * Feed process output data. Returns true if the process
18
- * should now be considered ready.
19
- */
20
- feedOutput(data: string): boolean {
21
- if (!(persistent && pattern)) return false
22
- outputBuffer += data
23
- if (outputBuffer.length > BUFFER_CAP) {
24
- outputBuffer = outputBuffer.slice(-BUFFER_CAP)
25
- }
26
- return pattern.test(outputBuffer)
27
- },
28
-
29
- /**
30
- * Returns true if the process is immediately ready on spawn
31
- * (persistent with no readyPattern).
32
- */
33
- get isImmediatelyReady(): boolean {
34
- return persistent && !pattern
35
- },
36
-
37
- /**
38
- * Returns true if readiness depends on exit code
39
- * (non-persistent processes).
40
- */
41
- get dependsOnExit(): boolean {
42
- return !persistent
43
- }
44
- }
45
- }
@@ -1,243 +0,0 @@
1
- import { resolve } from 'node:path'
2
- import type { NumuxProcessConfig, ProcessStatus } from '../types'
3
- import { loadEnvFiles } from '../utils/env-file'
4
- import { log } from '../utils/logger'
5
- import { createReadinessChecker } from './ready'
6
-
7
- export type RunnerEventHandler = {
8
- onStatus: (status: ProcessStatus) => void
9
- onOutput: (data: Uint8Array) => void
10
- onExit: (code: number | null) => void
11
- onReady: () => void
12
- }
13
-
14
- export class ProcessRunner {
15
- readonly name: string
16
- private config: NumuxProcessConfig
17
- private handler: RunnerEventHandler
18
- private proc: ReturnType<typeof Bun.spawn> | null = null
19
- private readiness: ReturnType<typeof createReadinessChecker>
20
- private _ready = false
21
- private stopping = false
22
- private decoder = new TextDecoder()
23
- private generation = 0
24
- private readyTimer: ReturnType<typeof setTimeout> | null = null
25
- private restarting = false
26
- private readyTimedOut = false
27
-
28
- constructor(name: string, config: NumuxProcessConfig, handler: RunnerEventHandler) {
29
- this.name = name
30
- this.config = config
31
- this.handler = handler
32
- this.readiness = createReadinessChecker(config)
33
- }
34
-
35
- get isReady(): boolean {
36
- return this._ready
37
- }
38
-
39
- private get signal(): NodeJS.Signals {
40
- return this.config.stopSignal ?? 'SIGTERM'
41
- }
42
-
43
- start(cols: number, rows: number): void {
44
- const gen = ++this.generation
45
- this.stopping = false
46
- log(`[${this.name}] Starting (gen ${gen}): ${this.config.command}`)
47
- this.handler.onStatus('starting')
48
-
49
- const cwd = this.config.cwd ? resolve(this.config.cwd) : process.cwd()
50
-
51
- try {
52
- const envFromFile = this.config.envFile ? loadEnvFiles(this.config.envFile, cwd) : {}
53
- const noColor = 'NO_COLOR' in process.env
54
- const env: Record<string, string> = {
55
- ...(process.env as Record<string, string>),
56
- ...(noColor ? {} : { FORCE_COLOR: '1' }),
57
- TERM: 'xterm-256color',
58
- ...envFromFile,
59
- ...this.config.env
60
- }
61
-
62
- this.proc = Bun.spawn(['sh', '-c', this.config.command], {
63
- cwd,
64
- env,
65
- terminal: {
66
- cols,
67
- rows,
68
- data: (_terminal, data) => {
69
- if (this.generation !== gen) return
70
- this.handler.onOutput(data)
71
- this.checkReadiness(data)
72
- }
73
- }
74
- })
75
- } catch (err) {
76
- log(`[${this.name}] Spawn failed: ${err}`)
77
- const encoder = new TextEncoder()
78
- const msg = `\r\n\x1b[31m[numux] failed to start: ${err instanceof Error ? err.message : err}\x1b[0m\r\n`
79
- this.handler.onOutput(encoder.encode(msg))
80
- this.handler.onStatus('failed')
81
- this.handler.onExit(null)
82
- return
83
- }
84
-
85
- this.handler.onStatus(this.config.persistent !== false ? 'running' : 'starting')
86
-
87
- if (this.readiness.isImmediatelyReady) {
88
- this.markReady()
89
- }
90
-
91
- this.startReadyTimeout(gen)
92
-
93
- this.proc.exited
94
- .then(code => {
95
- if (this.generation !== gen) return
96
- log(`[${this.name}] Exited with code ${code}`)
97
-
98
- if (this.readiness.dependsOnExit && code === 0) {
99
- this.markReady()
100
- }
101
-
102
- if (code === 127 || code === 126) {
103
- const encoder = new TextEncoder()
104
- const hint = code === 127 ? 'command not found' : 'permission denied'
105
- const msg = `\r\n\x1b[31m[numux] exit ${code}: ${hint}\x1b[0m\r\n`
106
- this.handler.onOutput(encoder.encode(msg))
107
- }
108
-
109
- // When readyTimeout already marked the process as failed, suppress
110
- // duplicate status/exit events to avoid double onStatus('failed')
111
- // and unintended auto-restart scheduling.
112
- if (!this.readyTimedOut) {
113
- const status: ProcessStatus = this.stopping ? 'stopped' : code === 0 ? 'finished' : 'failed'
114
- this.handler.onStatus(status)
115
- this.handler.onExit(code)
116
- }
117
- })
118
- .catch(err => {
119
- if (this.generation !== gen) return
120
- log(`[${this.name}] proc.exited rejected: ${err}`)
121
- this.handler.onStatus('failed')
122
- this.handler.onExit(null)
123
- })
124
- }
125
-
126
- private checkReadiness(data: Uint8Array): void {
127
- if (this._ready) return
128
- const text = this.decoder.decode(data, { stream: true })
129
- if (this.readiness.feedOutput(text)) {
130
- this.markReady()
131
- }
132
- }
133
-
134
- private startReadyTimeout(gen: number): void {
135
- const timeout = this.config.readyTimeout
136
- if (!(timeout && this.config.readyPattern) || this.config.persistent === false) return
137
-
138
- this.readyTimer = setTimeout(() => {
139
- this.readyTimer = null
140
- if (this.generation !== gen || this._ready) return
141
- this.readyTimedOut = true
142
- log(`[${this.name}] Ready timeout after ${timeout}ms`)
143
- const encoder = new TextEncoder()
144
- const msg = `\r\n\x1b[31m[numux] readyPattern not matched within ${(timeout / 1000).toFixed(0)}s — marking as failed\x1b[0m\r\n`
145
- this.handler.onOutput(encoder.encode(msg))
146
- this.handler.onStatus('failed')
147
- this.handler.onReady() // unblock the dependency tier
148
- }, timeout)
149
- }
150
-
151
- private clearReadyTimeout(): void {
152
- if (this.readyTimer) {
153
- clearTimeout(this.readyTimer)
154
- this.readyTimer = null
155
- }
156
- }
157
-
158
- private markReady(): void {
159
- if (this._ready) return
160
- this._ready = true
161
- this.clearReadyTimeout()
162
- log(`[${this.name}] Ready`)
163
- this.handler.onStatus('ready')
164
- this.handler.onReady()
165
- }
166
-
167
- async restart(cols: number, rows: number): Promise<void> {
168
- if (this.restarting) return
169
- this.restarting = true
170
- log(`[${this.name}] Restarting`)
171
- this.clearReadyTimeout()
172
- if (this.proc) {
173
- this.stopping = true
174
- this.handler.onStatus('stopping')
175
- this.killProcessGroup(this.signal)
176
- const result = await Promise.race([
177
- this.proc.exited.then(() => 'exited' as const),
178
- new Promise<'timeout'>(r => setTimeout(() => r('timeout'), 2000))
179
- ])
180
- if (result === 'timeout' && this.proc) {
181
- this.killProcessGroup('SIGKILL')
182
- await this.proc.exited
183
- }
184
- }
185
- this.proc = null
186
- this._ready = false
187
- this.restarting = false
188
- this.readyTimedOut = false
189
- this.readiness = createReadinessChecker(this.config)
190
- this.start(cols, rows)
191
- }
192
-
193
- async stop(timeoutMs = 5000): Promise<void> {
194
- if (!this.proc) return
195
-
196
- this.clearReadyTimeout()
197
- this.stopping = true
198
- log(`[${this.name}] Stopping (timeout: ${timeoutMs}ms)`)
199
- this.handler.onStatus('stopping')
200
- this.killProcessGroup(this.signal)
201
-
202
- const exited = Promise.race([
203
- this.proc.exited,
204
- new Promise<'timeout'>(r => setTimeout(() => r('timeout'), timeoutMs))
205
- ])
206
-
207
- const result = await exited
208
- if (result === 'timeout') {
209
- this.killProcessGroup('SIGKILL')
210
- await this.proc.exited
211
- }
212
-
213
- this.proc = null
214
- }
215
-
216
- /** Signal the entire process group (child + its descendants), falling back to direct PID */
217
- private killProcessGroup(sig: NodeJS.Signals): void {
218
- if (!this.proc) return
219
- try {
220
- // Negative PID signals the entire process group
221
- process.kill(-this.proc.pid, sig)
222
- } catch {
223
- // Process group may not exist; fall back to direct kill
224
- try {
225
- this.proc.kill(sig)
226
- } catch {
227
- // Process already exited
228
- }
229
- }
230
- }
231
-
232
- resize(cols: number, rows: number): void {
233
- if (this.proc?.terminal) {
234
- this.proc.terminal.resize(cols, rows)
235
- }
236
- }
237
-
238
- write(data: string): void {
239
- if (this.config.interactive && this.proc?.terminal) {
240
- this.proc.terminal.write(data)
241
- }
242
- }
243
- }