numux 0.0.1 → 1.1.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,199 @@
1
+ import type { ProcessManager } from '../process/manager'
2
+ import type { ProcessEvent, ProcessStatus, ResolvedNumuxConfig } from '../types'
3
+ import { ANSI_RESET, buildProcessColorMap, STATUS_ANSI, stripAnsi } from '../utils/color'
4
+ import type { LogWriter } from '../utils/log-writer'
5
+
6
+ const RESET = ANSI_RESET
7
+ const DIM = '\x1b[90m'
8
+
9
+ /**
10
+ * Concurrently-style prefixed output mode for CI and headless environments.
11
+ * Prints all process output interleaved with colored [name] prefixes.
12
+ */
13
+ export interface PrefixDisplayOptions {
14
+ logWriter?: LogWriter
15
+ killOthers?: boolean
16
+ timestamps?: boolean
17
+ }
18
+
19
+ export class PrefixDisplay {
20
+ private manager: ProcessManager
21
+ private colors: Map<string, string>
22
+ private noColor: boolean
23
+ private decoders = new Map<string, TextDecoder>()
24
+ private buffers = new Map<string, string>()
25
+ private maxNameLen: number
26
+ private logWriter?: LogWriter
27
+ private killOthers: boolean
28
+ private timestamps: boolean
29
+ private stopping = false
30
+
31
+ constructor(manager: ProcessManager, config: ResolvedNumuxConfig, options: PrefixDisplayOptions = {}) {
32
+ this.manager = manager
33
+ this.logWriter = options.logWriter
34
+ this.killOthers = options.killOthers ?? false
35
+ this.timestamps = options.timestamps ?? false
36
+ this.noColor = 'NO_COLOR' in process.env
37
+ const names = manager.getProcessNames()
38
+ this.maxNameLen = Math.max(...names.map(n => n.length))
39
+ this.colors = buildProcessColorMap(names, config)
40
+ for (const name of names) {
41
+ this.decoders.set(name, new TextDecoder('utf-8', { fatal: false }))
42
+ this.buffers.set(name, '')
43
+ }
44
+ }
45
+
46
+ async start(): Promise<void> {
47
+ const handler = (event: ProcessEvent) => {
48
+ this.logWriter?.handleEvent(event)
49
+ this.handleEvent(event)
50
+ }
51
+ this.manager.on(handler)
52
+
53
+ process.on('SIGINT', () => this.shutdown())
54
+ process.on('SIGTERM', () => this.shutdown())
55
+ process.on('uncaughtException', err => {
56
+ process.stderr.write(`numux: unexpected error: ${err?.stack ?? err}\n`)
57
+ this.shutdown()
58
+ })
59
+ process.on('unhandledRejection', (reason: unknown) => {
60
+ const message = reason instanceof Error ? reason.message : String(reason)
61
+ process.stderr.write(`numux: unhandled rejection: ${message}\n`)
62
+ this.shutdown()
63
+ })
64
+
65
+ const cols = process.stdout.columns || 80
66
+ const rows = process.stdout.rows || 24
67
+ await this.manager.startAll(cols, rows)
68
+
69
+ // After all processes started, check if any are non-persistent
70
+ // If all non-persistent processes have exited, we're done
71
+ this.checkAllDone()
72
+ }
73
+
74
+ private handleEvent(event: ProcessEvent): void {
75
+ if (event.type === 'output') {
76
+ this.handleOutput(event.name, event.data)
77
+ } else if (event.type === 'status') {
78
+ this.handleStatus(event.name, event.status)
79
+ } else if (event.type === 'exit') {
80
+ // Flush remaining buffer
81
+ this.flushBuffer(event.name)
82
+ if (this.killOthers) {
83
+ this.killAllAndExit(event.name)
84
+ } else {
85
+ this.checkAllDone()
86
+ }
87
+ }
88
+ }
89
+
90
+ private handleOutput(name: string, data: Uint8Array): void {
91
+ const decoder = this.decoders.get(name) ?? new TextDecoder()
92
+ const text = decoder.decode(data, { stream: true })
93
+ const buffer = (this.buffers.get(name) ?? '') + text
94
+ const lines = buffer.split(/\r?\n/)
95
+
96
+ // Keep the last element (incomplete line) in the buffer
97
+ this.buffers.set(name, lines.pop() ?? '')
98
+
99
+ for (const line of lines) {
100
+ this.printLine(name, line)
101
+ }
102
+ }
103
+
104
+ private handleStatus(name: string, status: ProcessStatus): void {
105
+ if (status === 'ready' || status === 'failed' || status === 'stopped' || status === 'skipped') {
106
+ if (this.noColor) {
107
+ this.printLine(name, `→ ${status}`)
108
+ } else {
109
+ const ansi = STATUS_ANSI[status]
110
+ const statusText = ansi ? `${ansi}${status}${RESET}` : status
111
+ this.printLine(name, `${DIM}→ ${statusText}${DIM}${RESET}`)
112
+ }
113
+ }
114
+ }
115
+
116
+ private formatTimestamp(): string {
117
+ const now = new Date()
118
+ const h = String(now.getHours()).padStart(2, '0')
119
+ const m = String(now.getMinutes()).padStart(2, '0')
120
+ const s = String(now.getSeconds()).padStart(2, '0')
121
+ return `${h}:${m}:${s}`
122
+ }
123
+
124
+ private printLine(name: string, line: string): void {
125
+ const padded = name.padEnd(this.maxNameLen)
126
+ const ts = this.timestamps ? `${DIM}[${this.formatTimestamp()}]${RESET} ` : ''
127
+ const tsPlain = this.timestamps ? `[${this.formatTimestamp()}] ` : ''
128
+ if (this.noColor) {
129
+ process.stdout.write(`${tsPlain}[${padded}] ${stripAnsi(line)}\n`)
130
+ } else {
131
+ const color = this.colors.get(name) ?? ''
132
+ process.stdout.write(`${ts}${color}[${padded}]${RESET} ${line}\n`)
133
+ }
134
+ }
135
+
136
+ private flushBuffer(name: string): void {
137
+ const remaining = this.buffers.get(name) ?? ''
138
+ if (remaining.length > 0) {
139
+ this.printLine(name, remaining)
140
+ this.buffers.set(name, '')
141
+ }
142
+ }
143
+
144
+ private checkAllDone(): void {
145
+ if (this.stopping) return
146
+ const states = this.manager.getAllStates()
147
+ const allDone = states.every(s => s.status === 'stopped' || s.status === 'failed' || s.status === 'skipped')
148
+ if (allDone) {
149
+ this.printSummary()
150
+ this.logWriter?.close()
151
+ const anyFailed = states.some(s => s.status === 'failed')
152
+ process.exit(anyFailed ? 1 : 0)
153
+ }
154
+ }
155
+
156
+ private killAllAndExit(exitedName: string): void {
157
+ if (this.stopping) return
158
+ this.stopping = true
159
+ const state = this.manager.getState(exitedName)
160
+ const code = state?.exitCode ?? 1
161
+ this.manager.stopAll().then(() => {
162
+ for (const name of this.manager.getProcessNames()) {
163
+ this.flushBuffer(name)
164
+ }
165
+ this.printSummary()
166
+ this.logWriter?.close()
167
+ process.exit(code === 0 ? 0 : 1)
168
+ })
169
+ }
170
+
171
+ private printSummary(): void {
172
+ const states = this.manager.getAllStates()
173
+ const namePad = Math.max(...states.map(s => s.name.length))
174
+ process.stdout.write('\n')
175
+ for (const s of states) {
176
+ const name = s.name.padEnd(namePad)
177
+ const exitStr = s.exitCode !== null ? `exit ${s.exitCode}` : ''
178
+ if (this.noColor) {
179
+ process.stdout.write(` ${name} ${s.status}${exitStr ? ` (${exitStr})` : ''}\n`)
180
+ } else {
181
+ const ansi = STATUS_ANSI[s.status] ?? ''
182
+ const statusText = ansi ? `${ansi}${s.status}${RESET}` : s.status
183
+ process.stdout.write(` ${name} ${statusText}${exitStr ? ` ${DIM}(${exitStr})${RESET}` : ''}\n`)
184
+ }
185
+ }
186
+ }
187
+
188
+ async shutdown(): Promise<void> {
189
+ if (this.stopping) return
190
+ this.stopping = true
191
+ await this.manager.stopAll()
192
+ for (const name of this.manager.getProcessNames()) {
193
+ this.flushBuffer(name)
194
+ }
195
+ this.logWriter?.close()
196
+ const anyFailed = this.manager.getAllStates().some(s => s.status === 'failed')
197
+ process.exit(anyFailed ? 1 : 0)
198
+ }
199
+ }
@@ -0,0 +1,119 @@
1
+ import {
2
+ type CliRenderer,
3
+ cyan,
4
+ fg,
5
+ green,
6
+ red,
7
+ reverse,
8
+ StyledText,
9
+ type TextChunk,
10
+ TextRenderable,
11
+ yellow
12
+ } from '@opentui/core'
13
+ import type { ProcessStatus } from '../types'
14
+
15
+ const STATUS_STYLE: Partial<Record<ProcessStatus, (input: string) => TextChunk>> = {
16
+ ready: green,
17
+ running: cyan,
18
+ failed: red,
19
+ stopped: fg('#888'),
20
+ skipped: fg('#888')
21
+ }
22
+
23
+ function plain(text: string): TextChunk {
24
+ return { __isChunk: true, text } as TextChunk
25
+ }
26
+
27
+ export class StatusBar {
28
+ readonly renderable: TextRenderable
29
+ private statuses = new Map<string, ProcessStatus>()
30
+ private colors: Map<string, string>
31
+ private scrolledUp = false
32
+ private _searchMode = false
33
+ private _searchQuery = ''
34
+ private _searchMatchCount = 0
35
+ private _searchCurrentIndex = -1
36
+
37
+ constructor(renderer: CliRenderer, names: string[], colors?: Map<string, string>) {
38
+ this.colors = colors ?? new Map()
39
+ for (const name of names) {
40
+ this.statuses.set(name, 'pending')
41
+ }
42
+
43
+ this.renderable = new TextRenderable(renderer, {
44
+ id: 'status-bar',
45
+ width: '100%',
46
+ height: 1,
47
+ content: this.buildContent(),
48
+ bg: '#1a1a1a',
49
+ paddingX: 1
50
+ })
51
+ }
52
+
53
+ updateStatus(name: string, status: ProcessStatus): void {
54
+ this.statuses.set(name, status)
55
+ this.renderable.content = this.buildContent()
56
+ }
57
+
58
+ setScrollIndicator(scrolledUp: boolean): void {
59
+ if (this.scrolledUp === scrolledUp) return
60
+ this.scrolledUp = scrolledUp
61
+ this.renderable.content = this.buildContent()
62
+ }
63
+
64
+ setSearchMode(active: boolean, query = '', matchCount = 0, currentIndex = -1): void {
65
+ this._searchMode = active
66
+ this._searchQuery = query
67
+ this._searchMatchCount = matchCount
68
+ this._searchCurrentIndex = currentIndex
69
+ this.renderable.content = this.buildContent()
70
+ }
71
+
72
+ private buildContent(): StyledText {
73
+ if (this._searchMode) {
74
+ return this.buildSearchContent()
75
+ }
76
+ const chunks: TextChunk[] = []
77
+ let first = true
78
+ for (const [name, status] of this.statuses) {
79
+ if (!first) chunks.push(plain(' '))
80
+ first = false
81
+ const styleFn = STATUS_STYLE[status]
82
+ const hexColor = this.colors.get(name)
83
+ if (styleFn) {
84
+ chunks.push(styleFn(`${name}:${status}`))
85
+ } else if (hexColor) {
86
+ chunks.push(fg(hexColor)(`${name}:${status}`))
87
+ } else {
88
+ chunks.push(plain(`${name}:${status}`))
89
+ }
90
+ }
91
+ if (this.scrolledUp) {
92
+ chunks.push(plain(' '))
93
+ chunks.push(yellow('[scrolled]'))
94
+ }
95
+ chunks.push(
96
+ plain(' Alt+\u2190\u2192/1-9: tabs Alt+PgUp/Dn: scroll Alt+R: restart Alt+S: stop/start Ctrl+C: quit')
97
+ )
98
+ return new StyledText(chunks)
99
+ }
100
+
101
+ private buildSearchContent(): StyledText {
102
+ const chunks: TextChunk[] = []
103
+ chunks.push(yellow('/'))
104
+ if (this._searchQuery) chunks.push(plain(this._searchQuery))
105
+ chunks.push(reverse(' '))
106
+ if (this._searchMatchCount === 0 && this._searchQuery) {
107
+ chunks.push(plain(' '))
108
+ chunks.push(red('no matches'))
109
+ chunks.push(plain(' Esc: close'))
110
+ } else if (this._searchMatchCount > 0) {
111
+ chunks.push(plain(' '))
112
+ chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`))
113
+ chunks.push(plain(' Enter/Shift+Enter: next/prev Esc: close'))
114
+ } else {
115
+ chunks.push(plain(' Enter: next Esc: close'))
116
+ }
117
+ return new StyledText(chunks)
118
+ }
119
+ }
package/src/ui/tabs.ts ADDED
@@ -0,0 +1,176 @@
1
+ import {
2
+ type CliRenderer,
3
+ type OptimizedBuffer,
4
+ parseColor,
5
+ type RGBA,
6
+ SelectRenderable,
7
+ SelectRenderableEvents
8
+ } from '@opentui/core'
9
+ import type { ProcessStatus } from '../types'
10
+
11
+ const STATUS_ICONS: Record<ProcessStatus, string> = {
12
+ pending: '○',
13
+ starting: '◐',
14
+ running: '◉',
15
+ ready: '●',
16
+ stopping: '◑',
17
+ stopped: '■',
18
+ failed: '✖',
19
+ skipped: '⊘'
20
+ }
21
+
22
+ /** Status-specific icon colors (override process colors) */
23
+ const STATUS_ICON_HEX: Partial<Record<ProcessStatus, string>> = {
24
+ ready: '#00cc00',
25
+ failed: '#ff5555',
26
+ stopped: '#888888',
27
+ skipped: '#888888'
28
+ }
29
+
30
+ interface OptionColors {
31
+ icon: RGBA | null
32
+ name: RGBA | null
33
+ }
34
+
35
+ /**
36
+ * SelectRenderable subclass that supports per-option coloring.
37
+ * The base SelectRenderable draws all option text with a single color.
38
+ * This overrides renderSelf to repaint the icon and name with individual
39
+ * RGBA colors after the base render.
40
+ */
41
+ class ColoredSelectRenderable extends SelectRenderable {
42
+ private _optionColors: OptionColors[] = []
43
+
44
+ setOptionColors(colors: OptionColors[]): void {
45
+ this._optionColors = colors
46
+ this.requestRender()
47
+ }
48
+
49
+ protected renderSelf(buffer: OptimizedBuffer, deltaTime: number): void {
50
+ const wasDirty = this.isDirty
51
+ super.renderSelf(buffer, deltaTime)
52
+ if (wasDirty && this.frameBuffer && this._optionColors.length > 0) {
53
+ this.colorizeOptions()
54
+ }
55
+ }
56
+
57
+ private colorizeOptions(): void {
58
+ const fb = this.frameBuffer!
59
+ // Access internal layout state (private in TS, accessible at runtime)
60
+ const scrollOffset = (this as any).scrollOffset as number
61
+ const maxVisibleItems = (this as any).maxVisibleItems as number
62
+ const linesPerItem = (this as any).linesPerItem as number
63
+ const options = this.options
64
+ const visibleCount = Math.min(maxVisibleItems, options.length - scrollOffset)
65
+
66
+ for (let i = 0; i < visibleCount; i++) {
67
+ const actualIndex = scrollOffset + i
68
+ const colors = this._optionColors[actualIndex]
69
+ if (!colors) continue
70
+ const itemY = i * linesPerItem
71
+ // Layout: "▶ ○ name" or " ○ name" (drawText at x=1, prefix 2 chars)
72
+ // Icon at x=3, space at x=4, name starts at x=5
73
+ const optName = options[actualIndex].name
74
+ if (colors.icon) {
75
+ fb.drawText(optName.charAt(0), 3, itemY, colors.icon)
76
+ }
77
+ if (colors.name) {
78
+ fb.drawText(optName.slice(2), 5, itemY, colors.name)
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ export class TabBar {
85
+ readonly renderable: ColoredSelectRenderable
86
+ private names: string[]
87
+ private statuses: Map<string, ProcessStatus>
88
+ private descriptions: Map<string, string>
89
+ private processColors: Map<string, string>
90
+
91
+ constructor(renderer: CliRenderer, names: string[], colors?: Map<string, string>) {
92
+ this.names = names
93
+ this.statuses = new Map(names.map(n => [n, 'pending' as ProcessStatus]))
94
+ this.descriptions = new Map(names.map(n => [n, 'pending']))
95
+ this.processColors = colors ?? new Map()
96
+
97
+ this.renderable = new ColoredSelectRenderable(renderer, {
98
+ id: 'tab-bar',
99
+ width: '100%',
100
+ height: '100%',
101
+ options: names.map(n => ({
102
+ name: this.formatTab(n, 'pending'),
103
+ description: 'pending'
104
+ })),
105
+ selectedBackgroundColor: '#334455',
106
+ selectedTextColor: '#fff',
107
+ textColor: '#888',
108
+ showDescription: true,
109
+ wrapSelection: true
110
+ })
111
+ this.updateOptionColors()
112
+ }
113
+
114
+ onSelect(handler: (index: number, name: string) => void): void {
115
+ this.renderable.on(SelectRenderableEvents.ITEM_SELECTED, (index: number) => {
116
+ handler(index, this.names[index])
117
+ })
118
+ }
119
+
120
+ onSelectionChanged(handler: (index: number, name: string) => void): void {
121
+ this.renderable.on(SelectRenderableEvents.SELECTION_CHANGED, (index: number) => {
122
+ handler(index, this.names[index])
123
+ })
124
+ }
125
+
126
+ updateStatus(name: string, status: ProcessStatus, exitCode?: number | null, restartCount?: number): void {
127
+ this.statuses.set(name, status)
128
+ this.descriptions.set(name, this.formatDescription(status, exitCode, restartCount))
129
+ this.renderable.options = this.names.map(n => ({
130
+ name: this.formatTab(n, this.statuses.get(n)!),
131
+ description: this.descriptions.get(n)!
132
+ }))
133
+ this.updateOptionColors()
134
+ }
135
+
136
+ private updateOptionColors(): void {
137
+ const colors = this.names.map(name => {
138
+ const status = this.statuses.get(name)!
139
+ const statusHex = STATUS_ICON_HEX[status]
140
+ const processHex = this.processColors.get(name)
141
+ return {
142
+ icon: parseColor(statusHex ?? processHex ?? '#888888'),
143
+ name: processHex ? parseColor(processHex) : null
144
+ }
145
+ })
146
+ this.renderable.setOptionColors(colors)
147
+ }
148
+
149
+ private formatDescription(status: ProcessStatus, exitCode?: number | null, restartCount?: number): string {
150
+ let desc: string = status
151
+ if ((status === 'failed' || status === 'stopped') && exitCode != null && exitCode !== 0) {
152
+ desc = `exit ${exitCode}`
153
+ }
154
+ if (restartCount && restartCount > 0) {
155
+ desc += ` ×${restartCount}`
156
+ }
157
+ return desc
158
+ }
159
+
160
+ private formatTab(name: string, status: ProcessStatus): string {
161
+ const icon = STATUS_ICONS[status]
162
+ return `${icon} ${name}`
163
+ }
164
+
165
+ getSelectedIndex(): number {
166
+ return this.renderable.getSelectedIndex()
167
+ }
168
+
169
+ setSelectedIndex(index: number): void {
170
+ this.renderable.setSelectedIndex(index)
171
+ }
172
+
173
+ focus(): void {
174
+ this.renderable.focus()
175
+ }
176
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Convert a hex color string (e.g. "#ff8800") to an ANSI true-color escape sequence.
3
+ * Returns an empty string if the hex is malformed.
4
+ */
5
+ export function hexToAnsi(hex: string): string {
6
+ const h = hex.replace('#', '')
7
+ const r = Number.parseInt(h.slice(0, 2), 16)
8
+ const g = Number.parseInt(h.slice(2, 4), 16)
9
+ const b = Number.parseInt(h.slice(4, 6), 16)
10
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return ''
11
+ return `\x1b[38;2;${r};${g};${b}m`
12
+ }
13
+
14
+ /** Regex matching a valid 6-digit hex color (with or without leading #) */
15
+ export const HEX_COLOR_RE = /^#?[0-9a-fA-F]{6}$/
16
+
17
+ import type { ProcessStatus, ResolvedNumuxConfig } from '../types'
18
+
19
+ /** ANSI color codes for process statuses */
20
+ export const STATUS_ANSI: Partial<Record<ProcessStatus, string>> = {
21
+ ready: '\x1b[32m',
22
+ running: '\x1b[36m',
23
+ failed: '\x1b[31m',
24
+ stopped: '\x1b[90m',
25
+ skipped: '\x1b[90m'
26
+ }
27
+
28
+ export const ANSI_RESET = '\x1b[0m'
29
+
30
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping requires matching control chars
31
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()#][0-9A-Za-z]|\x1b[A-Za-z><=]/g
32
+
33
+ /** Strip ANSI escape sequences from text */
34
+ export function stripAnsi(str: string): string {
35
+ return str.replace(ANSI_RE, '')
36
+ }
37
+
38
+ /** Default palette as ANSI codes (for prefix mode stdout output) */
39
+ const DEFAULT_ANSI_COLORS = [
40
+ '\x1b[36m',
41
+ '\x1b[33m',
42
+ '\x1b[35m',
43
+ '\x1b[34m',
44
+ '\x1b[32m',
45
+ '\x1b[91m',
46
+ '\x1b[93m',
47
+ '\x1b[95m'
48
+ ]
49
+
50
+ /** Default palette as hex colors (for styled text rendering) */
51
+ const DEFAULT_HEX_COLORS = ['#00cccc', '#cccc00', '#cc00cc', '#0000cc', '#00cc00', '#ff5555', '#ffff55', '#ff55ff']
52
+
53
+ /** Build a map of process names to ANSI color codes, using explicit config colors or a default palette. */
54
+ export function buildProcessColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string> {
55
+ const map = new Map<string, string>()
56
+ if ('NO_COLOR' in process.env) return map
57
+ let paletteIndex = 0
58
+ for (const name of names) {
59
+ const explicit = config.processes[name]?.color
60
+ if (explicit) {
61
+ map.set(name, hexToAnsi(explicit))
62
+ } else {
63
+ map.set(name, DEFAULT_ANSI_COLORS[paletteIndex % DEFAULT_ANSI_COLORS.length])
64
+ paletteIndex++
65
+ }
66
+ }
67
+ return map
68
+ }
69
+
70
+ /** Build a map of process names to hex color strings (for StyledText rendering). */
71
+ export function buildProcessHexColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string> {
72
+ const map = new Map<string, string>()
73
+ if ('NO_COLOR' in process.env) return map
74
+ let paletteIndex = 0
75
+ for (const name of names) {
76
+ const explicit = config.processes[name]?.color
77
+ if (explicit) {
78
+ map.set(name, explicit.startsWith('#') ? explicit : `#${explicit}`)
79
+ } else {
80
+ map.set(name, DEFAULT_HEX_COLORS[paletteIndex % DEFAULT_HEX_COLORS.length])
81
+ paletteIndex++
82
+ }
83
+ }
84
+ return map
85
+ }
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ /** Parse a .env file into key-value pairs. Supports comments, quotes, and empty lines. */
5
+ export function parseEnvFile(content: string): Record<string, string> {
6
+ const vars: Record<string, string> = {}
7
+
8
+ for (const line of content.split(/\r?\n/)) {
9
+ const trimmed = line.trim()
10
+ if (!trimmed || trimmed.startsWith('#')) continue
11
+
12
+ const eqIndex = trimmed.indexOf('=')
13
+ if (eqIndex < 1) continue
14
+
15
+ const key = trimmed.slice(0, eqIndex).trim()
16
+ let value = trimmed.slice(eqIndex + 1).trim()
17
+
18
+ // Strip surrounding quotes (no inline comment stripping for quoted values)
19
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
20
+ value = value.slice(1, -1)
21
+ } else {
22
+ // Strip inline comments for unquoted values
23
+ const commentIndex = value.indexOf(' #')
24
+ if (commentIndex !== -1) {
25
+ value = value.slice(0, commentIndex).trimEnd()
26
+ }
27
+ }
28
+
29
+ vars[key] = value
30
+ }
31
+
32
+ return vars
33
+ }
34
+
35
+ /** Load one or more .env files and merge them into a single Record. Later files override earlier ones. */
36
+ export function loadEnvFiles(envFile: string | string[], cwd: string): Record<string, string> {
37
+ const files = Array.isArray(envFile) ? envFile : [envFile]
38
+ const merged: Record<string, string> = {}
39
+
40
+ for (const file of files) {
41
+ const path = resolve(cwd, file)
42
+ let content: string
43
+ try {
44
+ content = readFileSync(path, 'utf-8')
45
+ } catch (err) {
46
+ const code = (err as NodeJS.ErrnoException).code
47
+ if (code === 'ENOENT') {
48
+ throw new Error(`envFile not found: ${path}`, { cause: err })
49
+ }
50
+ throw new Error(`Failed to read envFile "${path}": ${err instanceof Error ? err.message : err}`, {
51
+ cause: err
52
+ })
53
+ }
54
+ Object.assign(merged, parseEnvFile(content))
55
+ }
56
+
57
+ return merged
58
+ }
@@ -0,0 +1,48 @@
1
+ import { closeSync, mkdirSync, openSync, writeSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { ProcessEvent } from '../types'
4
+ import { stripAnsi } from './color'
5
+
6
+ /** Writes process output to per-process log files in the given directory. */
7
+ export class LogWriter {
8
+ private dir: string
9
+ private files = new Map<string, number>()
10
+ private decoder = new TextDecoder()
11
+ private encoder = new TextEncoder()
12
+
13
+ constructor(dir: string) {
14
+ this.dir = dir
15
+ mkdirSync(dir, { recursive: true })
16
+ }
17
+
18
+ private errored = false
19
+
20
+ /** Event listener — pass to ProcessManager.on() */
21
+ handleEvent = (event: ProcessEvent): void => {
22
+ if (event.type !== 'output' || this.errored) return
23
+
24
+ try {
25
+ let fd = this.files.get(event.name)
26
+ if (fd === undefined) {
27
+ const path = join(this.dir, `${event.name}.log`)
28
+ fd = openSync(path, 'w')
29
+ this.files.set(event.name, fd)
30
+ }
31
+
32
+ const text = this.decoder.decode(event.data, { stream: true })
33
+ const clean = stripAnsi(text)
34
+ writeSync(fd, this.encoder.encode(clean))
35
+ } catch {
36
+ // Disk full, permissions, deleted dir — warn once and stop writing
37
+ this.errored = true
38
+ process.stderr.write(`numux: log writing failed for ${this.dir}, disabling log output\n`)
39
+ }
40
+ }
41
+
42
+ close(): void {
43
+ for (const fd of this.files.values()) {
44
+ closeSync(fd)
45
+ }
46
+ this.files.clear()
47
+ }
48
+ }