preva-cli 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ import * as readline from 'node:readline';
2
+ import { colors } from './colors.js';
3
+
4
+ /**
5
+ * Check if the current process is in an interactive terminal.
6
+ */
7
+ export function isInteractive() {
8
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
9
+ }
10
+
11
+ /**
12
+ * Interactive Multi-Select Checkbox Prompt using native Node readline & raw mode.
13
+ */
14
+ export function checkboxList({ message, choices, pageSize = 15 }) {
15
+ if (!isInteractive()) {
16
+ throw new Error(
17
+ 'Interactive prompt unavailable in non-interactive environment (no TTY).\nRun with an interactive terminal to use preva preview.'
18
+ );
19
+ }
20
+
21
+ return new Promise((resolve) => {
22
+ const items = choices.map((c) => ({
23
+ name: c.name || c.path,
24
+ value: c.value !== undefined ? c.value : c.path,
25
+ checked: Boolean(c.checked),
26
+ flagged: Boolean(c.flagged),
27
+ reason: c.reason || '',
28
+ status: c.status || '',
29
+ }));
30
+
31
+ let cursor = 0;
32
+ let pageOffset = 0;
33
+ let renderedLineCount = 0;
34
+
35
+ // Save previous raw mode state
36
+ const wasRaw = process.stdin.isRaw;
37
+ readline.emitKeypressEvents(process.stdin);
38
+ if (process.stdin.setRawMode) {
39
+ process.stdin.setRawMode(true);
40
+ }
41
+ process.stdin.resume();
42
+
43
+ // Hide cursor
44
+ process.stdout.write('\x1b[?25l');
45
+
46
+ function cleanup() {
47
+ // Show cursor
48
+ process.stdout.write('\x1b[?25h');
49
+ process.stdin.removeListener('keypress', onKeypress);
50
+ if (process.stdin.setRawMode) {
51
+ process.stdin.setRawMode(Boolean(wasRaw));
52
+ }
53
+ process.stdin.pause();
54
+ }
55
+
56
+ function render(firstTime = false) {
57
+ // Clear previous lines
58
+ if (!firstTime && renderedLineCount > 0) {
59
+ process.stdout.write(`\x1b[${renderedLineCount}A`);
60
+ }
61
+
62
+ const outputLines = [];
63
+
64
+ // Header
65
+ outputLines.push(
66
+ `${colors.green('?')} ${colors.bold(message)} ${colors.dim(
67
+ '(<space> toggle, <a> toggle all, <enter> confirm)'
68
+ )}`
69
+ );
70
+
71
+ // Adjust pagination window
72
+ if (cursor < pageOffset) {
73
+ pageOffset = cursor;
74
+ } else if (cursor >= pageOffset + pageSize) {
75
+ pageOffset = cursor - pageSize + 1;
76
+ }
77
+
78
+ const visibleItems = items.slice(pageOffset, pageOffset + pageSize);
79
+
80
+ visibleItems.forEach((item, index) => {
81
+ const actualIndex = pageOffset + index;
82
+ const isCurrent = actualIndex === cursor;
83
+ const pointer = isCurrent ? colors.cyan('❯ ') : ' ';
84
+ const checkbox = item.checked ? colors.green('[x] ') : colors.gray('[ ] ');
85
+
86
+ let statusText = '';
87
+ if (item.status === 'new') statusText = colors.green(' (new)');
88
+ else if (item.status === 'deleted') statusText = colors.red(' (deleted)');
89
+ else if (item.status === 'renamed') statusText = colors.yellow(' (renamed)');
90
+ else if (item.status) statusText = colors.cyan(` (${item.status})`);
91
+
92
+ let flagNotice = '';
93
+ if (item.flagged) {
94
+ flagNotice = colors.yellow(` [flagged: ${item.reason || 'sensitive'}]`);
95
+ }
96
+
97
+ const nameText = isCurrent ? colors.bold(item.name) : item.name;
98
+ outputLines.push(`${pointer}${checkbox}${nameText}${statusText}${flagNotice}`);
99
+ });
100
+
101
+ // Pagination indicators
102
+ const remainingBelow = items.length - (pageOffset + pageSize);
103
+ const remainingAbove = pageOffset;
104
+ if (items.length > pageSize) {
105
+ const hints = [];
106
+ if (remainingAbove > 0) hints.push(`▲ ${remainingAbove} above`);
107
+ if (remainingBelow > 0) hints.push(`▼ ${remainingBelow} below`);
108
+ outputLines.push(colors.dim(` (${hints.join(' | ')})`));
109
+ }
110
+
111
+ renderedLineCount = outputLines.length;
112
+ process.stdout.write(outputLines.map((line) => `\x1b[2K\r${line}`).join('\n') + '\n');
113
+ }
114
+
115
+ function onKeypress(str, key) {
116
+ if (key.ctrl && key.name === 'c') {
117
+ cleanup();
118
+ process.stdout.write('\n');
119
+ process.exit(130);
120
+ }
121
+
122
+ if (key.name === 'up' || key.name === 'k') {
123
+ cursor = cursor > 0 ? cursor - 1 : items.length - 1;
124
+ render();
125
+ } else if (key.name === 'down' || key.name === 'j') {
126
+ cursor = cursor < items.length - 1 ? cursor + 1 : 0;
127
+ render();
128
+ } else if (key.name === 'space') {
129
+ items[cursor].checked = !items[cursor].checked;
130
+ render();
131
+ } else if (str === 'a' || str === 'A') {
132
+ const hasUnchecked = items.some((i) => !i.checked);
133
+ items.forEach((i) => {
134
+ i.checked = hasUnchecked;
135
+ });
136
+ render();
137
+ } else if (key.name === 'return' || key.name === 'enter') {
138
+ cleanup();
139
+ const selected = items.filter((i) => i.checked).map((i) => i.value);
140
+ resolve(selected);
141
+ }
142
+ }
143
+
144
+ process.stdin.on('keypress', onKeypress);
145
+ render(true);
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Interactive Yes/No Confirmation Prompt.
151
+ */
152
+ export function confirmPrompt({ message, defaultValue = false }) {
153
+ if (!isInteractive()) {
154
+ throw new Error(
155
+ 'Interactive prompt unavailable in non-interactive environment (no TTY).\nRun with an interactive terminal to use preva preview.'
156
+ );
157
+ }
158
+
159
+ return new Promise((resolve) => {
160
+ const wasRaw = process.stdin.isRaw;
161
+ readline.emitKeypressEvents(process.stdin);
162
+ if (process.stdin.setRawMode) {
163
+ process.stdin.setRawMode(true);
164
+ }
165
+ process.stdin.resume();
166
+
167
+ const hint = defaultValue ? '[Y/n]' : '[y/N]';
168
+ process.stdout.write(`${colors.green('?')} ${colors.bold(message)} ${colors.cyan(hint)}: `);
169
+
170
+ function cleanup() {
171
+ process.stdin.removeListener('keypress', onKeypress);
172
+ if (process.stdin.setRawMode) {
173
+ process.stdin.setRawMode(Boolean(wasRaw));
174
+ }
175
+ process.stdin.pause();
176
+ }
177
+
178
+ function onKeypress(str, key) {
179
+ if (key.ctrl && key.name === 'c') {
180
+ cleanup();
181
+ process.stdout.write('\n');
182
+ process.exit(130);
183
+ }
184
+
185
+ let answer = defaultValue;
186
+ let char = '';
187
+
188
+ if (key.name === 'y' || str === 'y' || str === 'Y') {
189
+ answer = true;
190
+ char = 'y';
191
+ } else if (key.name === 'n' || str === 'n' || str === 'N') {
192
+ answer = false;
193
+ char = 'n';
194
+ } else if (key.name === 'return' || key.name === 'enter') {
195
+ answer = defaultValue;
196
+ char = defaultValue ? 'y' : 'n';
197
+ } else {
198
+ return; // Ignore other keys
199
+ }
200
+
201
+ cleanup();
202
+ process.stdout.write(`${char}\n`);
203
+ resolve(answer);
204
+ }
205
+
206
+ process.stdin.on('keypress', onKeypress);
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Interactive Single-Select Prompt using native Node readline & raw mode.
212
+ */
213
+ export function selectPrompt({ message, choices, pageSize = 12 }) {
214
+ if (!isInteractive()) {
215
+ throw new Error(
216
+ 'Interactive prompt unavailable in non-interactive environment (no TTY).\nRun with an interactive terminal to use preva preview.'
217
+ );
218
+ }
219
+
220
+ return new Promise((resolve) => {
221
+ const items = choices.map((c) => {
222
+ if (typeof c === 'string') {
223
+ return { name: c, value: c, description: '' };
224
+ }
225
+ return {
226
+ name: c.name || c.value,
227
+ value: c.value,
228
+ description: c.description || '',
229
+ };
230
+ });
231
+
232
+ let cursor = 0;
233
+ let pageOffset = 0;
234
+ let renderedLineCount = 0;
235
+
236
+ const wasRaw = process.stdin.isRaw;
237
+ readline.emitKeypressEvents(process.stdin);
238
+ if (process.stdin.setRawMode) {
239
+ process.stdin.setRawMode(true);
240
+ }
241
+ process.stdin.resume();
242
+
243
+ // Hide cursor
244
+ process.stdout.write('\x1b[?25l');
245
+
246
+ function cleanup() {
247
+ process.stdout.write('\x1b[?25h');
248
+ process.stdin.removeListener('keypress', onKeypress);
249
+ if (process.stdin.setRawMode) {
250
+ process.stdin.setRawMode(Boolean(wasRaw));
251
+ }
252
+ process.stdin.pause();
253
+ }
254
+
255
+ function render(firstTime = false) {
256
+ if (!firstTime && renderedLineCount > 0) {
257
+ process.stdout.write(`\x1b[${renderedLineCount}A`);
258
+ }
259
+
260
+ const outputLines = [];
261
+ outputLines.push(
262
+ `${colors.green('?')} ${colors.bold(message)} ${colors.dim('(Use arrow keys, <enter> to select)')}`
263
+ );
264
+
265
+ if (cursor < pageOffset) {
266
+ pageOffset = cursor;
267
+ } else if (cursor >= pageOffset + pageSize) {
268
+ pageOffset = cursor - pageSize + 1;
269
+ }
270
+
271
+ const visibleItems = items.slice(pageOffset, pageOffset + pageSize);
272
+
273
+ visibleItems.forEach((item, index) => {
274
+ const actualIndex = pageOffset + index;
275
+ const isCurrent = actualIndex === cursor;
276
+ const pointer = isCurrent ? colors.cyan('❯ ') : ' ';
277
+ const nameText = isCurrent ? colors.bold(colors.cyan(item.name)) : item.name;
278
+ const descText = item.description ? ` ${colors.dim(`- ${item.description}`)}` : '';
279
+ outputLines.push(`${pointer}${nameText}${descText}`);
280
+ });
281
+
282
+ const remainingBelow = items.length - (pageOffset + pageSize);
283
+ const remainingAbove = pageOffset;
284
+ if (items.length > pageSize) {
285
+ const hints = [];
286
+ if (remainingAbove > 0) hints.push(`▲ ${remainingAbove} above`);
287
+ if (remainingBelow > 0) hints.push(`▼ ${remainingBelow} below`);
288
+ outputLines.push(colors.dim(` (${hints.join(' | ')})`));
289
+ }
290
+
291
+ renderedLineCount = outputLines.length;
292
+ process.stdout.write(outputLines.map((line) => `\x1b[2K\r${line}`).join('\n') + '\n');
293
+ }
294
+
295
+ function onKeypress(str, key) {
296
+ if (key.ctrl && key.name === 'c') {
297
+ cleanup();
298
+ process.stdout.write('\n');
299
+ process.exit(130);
300
+ }
301
+
302
+ if (key.name === 'up' || key.name === 'k') {
303
+ cursor = cursor > 0 ? cursor - 1 : items.length - 1;
304
+ render();
305
+ } else if (key.name === 'down' || key.name === 'j') {
306
+ cursor = cursor < items.length - 1 ? cursor + 1 : 0;
307
+ render();
308
+ } else if (key.name === 'return' || key.name === 'enter') {
309
+ cleanup();
310
+ resolve(items[cursor].value);
311
+ }
312
+ }
313
+
314
+ process.stdin.on('keypress', onKeypress);
315
+ render(true);
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Text Input Prompt using native Node readline interface.
321
+ */
322
+ export function inputPrompt({ message, defaultValue = '', placeholder = '', allowEmpty = true }) {
323
+ if (!isInteractive()) {
324
+ throw new Error('Interactive prompt unavailable in non-interactive environment (no TTY).');
325
+ }
326
+
327
+ return new Promise((resolve) => {
328
+ const rl = readline.createInterface({
329
+ input: process.stdin,
330
+ output: process.stdout,
331
+ });
332
+
333
+ const hint = placeholder ? ` ${colors.dim(`(${placeholder})`)}` : '';
334
+ const defaultHint = defaultValue ? ` ${colors.dim(`[${defaultValue}]`)}` : '';
335
+ const promptText = `${colors.green('?')} ${colors.bold(message)}${hint}${defaultHint}: `;
336
+
337
+ function ask() {
338
+ rl.question(promptText, (answer) => {
339
+ const val = answer.trim() || defaultValue;
340
+ if (!allowEmpty && !val) {
341
+ console.log(colors.yellow(' This field is required. Please enter a value.'));
342
+ ask();
343
+ return;
344
+ }
345
+ rl.close();
346
+ resolve(val);
347
+ });
348
+ }
349
+
350
+ ask();
351
+ });
352
+ }