ex-brain 0.1.0 → 0.2.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.
- package/README.md +87 -37
- package/package.json +6 -5
- package/src/ai/compiler.ts +494 -0
- package/src/ai/embed-factory.ts +116 -0
- package/src/ai/entity-link.ts +195 -0
- package/src/ai/hash-embed.ts +30 -0
- package/src/ai/llm-client.ts +291 -0
- package/src/ai/timeline-extractor.ts +403 -0
- package/src/cli.ts +16 -0
- package/src/commands/compile-cmd.ts +208 -0
- package/src/commands/graph-cmd.ts +1070 -0
- package/src/commands/index.ts +1973 -0
- package/src/config.ts +80 -0
- package/src/db/client.ts +207 -0
- package/src/db/errors.ts +178 -0
- package/src/db/schema.ts +50 -0
- package/src/markdown/io.ts +61 -0
- package/src/markdown/parser.ts +72 -0
- package/src/mcp/server.ts +703 -0
- package/src/repositories/brain-repo.ts +990 -0
- package/src/settings.ts +235 -0
- package/src/types/index.ts +56 -0
- package/src/utils/cli-output.ts +569 -0
- package/src/utils/progress.ts +171 -0
- package/src/utils/query-sanitizer.ts +63 -0
- package/dist/cli.js +0 -93543
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Output Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides colorful, structured output for CLI interactions:
|
|
5
|
+
* - Green for success messages
|
|
6
|
+
* - Red for errors
|
|
7
|
+
* - Yellow for warnings
|
|
8
|
+
* - Task status tracking (working -> [done])
|
|
9
|
+
* - Detailed step-by-step feedback
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ANSI color codes
|
|
13
|
+
export const COLORS = {
|
|
14
|
+
// Text colors
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
bold: '\x1b[1m',
|
|
17
|
+
dim: '\x1b[2m',
|
|
18
|
+
|
|
19
|
+
// Foreground colors
|
|
20
|
+
black: '\x1b[30m',
|
|
21
|
+
red: '\x1b[31m',
|
|
22
|
+
green: '\x1b[32m',
|
|
23
|
+
yellow: '\x1b[33m',
|
|
24
|
+
blue: '\x1b[34m',
|
|
25
|
+
magenta: '\x1b[35m',
|
|
26
|
+
cyan: '\x1b[36m',
|
|
27
|
+
white: '\x1b[37m',
|
|
28
|
+
|
|
29
|
+
// Background colors
|
|
30
|
+
bgRed: '\x1b[41m',
|
|
31
|
+
bgGreen: '\x1b[42m',
|
|
32
|
+
bgYellow: '\x1b[43m',
|
|
33
|
+
bgBlue: '\x1b[44m',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
// Icons
|
|
37
|
+
export const ICONS = {
|
|
38
|
+
success: '✓',
|
|
39
|
+
error: '✗',
|
|
40
|
+
warning: '⚠',
|
|
41
|
+
info: 'ℹ',
|
|
42
|
+
working: '◌',
|
|
43
|
+
done: '●',
|
|
44
|
+
arrow: '→',
|
|
45
|
+
bullet: '•',
|
|
46
|
+
chevron: '›',
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
// Spinner frames
|
|
50
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
51
|
+
const SPINNER_INTERVAL = 80;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Strip ANSI color codes from string (for length calculation)
|
|
55
|
+
*/
|
|
56
|
+
function stripAnsi(str: string): string {
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pad string to width (considering ANSI codes)
|
|
63
|
+
*/
|
|
64
|
+
function padRight(str: string, width: number): string {
|
|
65
|
+
const visibleLen = stripAnsi(str).length;
|
|
66
|
+
return str + ' '.repeat(Math.max(0, width - visibleLen));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Output Stream
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
export type OutputStream = NodeJS.WritableStream;
|
|
74
|
+
|
|
75
|
+
const defaultStderr: OutputStream = process.stderr;
|
|
76
|
+
const defaultStdout: OutputStream = process.stdout;
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// Basic Output Functions
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Print success message (green)
|
|
84
|
+
*/
|
|
85
|
+
export function success(message: string, stream: OutputStream = defaultStderr): void {
|
|
86
|
+
stream.write(`${COLORS.green}${ICONS.success}${COLORS.reset} ${message}\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Print error message (red)
|
|
91
|
+
*/
|
|
92
|
+
export function error(message: string, stream: OutputStream = defaultStderr): void {
|
|
93
|
+
stream.write(`${COLORS.red}${ICONS.error}${COLORS.reset} ${message}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Print warning message (yellow)
|
|
98
|
+
*/
|
|
99
|
+
export function warning(message: string, stream: OutputStream = defaultStderr): void {
|
|
100
|
+
stream.write(`${COLORS.yellow}${ICONS.warning}${COLORS.reset} ${message}\n`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Print info message (cyan)
|
|
105
|
+
*/
|
|
106
|
+
export function info(message: string, stream: OutputStream = defaultStderr): void {
|
|
107
|
+
stream.write(`${COLORS.cyan}${ICONS.info}${COLORS.reset} ${message}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Print a step/progress message
|
|
112
|
+
*/
|
|
113
|
+
export function step(stepNum: number, total: number, message: string, stream: OutputStream = defaultStderr): void {
|
|
114
|
+
const counter = `${COLORS.dim}[${stepNum}/${total}]${COLORS.reset}`;
|
|
115
|
+
stream.write(`${counter} ${message}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Print a sub-item (indented)
|
|
120
|
+
*/
|
|
121
|
+
export function subItem(message: string, indent: number = 2, stream: OutputStream = defaultStderr): void {
|
|
122
|
+
const spaces = ' '.repeat(indent);
|
|
123
|
+
stream.write(`${spaces}${COLORS.dim}${ICONS.bullet}${COLORS.reset} ${message}\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Print a key-value pair
|
|
128
|
+
*/
|
|
129
|
+
export function keyValue(key: string, value: string, stream: OutputStream = defaultStderr): void {
|
|
130
|
+
stream.write(` ${COLORS.dim}${key}:${COLORS.reset} ${value}\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Print a header/section title
|
|
135
|
+
*/
|
|
136
|
+
export function header(title: string, stream: OutputStream = defaultStderr): void {
|
|
137
|
+
stream.write(`\n${COLORS.bold}${COLORS.blue}■ ${title}${COLORS.reset}\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Print a separator line
|
|
142
|
+
*/
|
|
143
|
+
export function separator(stream: OutputStream = defaultStderr): void {
|
|
144
|
+
stream.write(`${COLORS.dim}${'─'.repeat(50)}${COLORS.reset}\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// Task Status Tracker
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
export interface TaskStatus {
|
|
152
|
+
name: string;
|
|
153
|
+
status: 'pending' | 'working' | 'done' | 'failed' | 'skipped';
|
|
154
|
+
message?: string;
|
|
155
|
+
details?: string[];
|
|
156
|
+
duration?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export class TaskRunner {
|
|
160
|
+
private tasks: TaskStatus[] = [];
|
|
161
|
+
private currentTaskIndex: number = -1;
|
|
162
|
+
private startTime: number = 0;
|
|
163
|
+
private spinnerInterval: Timer | null = null;
|
|
164
|
+
private frameIndex = 0;
|
|
165
|
+
private stream: OutputStream;
|
|
166
|
+
private json: boolean;
|
|
167
|
+
|
|
168
|
+
constructor(stream: OutputStream = defaultStderr, json: boolean = false) {
|
|
169
|
+
this.stream = stream;
|
|
170
|
+
this.json = json;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Register a task
|
|
175
|
+
*/
|
|
176
|
+
addTask(name: string): this {
|
|
177
|
+
this.tasks.push({ name, status: 'pending' });
|
|
178
|
+
return this;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Start a task by name or index
|
|
183
|
+
*/
|
|
184
|
+
start(taskIdentifier: string | number, message?: string): void {
|
|
185
|
+
if (this.json) return;
|
|
186
|
+
|
|
187
|
+
const index = typeof taskIdentifier === 'number'
|
|
188
|
+
? taskIdentifier
|
|
189
|
+
: this.tasks.findIndex(t => t.name === taskIdentifier);
|
|
190
|
+
|
|
191
|
+
if (index === -1) return;
|
|
192
|
+
|
|
193
|
+
this.currentTaskIndex = index;
|
|
194
|
+
this.tasks[index].status = 'working';
|
|
195
|
+
this.tasks[index].message = message;
|
|
196
|
+
this.startTime = Date.now();
|
|
197
|
+
|
|
198
|
+
this.render();
|
|
199
|
+
this.startSpinner();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Complete current task successfully
|
|
204
|
+
*/
|
|
205
|
+
succeed(message?: string, details?: string[]): void {
|
|
206
|
+
if (this.json) return;
|
|
207
|
+
|
|
208
|
+
this.stopSpinner();
|
|
209
|
+
|
|
210
|
+
if (this.currentTaskIndex >= 0) {
|
|
211
|
+
const task = this.tasks[this.currentTaskIndex];
|
|
212
|
+
task.status = 'done';
|
|
213
|
+
task.message = message;
|
|
214
|
+
task.details = details;
|
|
215
|
+
task.duration = Date.now() - this.startTime;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.render();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fail current task
|
|
223
|
+
*/
|
|
224
|
+
fail(message?: string, details?: string[]): void {
|
|
225
|
+
if (this.json) return;
|
|
226
|
+
|
|
227
|
+
this.stopSpinner();
|
|
228
|
+
|
|
229
|
+
if (this.currentTaskIndex >= 0) {
|
|
230
|
+
const task = this.tasks[this.currentTaskIndex];
|
|
231
|
+
task.status = 'failed';
|
|
232
|
+
task.message = message;
|
|
233
|
+
task.details = details;
|
|
234
|
+
task.duration = Date.now() - this.startTime;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.render();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Skip current task
|
|
242
|
+
*/
|
|
243
|
+
skip(reason?: string): void {
|
|
244
|
+
if (this.json) return;
|
|
245
|
+
|
|
246
|
+
this.stopSpinner();
|
|
247
|
+
|
|
248
|
+
if (this.currentTaskIndex >= 0) {
|
|
249
|
+
const task = this.tasks[this.currentTaskIndex];
|
|
250
|
+
task.status = 'skipped';
|
|
251
|
+
task.message = reason;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.render();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Update current task message (while working)
|
|
259
|
+
*/
|
|
260
|
+
update(message: string): void {
|
|
261
|
+
if (this.json) return;
|
|
262
|
+
|
|
263
|
+
if (this.currentTaskIndex >= 0) {
|
|
264
|
+
this.tasks[this.currentTaskIndex].message = message;
|
|
265
|
+
this.render();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Add detail to current task
|
|
271
|
+
*/
|
|
272
|
+
addDetail(detail: string): void {
|
|
273
|
+
if (this.json) return;
|
|
274
|
+
|
|
275
|
+
if (this.currentTaskIndex >= 0) {
|
|
276
|
+
const task = this.tasks[this.currentTaskIndex];
|
|
277
|
+
if (!task.details) task.details = [];
|
|
278
|
+
task.details.push(detail);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Print summary of all tasks
|
|
284
|
+
*/
|
|
285
|
+
summary(): void {
|
|
286
|
+
if (this.json) return;
|
|
287
|
+
|
|
288
|
+
this.stopSpinner();
|
|
289
|
+
this.stream.write('\n');
|
|
290
|
+
|
|
291
|
+
const done = this.tasks.filter(t => t.status === 'done').length;
|
|
292
|
+
const failed = this.tasks.filter(t => t.status === 'failed').length;
|
|
293
|
+
const skipped = this.tasks.filter(t => t.status === 'skipped').length;
|
|
294
|
+
const total = this.tasks.length;
|
|
295
|
+
|
|
296
|
+
// Summary line
|
|
297
|
+
const parts: string[] = [];
|
|
298
|
+
if (done > 0) parts.push(`${COLORS.green}${done} passed${COLORS.reset}`);
|
|
299
|
+
if (failed > 0) parts.push(`${COLORS.red}${failed} failed${COLORS.reset}`);
|
|
300
|
+
if (skipped > 0) parts.push(`${COLORS.yellow}${skipped} skipped${COLORS.reset}`);
|
|
301
|
+
|
|
302
|
+
const summaryText = parts.join(', ') || 'No tasks';
|
|
303
|
+
this.stream.write(`${COLORS.bold}Summary:${COLORS.reset} ${summaryText} (${total} total)\n`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -----------------------------------------------------------------------
|
|
307
|
+
// Private methods
|
|
308
|
+
// -----------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
private startSpinner(): void {
|
|
311
|
+
this.stopSpinner();
|
|
312
|
+
this.spinnerInterval = setInterval(() => {
|
|
313
|
+
this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
|
|
314
|
+
this.render();
|
|
315
|
+
}, SPINNER_INTERVAL);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private stopSpinner(): void {
|
|
319
|
+
if (this.spinnerInterval) {
|
|
320
|
+
clearInterval(this.spinnerInterval);
|
|
321
|
+
this.spinnerInterval = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private render(): void {
|
|
326
|
+
// Clear previous output
|
|
327
|
+
const linesToClear = this.tasks.length +
|
|
328
|
+
this.tasks.reduce((sum, t) => sum + (t.details?.length ?? 0), 0);
|
|
329
|
+
|
|
330
|
+
for (let i = 0; i < linesToClear + 2; i++) {
|
|
331
|
+
this.stream.write('\x1b[F\x1b[K'); // Move up and clear line
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Render each task
|
|
335
|
+
for (let i = 0; i < this.tasks.length; i++) {
|
|
336
|
+
const task = this.tasks[i];
|
|
337
|
+
this.renderTask(task, i === this.currentTaskIndex);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
this.stream.write('\n');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private renderTask(task: TaskStatus, isCurrent: boolean): void {
|
|
344
|
+
const icon = this.getStatusIcon(task.status, isCurrent);
|
|
345
|
+
const color = this.getStatusColor(task.status);
|
|
346
|
+
const name = task.name;
|
|
347
|
+
const message = task.message ? ` ${COLORS.dim}${task.message}${COLORS.reset}` : '';
|
|
348
|
+
const duration = task.duration ? ` ${COLORS.dim}(${formatDuration(task.duration)})${COLORS.reset}` : '';
|
|
349
|
+
|
|
350
|
+
this.stream.write(`${color}${icon}${COLORS.reset} ${name}${message}${duration}\n`);
|
|
351
|
+
|
|
352
|
+
// Render details
|
|
353
|
+
if (task.details && task.details.length > 0) {
|
|
354
|
+
for (const detail of task.details) {
|
|
355
|
+
this.stream.write(` ${COLORS.dim}${ICONS.chevron} ${detail}${COLORS.reset}\n`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private getStatusIcon(status: TaskStatus['status'], isCurrent: boolean): string {
|
|
361
|
+
if (status === 'working' && isCurrent) {
|
|
362
|
+
return SPINNER_FRAMES[this.frameIndex];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
switch (status) {
|
|
366
|
+
case 'pending': return '○';
|
|
367
|
+
case 'working': return SPINNER_FRAMES[this.frameIndex];
|
|
368
|
+
case 'done': return ICONS.done;
|
|
369
|
+
case 'failed': return ICONS.error;
|
|
370
|
+
case 'skipped': return '○';
|
|
371
|
+
default: return '○';
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private getStatusColor(status: TaskStatus['status']): string {
|
|
376
|
+
switch (status) {
|
|
377
|
+
case 'done': return COLORS.green;
|
|
378
|
+
case 'failed': return COLORS.red;
|
|
379
|
+
case 'skipped': return COLORS.yellow;
|
|
380
|
+
case 'working': return COLORS.cyan;
|
|
381
|
+
default: return COLORS.dim;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ============================================================================
|
|
387
|
+
// Progress Spinner (Simple)
|
|
388
|
+
// ============================================================================
|
|
389
|
+
|
|
390
|
+
export interface ProgressSpinner {
|
|
391
|
+
start(message: string): void;
|
|
392
|
+
update(message: string): void;
|
|
393
|
+
succeed(message?: string): void;
|
|
394
|
+
fail(message?: string): void;
|
|
395
|
+
warn(message?: string): void;
|
|
396
|
+
stop(): void;
|
|
397
|
+
nativeError(message?: string): void;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function createSpinner(stream: OutputStream = defaultStderr): ProgressSpinner {
|
|
401
|
+
// Allow disabling spinner via env var for debugging native errors
|
|
402
|
+
const noSpinner = process.env.EBRAIN_NO_SPINNER === '1' || process.env.EBRAIN_NO_SPINNER === 'true';
|
|
403
|
+
|
|
404
|
+
let frameIndex = 0;
|
|
405
|
+
let interval: Timer | null = null;
|
|
406
|
+
let currentMessage = '';
|
|
407
|
+
let isRunning = false;
|
|
408
|
+
|
|
409
|
+
function clearLine(): void {
|
|
410
|
+
stream.write('\r\x1b[K');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function render(): void {
|
|
414
|
+
if (!isRunning) return;
|
|
415
|
+
const frame = SPINNER_FRAMES[frameIndex];
|
|
416
|
+
clearLine();
|
|
417
|
+
stream.write(`\x1b[36m${frame}\x1b[0m ${currentMessage}`);
|
|
418
|
+
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function start(message: string): void {
|
|
422
|
+
if (isRunning) stop();
|
|
423
|
+
currentMessage = message;
|
|
424
|
+
isRunning = true;
|
|
425
|
+
frameIndex = 0;
|
|
426
|
+
|
|
427
|
+
if (noSpinner) {
|
|
428
|
+
// No spinner mode: just print the message
|
|
429
|
+
stream.write(`${COLORS.cyan}${ICONS.working}${COLORS.reset} ${message}\n`);
|
|
430
|
+
} else {
|
|
431
|
+
render();
|
|
432
|
+
interval = setInterval(render, SPINNER_INTERVAL);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function update(message: string): void {
|
|
437
|
+
currentMessage = message;
|
|
438
|
+
if (!isRunning) {
|
|
439
|
+
start(message);
|
|
440
|
+
} else if (noSpinner) {
|
|
441
|
+
stream.write(`${COLORS.cyan}${ICONS.working}${COLORS.reset} ${message}\n`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function stop(): void {
|
|
446
|
+
if (interval) {
|
|
447
|
+
clearInterval(interval);
|
|
448
|
+
interval = null;
|
|
449
|
+
}
|
|
450
|
+
if (isRunning) {
|
|
451
|
+
// Clear the spinner line completely so native errors show on new line
|
|
452
|
+
clearLine();
|
|
453
|
+
isRunning = false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function succeed(message?: string): void {
|
|
458
|
+
stop();
|
|
459
|
+
const text = message || currentMessage;
|
|
460
|
+
stream.write(`\x1b[32m${ICONS.success}\x1b[0m ${text}\n`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function fail(message?: string): void {
|
|
464
|
+
stop();
|
|
465
|
+
const text = message || currentMessage;
|
|
466
|
+
stream.write(`\x1b[31m${ICONS.error}\x1b[0m ${text}\n`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function warn(message?: string): void {
|
|
470
|
+
stop();
|
|
471
|
+
const text = message || currentMessage;
|
|
472
|
+
stream.write(`\x1b[33m${ICONS.warning}\x1b[0m ${text}\n`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Stop spinner and show native error output
|
|
477
|
+
*/
|
|
478
|
+
function nativeError(message?: string): void {
|
|
479
|
+
stop();
|
|
480
|
+
// Print a blank line first so native stderr output is visible
|
|
481
|
+
stream.write('\n');
|
|
482
|
+
if (message) {
|
|
483
|
+
stream.write(`\x1b[31m${ICONS.error}\x1b[0m ${message}\n`);
|
|
484
|
+
}
|
|
485
|
+
stream.write(`${COLORS.dim}(Native library error output above)${COLORS.reset}\n`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { start, update, succeed, fail, warn, stop, nativeError };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ============================================================================
|
|
492
|
+
// Result Reporter
|
|
493
|
+
// ============================================================================
|
|
494
|
+
|
|
495
|
+
export interface OperationResult {
|
|
496
|
+
ok: boolean;
|
|
497
|
+
message?: string;
|
|
498
|
+
details?: Record<string, unknown>;
|
|
499
|
+
warnings?: string[];
|
|
500
|
+
errors?: string[];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Print detailed operation result
|
|
505
|
+
*/
|
|
506
|
+
export function reportResult(
|
|
507
|
+
result: OperationResult,
|
|
508
|
+
stream: OutputStream = defaultStderr
|
|
509
|
+
): void {
|
|
510
|
+
if (result.ok) {
|
|
511
|
+
success(result.message || 'Operation completed successfully', stream);
|
|
512
|
+
} else {
|
|
513
|
+
error(result.message || 'Operation failed', stream);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Print details
|
|
517
|
+
if (result.details) {
|
|
518
|
+
for (const [key, value] of Object.entries(result.details)) {
|
|
519
|
+
keyValue(key, String(value), stream);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Print warnings
|
|
524
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
525
|
+
for (const w of result.warnings) {
|
|
526
|
+
warning(w, stream);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Print errors
|
|
531
|
+
if (result.errors && result.errors.length > 0) {
|
|
532
|
+
for (const e of result.errors) {
|
|
533
|
+
subItem(`${COLORS.red}${e}${COLORS.reset}`, 4, stream);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ============================================================================
|
|
539
|
+
// Helpers
|
|
540
|
+
// ============================================================================
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Format duration in human-readable form
|
|
544
|
+
*/
|
|
545
|
+
export function formatDuration(ms: number): string {
|
|
546
|
+
if (ms < 1000) return `${ms}ms`;
|
|
547
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
548
|
+
const minutes = Math.floor(ms / 60000);
|
|
549
|
+
const seconds = Math.floor((ms % 60000) / 1000);
|
|
550
|
+
return `${minutes}m ${seconds}s`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Format bytes
|
|
555
|
+
*/
|
|
556
|
+
export function formatBytes(bytes: number): string {
|
|
557
|
+
if (bytes === 0) return '0 B';
|
|
558
|
+
const k = 1024;
|
|
559
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
560
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
561
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Format count with singular/plural
|
|
566
|
+
*/
|
|
567
|
+
export function formatCount(count: number, singular: string, plural?: string): string {
|
|
568
|
+
return `${count} ${count === 1 ? singular : (plural || singular + 's')}`;
|
|
569
|
+
}
|