numux 1.5.0 → 1.5.2
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 +26 -16
- package/dist/bin.js +23 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +7 -0
- package/dist/numux.js +2610 -0
- package/dist/types.d.ts +53 -0
- package/package.json +10 -7
- 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 -442
- package/src/ui/pane.ts +0 -125
- package/src/ui/prefix.ts +0 -207
- package/src/ui/status-bar.ts +0 -60
- 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 -40
- package/src/utils/shutdown.ts +0 -39
- package/src/utils/watcher.ts +0 -53
package/src/ui/prefix.ts
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
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 (
|
|
106
|
-
status === 'ready' ||
|
|
107
|
-
status === 'failed' ||
|
|
108
|
-
status === 'finished' ||
|
|
109
|
-
status === 'stopped' ||
|
|
110
|
-
status === 'skipped'
|
|
111
|
-
) {
|
|
112
|
-
if (this.noColor) {
|
|
113
|
-
this.printLine(name, `→ ${status}`)
|
|
114
|
-
} else {
|
|
115
|
-
const ansi = STATUS_ANSI[status]
|
|
116
|
-
const statusText = ansi ? `${ansi}${status}${RESET}` : status
|
|
117
|
-
this.printLine(name, `${DIM}→ ${statusText}${DIM}${RESET}`)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private formatTimestamp(): string {
|
|
123
|
-
const now = new Date()
|
|
124
|
-
const h = String(now.getHours()).padStart(2, '0')
|
|
125
|
-
const m = String(now.getMinutes()).padStart(2, '0')
|
|
126
|
-
const s = String(now.getSeconds()).padStart(2, '0')
|
|
127
|
-
return `${h}:${m}:${s}`
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private printLine(name: string, line: string): void {
|
|
131
|
-
const padded = name.padEnd(this.maxNameLen)
|
|
132
|
-
const ts = this.timestamps ? `${DIM}[${this.formatTimestamp()}]${RESET} ` : ''
|
|
133
|
-
const tsPlain = this.timestamps ? `[${this.formatTimestamp()}] ` : ''
|
|
134
|
-
if (this.noColor) {
|
|
135
|
-
process.stdout.write(`${tsPlain}[${padded}] ${stripAnsi(line)}\n`)
|
|
136
|
-
} else {
|
|
137
|
-
const color = this.colors.get(name) ?? ''
|
|
138
|
-
process.stdout.write(`${ts}${color}[${padded}]${RESET} ${line}\n`)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
private flushBuffer(name: string): void {
|
|
143
|
-
const remaining = this.buffers.get(name) ?? ''
|
|
144
|
-
if (remaining.length > 0) {
|
|
145
|
-
this.printLine(name, remaining)
|
|
146
|
-
this.buffers.set(name, '')
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private checkAllDone(): void {
|
|
151
|
-
if (this.stopping) return
|
|
152
|
-
const states = this.manager.getAllStates()
|
|
153
|
-
const allDone = states.every(
|
|
154
|
-
s => s.status === 'stopped' || s.status === 'finished' || s.status === 'failed' || s.status === 'skipped'
|
|
155
|
-
)
|
|
156
|
-
if (allDone) {
|
|
157
|
-
this.printSummary()
|
|
158
|
-
this.logWriter?.close()
|
|
159
|
-
const anyFailed = states.some(s => s.status === 'failed')
|
|
160
|
-
process.exit(anyFailed ? 1 : 0)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private killAllAndExit(exitedName: string): void {
|
|
165
|
-
if (this.stopping) return
|
|
166
|
-
this.stopping = true
|
|
167
|
-
const state = this.manager.getState(exitedName)
|
|
168
|
-
const code = state?.exitCode ?? 1
|
|
169
|
-
this.manager.stopAll().then(() => {
|
|
170
|
-
for (const name of this.manager.getProcessNames()) {
|
|
171
|
-
this.flushBuffer(name)
|
|
172
|
-
}
|
|
173
|
-
this.printSummary()
|
|
174
|
-
this.logWriter?.close()
|
|
175
|
-
process.exit(code === 0 ? 0 : 1)
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
private printSummary(): void {
|
|
180
|
-
const states = this.manager.getAllStates()
|
|
181
|
-
const namePad = Math.max(...states.map(s => s.name.length))
|
|
182
|
-
process.stdout.write('\n')
|
|
183
|
-
for (const s of states) {
|
|
184
|
-
const name = s.name.padEnd(namePad)
|
|
185
|
-
const exitStr = s.exitCode !== null ? `exit ${s.exitCode}` : ''
|
|
186
|
-
if (this.noColor) {
|
|
187
|
-
process.stdout.write(` ${name} ${s.status}${exitStr ? ` (${exitStr})` : ''}\n`)
|
|
188
|
-
} else {
|
|
189
|
-
const ansi = STATUS_ANSI[s.status] ?? ''
|
|
190
|
-
const statusText = ansi ? `${ansi}${s.status}${RESET}` : s.status
|
|
191
|
-
process.stdout.write(` ${name} ${statusText}${exitStr ? ` ${DIM}(${exitStr})${RESET}` : ''}\n`)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async shutdown(): Promise<void> {
|
|
197
|
-
if (this.stopping) return
|
|
198
|
-
this.stopping = true
|
|
199
|
-
await this.manager.stopAll()
|
|
200
|
-
for (const name of this.manager.getProcessNames()) {
|
|
201
|
-
this.flushBuffer(name)
|
|
202
|
-
}
|
|
203
|
-
this.logWriter?.close()
|
|
204
|
-
const anyFailed = this.manager.getAllStates().some(s => s.status === 'failed')
|
|
205
|
-
process.exit(anyFailed ? 1 : 0)
|
|
206
|
-
}
|
|
207
|
-
}
|
package/src/ui/status-bar.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { type CliRenderer, cyan, red, reverse, StyledText, type TextChunk, TextRenderable, yellow } from '@opentui/core'
|
|
2
|
-
|
|
3
|
-
function plain(text: string): TextChunk {
|
|
4
|
-
return { __isChunk: true, text } as TextChunk
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export class StatusBar {
|
|
8
|
-
readonly renderable: TextRenderable
|
|
9
|
-
private _searchMode = false
|
|
10
|
-
private _searchQuery = ''
|
|
11
|
-
private _searchMatchCount = 0
|
|
12
|
-
private _searchCurrentIndex = -1
|
|
13
|
-
|
|
14
|
-
constructor(renderer: CliRenderer) {
|
|
15
|
-
this.renderable = new TextRenderable(renderer, {
|
|
16
|
-
id: 'status-bar',
|
|
17
|
-
width: '100%',
|
|
18
|
-
height: 1,
|
|
19
|
-
content: this.buildContent(),
|
|
20
|
-
bg: '#1a1a1a',
|
|
21
|
-
paddingX: 1
|
|
22
|
-
})
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
setSearchMode(active: boolean, query = '', matchCount = 0, currentIndex = -1): void {
|
|
26
|
-
this._searchMode = active
|
|
27
|
-
this._searchQuery = query
|
|
28
|
-
this._searchMatchCount = matchCount
|
|
29
|
-
this._searchCurrentIndex = currentIndex
|
|
30
|
-
this.renderable.content = this.buildContent()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
private buildContent(): StyledText {
|
|
34
|
-
if (this._searchMode) {
|
|
35
|
-
return this.buildSearchContent()
|
|
36
|
-
}
|
|
37
|
-
return new StyledText([
|
|
38
|
-
plain('\u2190\u2192/1-9: tabs R: restart S: stop/start F: search L: clear Ctrl+C: quit')
|
|
39
|
-
])
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
private buildSearchContent(): StyledText {
|
|
43
|
-
const chunks: TextChunk[] = []
|
|
44
|
-
chunks.push(yellow('/'))
|
|
45
|
-
if (this._searchQuery) chunks.push(plain(this._searchQuery))
|
|
46
|
-
chunks.push(reverse(' '))
|
|
47
|
-
if (this._searchMatchCount === 0 && this._searchQuery) {
|
|
48
|
-
chunks.push(plain(' '))
|
|
49
|
-
chunks.push(red('no matches'))
|
|
50
|
-
chunks.push(plain(' Esc: close'))
|
|
51
|
-
} else if (this._searchMatchCount > 0) {
|
|
52
|
-
chunks.push(plain(' '))
|
|
53
|
-
chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`))
|
|
54
|
-
chunks.push(plain(' Enter/Shift+Enter: next/prev Esc: close'))
|
|
55
|
-
} else {
|
|
56
|
-
chunks.push(plain(' Enter: next Esc: close'))
|
|
57
|
-
}
|
|
58
|
-
return new StyledText(chunks)
|
|
59
|
-
}
|
|
60
|
-
}
|
package/src/ui/tabs.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type CliRenderer,
|
|
3
|
-
type MouseEvent,
|
|
4
|
-
type OptimizedBuffer,
|
|
5
|
-
parseColor,
|
|
6
|
-
type RGBA,
|
|
7
|
-
SelectRenderable,
|
|
8
|
-
SelectRenderableEvents
|
|
9
|
-
} from '@opentui/core'
|
|
10
|
-
import type { ProcessStatus } from '../types'
|
|
11
|
-
|
|
12
|
-
const STATUS_ICONS: Record<ProcessStatus, string> = {
|
|
13
|
-
pending: '○',
|
|
14
|
-
starting: '◐',
|
|
15
|
-
running: '◉',
|
|
16
|
-
ready: '●',
|
|
17
|
-
stopping: '◑',
|
|
18
|
-
stopped: '■',
|
|
19
|
-
finished: '✓',
|
|
20
|
-
failed: '✖',
|
|
21
|
-
skipped: '⊘'
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Status-specific icon colors (override process colors) */
|
|
25
|
-
const STATUS_ICON_HEX: Partial<Record<ProcessStatus, string>> = {
|
|
26
|
-
ready: '#00cc00',
|
|
27
|
-
finished: '#66aa66',
|
|
28
|
-
failed: '#ff5555',
|
|
29
|
-
stopped: '#888888',
|
|
30
|
-
skipped: '#888888'
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Statuses that represent a terminal (done) state — tabs move to bottom */
|
|
34
|
-
const TERMINAL_STATUSES = new Set<ProcessStatus>(['finished', 'stopped', 'failed', 'skipped'])
|
|
35
|
-
|
|
36
|
-
interface OptionColors {
|
|
37
|
-
icon: RGBA | null
|
|
38
|
-
name: RGBA | null
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* SelectRenderable subclass that supports per-option coloring.
|
|
43
|
-
* The base SelectRenderable draws all option text with a single color.
|
|
44
|
-
* This overrides renderSelf to repaint the icon and name with individual
|
|
45
|
-
* RGBA colors after the base render.
|
|
46
|
-
*/
|
|
47
|
-
class ColoredSelectRenderable extends SelectRenderable {
|
|
48
|
-
private _optionColors: OptionColors[] = []
|
|
49
|
-
|
|
50
|
-
setOptionColors(colors: OptionColors[]): void {
|
|
51
|
-
this._optionColors = colors
|
|
52
|
-
this.requestRender()
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
protected renderSelf(buffer: OptimizedBuffer, deltaTime: number): void {
|
|
56
|
-
const wasDirty = this.isDirty
|
|
57
|
-
super.renderSelf(buffer, deltaTime)
|
|
58
|
-
if (wasDirty && this.frameBuffer && this._optionColors.length > 0) {
|
|
59
|
-
this.colorizeOptions()
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
protected onMouseEvent(event: MouseEvent): void {
|
|
64
|
-
if (event.type === 'down') {
|
|
65
|
-
const linesPerItem = (this as any).linesPerItem as number
|
|
66
|
-
const scrollOffset = (this as any).scrollOffset as number
|
|
67
|
-
const clickedIndex = scrollOffset + Math.floor(event.y / linesPerItem)
|
|
68
|
-
if (clickedIndex >= 0 && clickedIndex < this.options.length) {
|
|
69
|
-
this.setSelectedIndex(clickedIndex)
|
|
70
|
-
this.selectCurrent()
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private colorizeOptions(): void {
|
|
76
|
-
const fb = this.frameBuffer!
|
|
77
|
-
// Access internal layout state (private in TS, accessible at runtime)
|
|
78
|
-
const scrollOffset = (this as any).scrollOffset as number
|
|
79
|
-
const maxVisibleItems = (this as any).maxVisibleItems as number
|
|
80
|
-
const linesPerItem = (this as any).linesPerItem as number
|
|
81
|
-
const options = this.options
|
|
82
|
-
const visibleCount = Math.min(maxVisibleItems, options.length - scrollOffset)
|
|
83
|
-
|
|
84
|
-
for (let i = 0; i < visibleCount; i++) {
|
|
85
|
-
const actualIndex = scrollOffset + i
|
|
86
|
-
const colors = this._optionColors[actualIndex]
|
|
87
|
-
if (!colors) continue
|
|
88
|
-
const itemY = i * linesPerItem
|
|
89
|
-
// Layout: "▶ ○ name" or " ○ name" (drawText at x=1, prefix 2 chars)
|
|
90
|
-
// Icon at x=3, space at x=4, name starts at x=5
|
|
91
|
-
const optName = options[actualIndex].name
|
|
92
|
-
if (colors.icon) {
|
|
93
|
-
fb.drawText(optName.charAt(0), 3, itemY, colors.icon)
|
|
94
|
-
}
|
|
95
|
-
if (colors.name) {
|
|
96
|
-
fb.drawText(optName.slice(2), 5, itemY, colors.name)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export class TabBar {
|
|
103
|
-
readonly renderable: ColoredSelectRenderable
|
|
104
|
-
private originalNames: string[]
|
|
105
|
-
private names: string[]
|
|
106
|
-
private statuses: Map<string, ProcessStatus>
|
|
107
|
-
private baseDescriptions: Map<string, string>
|
|
108
|
-
private processColors: Map<string, string>
|
|
109
|
-
private inputWaiting = new Set<string>()
|
|
110
|
-
|
|
111
|
-
constructor(renderer: CliRenderer, names: string[], colors?: Map<string, string>) {
|
|
112
|
-
this.originalNames = names
|
|
113
|
-
this.names = [...names]
|
|
114
|
-
this.statuses = new Map(names.map(n => [n, 'pending' as ProcessStatus]))
|
|
115
|
-
this.baseDescriptions = new Map(names.map(n => [n, 'pending']))
|
|
116
|
-
this.processColors = colors ?? new Map()
|
|
117
|
-
|
|
118
|
-
this.renderable = new ColoredSelectRenderable(renderer, {
|
|
119
|
-
id: 'tab-bar',
|
|
120
|
-
width: '100%',
|
|
121
|
-
height: '100%',
|
|
122
|
-
options: names.map(n => ({
|
|
123
|
-
name: this.formatTab(n, 'pending'),
|
|
124
|
-
description: 'pending'
|
|
125
|
-
})),
|
|
126
|
-
selectedBackgroundColor: '#334455',
|
|
127
|
-
selectedTextColor: '#fff',
|
|
128
|
-
textColor: '#888',
|
|
129
|
-
showDescription: true,
|
|
130
|
-
wrapSelection: true
|
|
131
|
-
})
|
|
132
|
-
this.updateOptionColors()
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
onSelect(handler: (index: number, name: string) => void): void {
|
|
136
|
-
this.renderable.on(SelectRenderableEvents.ITEM_SELECTED, (index: number) => {
|
|
137
|
-
handler(index, this.names[index])
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
onSelectionChanged(handler: (index: number, name: string) => void): void {
|
|
142
|
-
this.renderable.on(SelectRenderableEvents.SELECTION_CHANGED, (index: number) => {
|
|
143
|
-
handler(index, this.names[index])
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
updateStatus(name: string, status: ProcessStatus, exitCode?: number | null, restartCount?: number): void {
|
|
148
|
-
this.statuses.set(name, status)
|
|
149
|
-
this.baseDescriptions.set(name, this.formatDescription(status, exitCode, restartCount))
|
|
150
|
-
// Clear input waiting on terminal status changes
|
|
151
|
-
if (TERMINAL_STATUSES.has(status) || status === 'stopping') {
|
|
152
|
-
this.inputWaiting.delete(name)
|
|
153
|
-
}
|
|
154
|
-
this.refreshOptions()
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
setInputWaiting(name: string, waiting: boolean): void {
|
|
158
|
-
if (waiting) this.inputWaiting.add(name)
|
|
159
|
-
else this.inputWaiting.delete(name)
|
|
160
|
-
this.refreshOptions()
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Get the process name at the given display index */
|
|
164
|
-
getNameAtIndex(index: number): string {
|
|
165
|
-
return this.names[index]
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
get count(): number {
|
|
169
|
-
return this.names.length
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private refreshOptions(): void {
|
|
173
|
-
// Preserve currently selected name
|
|
174
|
-
const currentIdx = this.renderable.getSelectedIndex()
|
|
175
|
-
const currentName = this.names[currentIdx]
|
|
176
|
-
|
|
177
|
-
// Reorder: active first, terminal states at bottom
|
|
178
|
-
this.names = this.getDisplayOrder()
|
|
179
|
-
|
|
180
|
-
this.renderable.options = this.names.map(n => ({
|
|
181
|
-
name: this.formatTab(n, this.statuses.get(n)!),
|
|
182
|
-
description: this.getDescription(n)
|
|
183
|
-
}))
|
|
184
|
-
|
|
185
|
-
// Restore selection by name
|
|
186
|
-
const newIdx = this.names.indexOf(currentName)
|
|
187
|
-
if (newIdx >= 0 && newIdx !== currentIdx) {
|
|
188
|
-
this.renderable.setSelectedIndex(newIdx)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
this.updateOptionColors()
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
private getDisplayOrder(): string[] {
|
|
195
|
-
const active = this.originalNames.filter(n => !TERMINAL_STATUSES.has(this.statuses.get(n)!))
|
|
196
|
-
const terminal = this.originalNames.filter(n => TERMINAL_STATUSES.has(this.statuses.get(n)!))
|
|
197
|
-
return [...active, ...terminal]
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
private getDescription(name: string): string {
|
|
201
|
-
if (this.inputWaiting.has(name)) return 'awaiting input'
|
|
202
|
-
return this.baseDescriptions.get(name) ?? 'pending'
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private updateOptionColors(): void {
|
|
206
|
-
const colors = this.names.map(name => {
|
|
207
|
-
const status = this.statuses.get(name)!
|
|
208
|
-
const waiting = this.inputWaiting.has(name)
|
|
209
|
-
const statusHex = waiting ? '#ffaa00' : STATUS_ICON_HEX[status]
|
|
210
|
-
const processHex = this.processColors.get(name)
|
|
211
|
-
return {
|
|
212
|
-
icon: parseColor(statusHex ?? processHex ?? '#888888'),
|
|
213
|
-
name: processHex ? parseColor(processHex) : null
|
|
214
|
-
}
|
|
215
|
-
})
|
|
216
|
-
this.renderable.setOptionColors(colors)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
private formatDescription(status: ProcessStatus, exitCode?: number | null, restartCount?: number): string {
|
|
220
|
-
let desc: string = status
|
|
221
|
-
if ((status === 'failed' || status === 'stopped') && exitCode != null && exitCode !== 0) {
|
|
222
|
-
desc = `exit ${exitCode}`
|
|
223
|
-
}
|
|
224
|
-
if (restartCount && restartCount > 0) {
|
|
225
|
-
desc += ` ×${restartCount}`
|
|
226
|
-
}
|
|
227
|
-
return desc
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
private formatTab(name: string, status: ProcessStatus): string {
|
|
231
|
-
const icon = STATUS_ICONS[status]
|
|
232
|
-
return `${icon} ${name}`
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
getSelectedIndex(): number {
|
|
236
|
-
return this.renderable.getSelectedIndex()
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
setSelectedIndex(index: number): void {
|
|
240
|
-
this.renderable.setSelectedIndex(index)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
focus(): void {
|
|
244
|
-
this.renderable.focus()
|
|
245
|
-
}
|
|
246
|
-
}
|
package/src/utils/color.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
finished: '\x1b[32m',
|
|
24
|
-
failed: '\x1b[31m',
|
|
25
|
-
stopped: '\x1b[90m',
|
|
26
|
-
skipped: '\x1b[90m'
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const ANSI_RESET = '\x1b[0m'
|
|
30
|
-
|
|
31
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping requires matching control chars
|
|
32
|
-
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()#][0-9A-Za-z]|\x1b[A-Za-z><=]/g
|
|
33
|
-
|
|
34
|
-
/** Strip ANSI escape sequences from text */
|
|
35
|
-
export function stripAnsi(str: string): string {
|
|
36
|
-
return str.replace(ANSI_RE, '')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Default palette as ANSI codes (for prefix mode stdout output) */
|
|
40
|
-
const DEFAULT_ANSI_COLORS = [
|
|
41
|
-
'\x1b[36m',
|
|
42
|
-
'\x1b[33m',
|
|
43
|
-
'\x1b[35m',
|
|
44
|
-
'\x1b[34m',
|
|
45
|
-
'\x1b[32m',
|
|
46
|
-
'\x1b[91m',
|
|
47
|
-
'\x1b[93m',
|
|
48
|
-
'\x1b[95m'
|
|
49
|
-
]
|
|
50
|
-
|
|
51
|
-
/** Default palette as hex colors (for styled text rendering) */
|
|
52
|
-
const DEFAULT_HEX_COLORS = ['#00cccc', '#cccc00', '#cc00cc', '#0000cc', '#00cc00', '#ff5555', '#ffff55', '#ff55ff']
|
|
53
|
-
|
|
54
|
-
/** Resolve a color value (string or array) to a single hex string, or undefined. */
|
|
55
|
-
function resolveColor(color: string | string[] | undefined): string | undefined {
|
|
56
|
-
if (typeof color === 'string') return color
|
|
57
|
-
if (Array.isArray(color) && color.length > 0) return color[0]
|
|
58
|
-
return undefined
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Build a map of process names to ANSI color codes, using explicit config colors or a default palette. */
|
|
62
|
-
export function buildProcessColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string> {
|
|
63
|
-
const map = new Map<string, string>()
|
|
64
|
-
if ('NO_COLOR' in process.env) return map
|
|
65
|
-
let paletteIndex = 0
|
|
66
|
-
for (const name of names) {
|
|
67
|
-
const explicit = resolveColor(config.processes[name]?.color)
|
|
68
|
-
if (explicit) {
|
|
69
|
-
map.set(name, hexToAnsi(explicit))
|
|
70
|
-
} else {
|
|
71
|
-
map.set(name, DEFAULT_ANSI_COLORS[paletteIndex % DEFAULT_ANSI_COLORS.length])
|
|
72
|
-
paletteIndex++
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return map
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Build a map of process names to hex color strings (for StyledText rendering). */
|
|
79
|
-
export function buildProcessHexColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string> {
|
|
80
|
-
const map = new Map<string, string>()
|
|
81
|
-
if ('NO_COLOR' in process.env) return map
|
|
82
|
-
let paletteIndex = 0
|
|
83
|
-
for (const name of names) {
|
|
84
|
-
const explicit = resolveColor(config.processes[name]?.color)
|
|
85
|
-
if (explicit) {
|
|
86
|
-
map.set(name, explicit.startsWith('#') ? explicit : `#${explicit}`)
|
|
87
|
-
} else {
|
|
88
|
-
map.set(name, DEFAULT_HEX_COLORS[paletteIndex % DEFAULT_HEX_COLORS.length])
|
|
89
|
-
paletteIndex++
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return map
|
|
93
|
-
}
|
package/src/utils/env-file.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
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
|
-
}
|