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.
- package/README.md +27 -17
- package/dist/bin.js +2611 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +7 -0
- package/dist/types.d.ts +53 -0
- package/package.json +13 -9
- package/src/cli.ts +0 -217
- package/src/completions.ts +0 -121
- package/src/config/expand-scripts.ts +0 -96
- package/src/config/interpolate.ts +0 -50
- package/src/config/loader.ts +0 -76
- package/src/config/resolver.ts +0 -67
- package/src/config/validator.ts +0 -150
- package/src/config.ts +0 -8
- package/src/index.ts +0 -258
- package/src/process/manager.ts +0 -379
- package/src/process/ready.ts +0 -45
- package/src/process/runner.ts +0 -243
- package/src/types.ts +0 -57
- package/src/ui/app.ts +0 -454
- package/src/ui/pane.ts +0 -125
- package/src/ui/prefix.ts +0 -207
- package/src/ui/status-bar.ts +0 -58
- package/src/ui/tabs.ts +0 -246
- package/src/utils/color.ts +0 -93
- package/src/utils/env-file.ts +0 -58
- package/src/utils/log-writer.ts +0 -48
- package/src/utils/logger.ts +0 -32
- package/src/utils/shutdown.ts +0 -39
- package/src/utils/watcher.ts +0 -53
package/src/process/manager.ts
DELETED
|
@@ -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
|
-
}
|
package/src/process/ready.ts
DELETED
|
@@ -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
|
-
}
|
package/src/process/runner.ts
DELETED
|
@@ -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
|
-
}
|