@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.
- package/LICENSE +21 -0
- package/README.md +368 -0
- package/assets/config.yml +35 -0
- package/assets/default.md +47 -0
- package/assets/instructions/README.md +46 -0
- package/assets/instructions/claude.md +83 -0
- package/assets/instructions/codex.md +19 -0
- package/index.js +106 -0
- package/package.json +90 -0
- package/src/commands/close/index.js +66 -0
- package/src/commands/close/index.test.js +235 -0
- package/src/commands/get-started/index.js +138 -0
- package/src/commands/get-started/index.test.js +246 -0
- package/src/commands/init/index.js +51 -0
- package/src/commands/init/index.test.js +159 -0
- package/src/commands/link/index.js +395 -0
- package/src/commands/link/index.test.js +28 -0
- package/src/commands/lint/index.js +657 -0
- package/src/commands/lint/index.test.js +569 -0
- package/src/commands/list/index.js +131 -0
- package/src/commands/list/index.test.js +153 -0
- package/src/commands/new/index.js +305 -0
- package/src/commands/new/index.test.js +256 -0
- package/src/commands/refine/index.js +741 -0
- package/src/commands/refine/index.test.js +28 -0
- package/src/commands/review/index.js +957 -0
- package/src/commands/review/index.test.js +193 -0
- package/src/commands/start/index.js +180 -0
- package/src/commands/start/index.test.js +88 -0
- package/src/commands/unlink/index.js +123 -0
- package/src/commands/unlink/index.test.js +22 -0
- package/src/utils/arrow-select.js +233 -0
- package/src/utils/cli.js +489 -0
- package/src/utils/cli.test.js +9 -0
- package/src/utils/git.js +146 -0
- package/src/utils/git.test.js +330 -0
- package/src/utils/index.js +193 -0
- package/src/utils/index.test.js +375 -0
- package/src/utils/prompts.js +47 -0
- package/src/utils/prompts.test.js +165 -0
- package/src/utils/test-helpers.js +492 -0
- package/src/utils/ticket.js +423 -0
- 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
|
+
}
|
package/src/utils/cli.js
ADDED
|
@@ -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
|
+
}
|