@vibedx/vibekit 0.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +368 -0
  3. package/assets/config.yml +35 -0
  4. package/assets/default.md +47 -0
  5. package/assets/instructions/README.md +46 -0
  6. package/assets/instructions/claude.md +83 -0
  7. package/assets/instructions/codex.md +19 -0
  8. package/index.js +106 -0
  9. package/package.json +90 -0
  10. package/src/commands/close/index.js +66 -0
  11. package/src/commands/close/index.test.js +235 -0
  12. package/src/commands/get-started/index.js +138 -0
  13. package/src/commands/get-started/index.test.js +246 -0
  14. package/src/commands/init/index.js +51 -0
  15. package/src/commands/init/index.test.js +159 -0
  16. package/src/commands/link/index.js +395 -0
  17. package/src/commands/link/index.test.js +28 -0
  18. package/src/commands/lint/index.js +657 -0
  19. package/src/commands/lint/index.test.js +569 -0
  20. package/src/commands/list/index.js +131 -0
  21. package/src/commands/list/index.test.js +153 -0
  22. package/src/commands/new/index.js +305 -0
  23. package/src/commands/new/index.test.js +256 -0
  24. package/src/commands/refine/index.js +741 -0
  25. package/src/commands/refine/index.test.js +28 -0
  26. package/src/commands/review/index.js +957 -0
  27. package/src/commands/review/index.test.js +193 -0
  28. package/src/commands/start/index.js +180 -0
  29. package/src/commands/start/index.test.js +88 -0
  30. package/src/commands/unlink/index.js +123 -0
  31. package/src/commands/unlink/index.test.js +22 -0
  32. package/src/utils/arrow-select.js +233 -0
  33. package/src/utils/cli.js +489 -0
  34. package/src/utils/cli.test.js +9 -0
  35. package/src/utils/git.js +146 -0
  36. package/src/utils/git.test.js +330 -0
  37. package/src/utils/index.js +193 -0
  38. package/src/utils/index.test.js +375 -0
  39. package/src/utils/prompts.js +47 -0
  40. package/src/utils/prompts.test.js +165 -0
  41. package/src/utils/test-helpers.js +492 -0
  42. package/src/utils/ticket.js +423 -0
  43. package/src/utils/ticket.test.js +190 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Terminal color constants
3
+ */
4
+ const colors = {
5
+ reset: '\x1b[0m',
6
+ bright: '\x1b[1m',
7
+ green: '\x1b[32m',
8
+ cyan: '\x1b[36m',
9
+ gray: '\x1b[90m',
10
+ red: '\x1b[31m'
11
+ };
12
+
13
+ // Terminal control sequences
14
+ const CURSOR_UP = '\x1b[1A';
15
+ const CLEAR_LINE = '\x1b[2K';
16
+ const SHOW_CURSOR = '\x1b[?25h';
17
+ const HIDE_CURSOR = '\x1b[?25l';
18
+
19
+ /**
20
+ * Arrow key navigation selector with improved error handling and cleanup
21
+ * @param {string} message - The selection prompt message
22
+ * @param {Array} choices - Array of choice objects with name and value properties
23
+ * @param {string|null} defaultValue - Default selected value
24
+ * @returns {Promise<string>} Selected choice value
25
+ * @throws {Error} If setup fails or user cancels
26
+ */
27
+ export async function arrowSelect(message, choices, defaultValue = null) {
28
+ // Validate inputs
29
+ if (!Array.isArray(choices) || choices.length === 0) {
30
+ throw new Error('Choices must be a non-empty array');
31
+ }
32
+
33
+ return new Promise((resolve, reject) => {
34
+ let selectedIndex = 0;
35
+ let isFirstRender = true;
36
+ let isActive = true;
37
+
38
+ // Find default selection index
39
+ if (defaultValue) {
40
+ const defaultIndex = choices.findIndex(choice => choice.value === defaultValue);
41
+ if (defaultIndex >= 0) {
42
+ selectedIndex = defaultIndex;
43
+ }
44
+ }
45
+
46
+ const stdin = process.stdin;
47
+ const stdout = process.stdout;
48
+ const menuHeight = choices.length + 4;
49
+
50
+ // Store original terminal state
51
+ const originalRawMode = stdin.isRaw;
52
+ const originalFlowing = stdin.readableFlowing !== null;
53
+
54
+ /**
55
+ * Render the selection menu
56
+ */
57
+ function render() {
58
+ if (!isActive) return;
59
+
60
+ try {
61
+ // Clear previous render (except first time)
62
+ if (!isFirstRender) {
63
+ for (let i = 0; i < menuHeight; i++) {
64
+ stdout.write(CURSOR_UP + CLEAR_LINE);
65
+ }
66
+ }
67
+ isFirstRender = false;
68
+
69
+ // Hide cursor during rendering
70
+ stdout.write(HIDE_CURSOR);
71
+
72
+ // Render prompt message
73
+ stdout.write(`${colors.bright}${message}${colors.reset}\n\n`);
74
+
75
+ // Render choices
76
+ choices.forEach((choice, index) => {
77
+ const isSelected = index === selectedIndex;
78
+ const marker = isSelected ? `${colors.green}▶${colors.reset}` : ' ';
79
+ const color = isSelected ? colors.cyan : colors.gray;
80
+ const name = choice.name || choice.value || 'Unknown';
81
+ stdout.write(`${marker} ${color}${name}${colors.reset}\n`);
82
+ });
83
+
84
+ // Show instructions
85
+ stdout.write(`\n${colors.gray}↑↓ Navigate • Enter Select • 1-${choices.length} Quick select • Ctrl+C Cancel${colors.reset}\n`);
86
+
87
+ // Show cursor
88
+ stdout.write(SHOW_CURSOR);
89
+ } catch (error) {
90
+ cleanup();
91
+ reject(new Error(`Render failed: ${error.message}`));
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Clean up terminal state and event listeners
97
+ */
98
+ function cleanup() {
99
+ if (!isActive) return;
100
+ isActive = false;
101
+
102
+ try {
103
+ // Show cursor
104
+ stdout.write(SHOW_CURSOR);
105
+
106
+ // Restore terminal state
107
+ if (stdin.isTTY) {
108
+ stdin.setRawMode(originalRawMode);
109
+ }
110
+
111
+ if (!originalFlowing) {
112
+ stdin.pause();
113
+ }
114
+
115
+ // Remove event listeners
116
+ stdin.removeAllListeners('data');
117
+ stdin.removeAllListeners('error');
118
+
119
+ } catch (cleanupError) {
120
+ // Ignore cleanup errors to prevent double-throw
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Handle user selection and resolve promise
126
+ */
127
+ function handleSelection() {
128
+ if (!isActive) return;
129
+
130
+ const selectedChoice = choices[selectedIndex];
131
+ cleanup();
132
+
133
+ try {
134
+ // Clear menu
135
+ for (let i = 0; i < menuHeight; i++) {
136
+ stdout.write(CURSOR_UP + CLEAR_LINE);
137
+ }
138
+
139
+ // Show selection confirmation
140
+ const choiceName = selectedChoice.name || selectedChoice.value;
141
+ stdout.write(`${colors.green}✓${colors.reset} Selected: ${colors.cyan}${choiceName}${colors.reset}\n\n`);
142
+
143
+ resolve(selectedChoice.value);
144
+ } catch (error) {
145
+ reject(new Error(`Selection failed: ${error.message}`));
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Process different key inputs and update selection
151
+ * @param {string} key - The key that was pressed
152
+ */
153
+ function processKeyInput(key) {
154
+ switch (key) {
155
+ case '\u001b[A': // Up arrow
156
+ selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : choices.length - 1;
157
+ render();
158
+ break;
159
+
160
+ case '\u001b[B': // Down arrow
161
+ selectedIndex = selectedIndex < choices.length - 1 ? selectedIndex + 1 : 0;
162
+ render();
163
+ break;
164
+
165
+ case '\r':
166
+ case '\n': // Enter
167
+ handleSelection();
168
+ break;
169
+
170
+ case '\u0003': // Ctrl+C
171
+ cleanup();
172
+ stdout.write(`\n${colors.red}✗${colors.reset} Cancelled\n`);
173
+ reject(new Error('User cancelled selection'));
174
+ break;
175
+
176
+ default:
177
+ // Handle numeric selection (1-9)
178
+ const num = parseInt(key, 10);
179
+ if (num >= 1 && num <= choices.length) {
180
+ selectedIndex = num - 1;
181
+ handleSelection();
182
+ }
183
+ // Ignore other keys
184
+ break;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Handle user input
190
+ */
191
+ function handleInput(chunk) {
192
+ if (!isActive) return;
193
+
194
+ const key = chunk.toString();
195
+
196
+ try {
197
+ processKeyInput(key);
198
+ } catch (error) {
199
+ cleanup();
200
+ reject(new Error(`Input handling failed: ${error.message}`));
201
+ }
202
+ }
203
+
204
+ // Set up terminal and start interaction
205
+ try {
206
+ // Ensure clean state
207
+ stdin.removeAllListeners('data');
208
+ stdin.removeAllListeners('error');
209
+
210
+ // Configure terminal
211
+ if (stdin.isTTY) {
212
+ stdin.setRawMode(true);
213
+ }
214
+
215
+ stdin.resume();
216
+ stdin.setEncoding('utf8');
217
+
218
+ // Set up event handlers
219
+ stdin.on('data', handleInput);
220
+ stdin.on('error', (error) => {
221
+ cleanup();
222
+ reject(new Error(`Terminal input error: ${error.message}`));
223
+ });
224
+
225
+ // Initial render
226
+ render();
227
+
228
+ } catch (error) {
229
+ cleanup();
230
+ reject(new Error(`Setup failed: ${error.message}`));
231
+ }
232
+ });
233
+ }
@@ -0,0 +1,489 @@
1
+ import { createInterface } from 'readline';
2
+
3
+ /**
4
+ * CLI Colors and Styling Constants
5
+ */
6
+ const colors = {
7
+ reset: '\x1b[0m',
8
+ bright: '\x1b[1m',
9
+ dim: '\x1b[2m',
10
+ red: '\x1b[31m',
11
+ green: '\x1b[32m',
12
+ yellow: '\x1b[33m',
13
+ blue: '\x1b[34m',
14
+ magenta: '\x1b[35m',
15
+ cyan: '\x1b[36m',
16
+ white: '\x1b[37m',
17
+ gray: '\x1b[90m'
18
+ };
19
+
20
+ /**
21
+ * Create a clean readline interface
22
+ */
23
+ function createCleanInterface() {
24
+ const rl = createInterface({
25
+ input: process.stdin,
26
+ output: process.stdout
27
+ });
28
+
29
+ // Ensure clean state
30
+ process.stdout.write('\x1b[?25h'); // Show cursor
31
+
32
+ return rl;
33
+ }
34
+
35
+ /**
36
+ * Logger utilities
37
+ */
38
+ export const logger = {
39
+ info: (message) => {
40
+ console.log(`${colors.cyan}ℹ${colors.reset} ${message}`);
41
+ },
42
+
43
+ success: (message) => {
44
+ console.log(`${colors.green}✓${colors.reset} ${message}`);
45
+ },
46
+
47
+ error: (message) => {
48
+ console.log(`${colors.red}✗${colors.reset} ${message}`);
49
+ },
50
+
51
+ warning: (message) => {
52
+ console.log(`${colors.yellow}⚠${colors.reset} ${message}`);
53
+ },
54
+
55
+ debug: (message) => {
56
+ console.log(`${colors.gray}[DEBUG]${colors.reset} ${message}`);
57
+ },
58
+
59
+ ai: (message) => {
60
+ console.log(`${colors.magenta}🤖${colors.reset} ${message}`);
61
+ },
62
+
63
+ step: (message) => {
64
+ console.log(`${colors.blue}▶${colors.reset} ${message}`);
65
+ },
66
+
67
+ tip: (message) => {
68
+ console.log(`${colors.yellow}💡${colors.reset} ${message}`);
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Simple input prompt with validation and error handling
74
+ * @param {string} message - The prompt message
75
+ * @param {Object} options - Configuration options
76
+ * @param {string|null} options.defaultValue - Default value if no input provided
77
+ * @param {boolean} options.required - Whether input is required
78
+ * @param {Function} options.validate - Optional validation function
79
+ * @returns {Promise<string>} User's input
80
+ * @throws {Error} If input operation fails
81
+ */
82
+ export async function input(message, options = {}) {
83
+ const { defaultValue = null, required = false, validate = null } = options;
84
+
85
+ if (typeof message !== 'string') {
86
+ throw new Error('Message must be a string');
87
+ }
88
+
89
+ while (true) {
90
+ let rl;
91
+
92
+ try {
93
+ // Ensure clean terminal state
94
+ if (process.stdin.isTTY) {
95
+ process.stdin.setRawMode(false);
96
+ }
97
+
98
+ rl = createInterface({
99
+ input: process.stdin,
100
+ output: process.stdout,
101
+ terminal: false,
102
+ historySize: 0
103
+ });
104
+
105
+ // Build prompt text
106
+ let promptText = `${colors.green}➤${colors.reset} ${colors.bright}${message}${colors.reset}`;
107
+
108
+ if (defaultValue) {
109
+ promptText += ` ${colors.gray}(${defaultValue})${colors.reset}`;
110
+ }
111
+
112
+ promptText += ': ';
113
+ process.stdout.write(promptText);
114
+
115
+ // Get user input
116
+ const answer = await new Promise((resolve, reject) => {
117
+ const timeout = setTimeout(() => {
118
+ reject(new Error('Input timeout after 5 minutes'));
119
+ }, 300000); // 5 minute timeout
120
+
121
+ const handleLine = (line) => {
122
+ clearTimeout(timeout);
123
+ rl.removeListener('line', handleLine);
124
+ rl.removeListener('error', handleError);
125
+ resolve(line.trim());
126
+ };
127
+
128
+ const handleError = (error) => {
129
+ clearTimeout(timeout);
130
+ rl.removeListener('line', handleLine);
131
+ rl.removeListener('error', handleError);
132
+ reject(error);
133
+ };
134
+
135
+ rl.on('line', handleLine);
136
+ rl.on('error', handleError);
137
+ });
138
+
139
+ rl.close();
140
+
141
+ const finalAnswer = answer || defaultValue;
142
+
143
+ // Validate required field
144
+ if (required && !finalAnswer) {
145
+ console.log(`${colors.red}✗${colors.reset} This field is required`);
146
+ continue;
147
+ }
148
+
149
+ // Run custom validation if provided
150
+ if (finalAnswer && validate) {
151
+ try {
152
+ const validationResult = await validate(finalAnswer);
153
+ if (validationResult !== true) {
154
+ console.log(`${colors.red}✗${colors.reset} ${validationResult || 'Invalid input'}`);
155
+ continue;
156
+ }
157
+ } catch (validationError) {
158
+ console.log(`${colors.red}✗${colors.reset} Validation error: ${validationError.message}`);
159
+ continue;
160
+ }
161
+ }
162
+
163
+ return finalAnswer;
164
+
165
+ } catch (error) {
166
+ if (rl) {
167
+ try {
168
+ rl.close();
169
+ } catch (closeError) {
170
+ // Ignore close errors
171
+ }
172
+ }
173
+
174
+ if (error.message.includes('timeout')) {
175
+ throw new Error('Input operation timed out');
176
+ }
177
+
178
+ throw new Error(`Input operation failed: ${error.message}`);
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Confirmation prompt with validation
185
+ * @param {string} message - The confirmation message
186
+ * @param {boolean} defaultValue - Default confirmation value
187
+ * @returns {Promise<boolean>} User's confirmation
188
+ * @throws {Error} If confirmation operation fails
189
+ */
190
+ export async function confirm(message, defaultValue = false) {
191
+ if (typeof message !== 'string') {
192
+ throw new Error('Message must be a string');
193
+ }
194
+
195
+ try {
196
+ const defaultText = defaultValue ? 'Y/n' : 'y/N';
197
+ const grayDefault = defaultValue ?
198
+ `${colors.gray}default: yes${colors.reset}` :
199
+ `${colors.gray}default: no${colors.reset}`;
200
+ const promptText = `${message} (${defaultText}, ${grayDefault})`;
201
+ const answer = await input(promptText);
202
+
203
+ if (!answer) {
204
+ return defaultValue;
205
+ }
206
+
207
+ const normalized = answer.toLowerCase().trim();
208
+
209
+ // Accept various positive responses
210
+ if (['y', 'yes', 'true', '1'].includes(normalized)) {
211
+ return true;
212
+ }
213
+
214
+ // Accept various negative responses
215
+ if (['n', 'no', 'false', '0'].includes(normalized)) {
216
+ return false;
217
+ }
218
+
219
+ // Invalid response, return default
220
+ console.log(`${colors.yellow}⚠${colors.reset} Invalid response, using default (${defaultValue ? 'yes' : 'no'})`);
221
+ return defaultValue;
222
+
223
+ } catch (error) {
224
+ throw new Error(`Confirmation operation failed: ${error.message}`);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Selection prompt with validation and error handling
230
+ * @param {string} message - The selection prompt message
231
+ * @param {Array} choices - Array of choice objects with name and value properties
232
+ * @param {string|null} defaultValue - Default selected value
233
+ * @returns {Promise<string>} Selected choice value
234
+ * @throws {Error} If selection operation fails or choices are invalid
235
+ */
236
+ export async function select(message, choices, defaultValue = null) {
237
+ // Validate inputs
238
+ if (typeof message !== 'string') {
239
+ throw new Error('Message must be a string');
240
+ }
241
+
242
+ if (!Array.isArray(choices) || choices.length === 0) {
243
+ throw new Error('Choices must be a non-empty array');
244
+ }
245
+
246
+ // Validate choice objects
247
+ const validChoices = choices.every(choice =>
248
+ choice && (choice.name || choice.value)
249
+ );
250
+
251
+ if (!validChoices) {
252
+ throw new Error('All choices must have a name or value property');
253
+ }
254
+
255
+ // Display choices
256
+ console.log(`${colors.green}➤${colors.reset} ${colors.bright}${message}${colors.reset}\n`);
257
+
258
+ choices.forEach((choice, index) => {
259
+ const number = index + 1;
260
+ const isDefault = choice.value === defaultValue;
261
+ const marker = isDefault ? `${colors.green}❯${colors.reset}` : ' ';
262
+ const name = choice.name || choice.value || 'Unknown';
263
+ console.log(`${marker} ${colors.gray}${number}.${colors.reset} ${name}`);
264
+ });
265
+
266
+ console.log();
267
+
268
+ while (true) {
269
+ let rl;
270
+
271
+ try {
272
+ // Ensure clean terminal state
273
+ if (process.stdin.isTTY) {
274
+ process.stdin.setRawMode(false);
275
+ }
276
+
277
+ rl = createInterface({
278
+ input: process.stdin,
279
+ output: process.stdout,
280
+ terminal: false,
281
+ historySize: 0
282
+ });
283
+
284
+ process.stdout.write(`${colors.gray}Enter choice (1-${choices.length}):${colors.reset} `);
285
+
286
+ const answer = await new Promise((resolve, reject) => {
287
+ const timeout = setTimeout(() => {
288
+ reject(new Error('Selection timeout after 5 minutes'));
289
+ }, 300000);
290
+
291
+ const handleLine = (line) => {
292
+ clearTimeout(timeout);
293
+ rl.removeListener('line', handleLine);
294
+ rl.removeListener('error', handleError);
295
+ resolve(line.trim());
296
+ };
297
+
298
+ const handleError = (error) => {
299
+ clearTimeout(timeout);
300
+ rl.removeListener('line', handleLine);
301
+ rl.removeListener('error', handleError);
302
+ reject(error);
303
+ };
304
+
305
+ rl.on('line', handleLine);
306
+ rl.on('error', handleError);
307
+ });
308
+
309
+ rl.close();
310
+
311
+ // Handle empty input with default
312
+ if (answer === '' && defaultValue) {
313
+ const defaultChoice = choices.find(c => c.value === defaultValue);
314
+ if (defaultChoice) {
315
+ const choiceName = defaultChoice.name || defaultChoice.value;
316
+ console.log(`${colors.green}✓${colors.reset} Selected: ${choiceName}\n`);
317
+ return defaultChoice.value;
318
+ }
319
+ }
320
+
321
+ // Parse and validate numeric input
322
+ const choiceIndex = parseInt(answer, 10) - 1;
323
+ if (Number.isInteger(choiceIndex) && choiceIndex >= 0 && choiceIndex < choices.length) {
324
+ const selectedChoice = choices[choiceIndex];
325
+ const choiceName = selectedChoice.name || selectedChoice.value;
326
+ console.log(`${colors.green}✓${colors.reset} Selected: ${choiceName}\n`);
327
+ return selectedChoice.value;
328
+ }
329
+
330
+ console.log(`${colors.red}✗${colors.reset} Invalid choice. Please enter a number between 1 and ${choices.length}`);
331
+
332
+ } catch (error) {
333
+ if (rl) {
334
+ try {
335
+ rl.close();
336
+ } catch (closeError) {
337
+ // Ignore close errors
338
+ }
339
+ }
340
+
341
+ if (error.message.includes('timeout')) {
342
+ throw new Error('Selection operation timed out');
343
+ }
344
+
345
+ throw new Error(`Selection operation failed: ${error.message}`);
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Progress spinner with improved error handling and lifecycle management
352
+ * @param {string} message - Initial spinner message
353
+ * @returns {Object} Spinner control object
354
+ */
355
+ export function spinner(message = 'Loading...') {
356
+ if (typeof message !== 'string') {
357
+ throw new Error('Spinner message must be a string');
358
+ }
359
+
360
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
361
+ let frameIndex = 0;
362
+ let currentMessage = message;
363
+ let isActive = true;
364
+ let interval = null;
365
+
366
+ // Start the spinner
367
+ const startSpinner = () => {
368
+ if (interval) return; // Already running
369
+
370
+ interval = setInterval(() => {
371
+ if (isActive && process.stdout.isTTY) {
372
+ try {
373
+ process.stdout.write(`\r${colors.cyan}${frames[frameIndex]}${colors.reset} ${currentMessage}`);
374
+ frameIndex = (frameIndex + 1) % frames.length;
375
+ } catch (error) {
376
+ // Ignore write errors to prevent spinner crashes
377
+ }
378
+ }
379
+ }, 100);
380
+ };
381
+
382
+ // Clear the spinner safely
383
+ const clearSpinner = () => {
384
+ if (interval) {
385
+ clearInterval(interval);
386
+ interval = null;
387
+ }
388
+
389
+ if (process.stdout.isTTY) {
390
+ try {
391
+ process.stdout.write('\r\x1b[2K'); // Clear line
392
+ } catch (error) {
393
+ // Ignore clear errors
394
+ }
395
+ }
396
+ };
397
+
398
+ // Start immediately
399
+ startSpinner();
400
+
401
+ return {
402
+ /**
403
+ * Update spinner message
404
+ * @param {string} newMessage - New message to display
405
+ */
406
+ update: (newMessage) => {
407
+ if (typeof newMessage === 'string') {
408
+ currentMessage = newMessage;
409
+ }
410
+ },
411
+
412
+ /**
413
+ * Stop spinner without message
414
+ */
415
+ stop: () => {
416
+ isActive = false;
417
+ clearSpinner();
418
+ },
419
+
420
+ /**
421
+ * Stop spinner with success message
422
+ * @param {string} successMessage - Success message to display
423
+ */
424
+ succeed: (successMessage) => {
425
+ isActive = false;
426
+ clearSpinner();
427
+ if (typeof successMessage === 'string') {
428
+ console.log(`${colors.green}✓${colors.reset} ${successMessage}`);
429
+ }
430
+ },
431
+
432
+ /**
433
+ * Stop spinner with failure message
434
+ * @param {string} failMessage - Failure message to display
435
+ */
436
+ fail: (failMessage) => {
437
+ isActive = false;
438
+ clearSpinner();
439
+ if (typeof failMessage === 'string') {
440
+ console.log(`${colors.red}✗${colors.reset} ${failMessage}`);
441
+ }
442
+ }
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Display a table
448
+ */
449
+ export function table(headers, rows) {
450
+ const columnWidths = headers.map((header, index) => {
451
+ const maxRowWidth = Math.max(...rows.map(row => String(row[index] || '').length));
452
+ return Math.max(header.length, maxRowWidth);
453
+ });
454
+
455
+ // Header
456
+ const headerRow = headers.map((header, index) =>
457
+ header.padEnd(columnWidths[index])
458
+ ).join(' │ ');
459
+
460
+ console.log(`${colors.bright}${headerRow}${colors.reset}`);
461
+ console.log('─'.repeat(headerRow.length));
462
+
463
+ // Rows
464
+ rows.forEach(row => {
465
+ const rowStr = row.map((cell, index) =>
466
+ String(cell || '').padEnd(columnWidths[index])
467
+ ).join(' │ ');
468
+ console.log(rowStr);
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Display a progress bar
474
+ */
475
+ export function progressBar(current, total, message = '') {
476
+ const width = 40;
477
+ const percentage = Math.round((current / total) * 100);
478
+ const filled = Math.round((current / total) * width);
479
+ const empty = width - filled;
480
+
481
+ const bar = '█'.repeat(filled) + '░'.repeat(empty);
482
+ const progressText = `${colors.cyan}${bar}${colors.reset} ${percentage}% ${message}`;
483
+
484
+ process.stdout.write(`\r${progressText}`);
485
+
486
+ if (current >= total) {
487
+ process.stdout.write('\n');
488
+ }
489
+ }