@buenojs/bueno 0.8.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 (120) hide show
  1. package/.env.example +109 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/LICENSE +21 -0
  4. package/README.md +892 -0
  5. package/architecture.md +652 -0
  6. package/bun.lock +70 -0
  7. package/dist/cli/index.js +3233 -0
  8. package/dist/index.js +9014 -0
  9. package/package.json +77 -0
  10. package/src/cache/index.ts +795 -0
  11. package/src/cli/ARCHITECTURE.md +837 -0
  12. package/src/cli/bin.ts +10 -0
  13. package/src/cli/commands/build.ts +425 -0
  14. package/src/cli/commands/dev.ts +248 -0
  15. package/src/cli/commands/generate.ts +541 -0
  16. package/src/cli/commands/help.ts +55 -0
  17. package/src/cli/commands/index.ts +112 -0
  18. package/src/cli/commands/migration.ts +355 -0
  19. package/src/cli/commands/new.ts +804 -0
  20. package/src/cli/commands/start.ts +208 -0
  21. package/src/cli/core/args.ts +283 -0
  22. package/src/cli/core/console.ts +349 -0
  23. package/src/cli/core/index.ts +60 -0
  24. package/src/cli/core/prompt.ts +424 -0
  25. package/src/cli/core/spinner.ts +265 -0
  26. package/src/cli/index.ts +135 -0
  27. package/src/cli/templates/deploy.ts +295 -0
  28. package/src/cli/templates/docker.ts +307 -0
  29. package/src/cli/templates/index.ts +24 -0
  30. package/src/cli/utils/fs.ts +428 -0
  31. package/src/cli/utils/index.ts +8 -0
  32. package/src/cli/utils/strings.ts +197 -0
  33. package/src/config/env.ts +408 -0
  34. package/src/config/index.ts +506 -0
  35. package/src/config/loader.ts +329 -0
  36. package/src/config/merge.ts +285 -0
  37. package/src/config/types.ts +320 -0
  38. package/src/config/validation.ts +441 -0
  39. package/src/container/forward-ref.ts +143 -0
  40. package/src/container/index.ts +386 -0
  41. package/src/context/index.ts +360 -0
  42. package/src/database/index.ts +1142 -0
  43. package/src/database/migrations/index.ts +371 -0
  44. package/src/database/schema/index.ts +619 -0
  45. package/src/frontend/api-routes.ts +640 -0
  46. package/src/frontend/bundler.ts +643 -0
  47. package/src/frontend/console-client.ts +419 -0
  48. package/src/frontend/console-stream.ts +587 -0
  49. package/src/frontend/dev-server.ts +846 -0
  50. package/src/frontend/file-router.ts +611 -0
  51. package/src/frontend/frameworks/index.ts +106 -0
  52. package/src/frontend/frameworks/react.ts +85 -0
  53. package/src/frontend/frameworks/solid.ts +104 -0
  54. package/src/frontend/frameworks/svelte.ts +110 -0
  55. package/src/frontend/frameworks/vue.ts +92 -0
  56. package/src/frontend/hmr-client.ts +663 -0
  57. package/src/frontend/hmr.ts +728 -0
  58. package/src/frontend/index.ts +342 -0
  59. package/src/frontend/islands.ts +552 -0
  60. package/src/frontend/isr.ts +555 -0
  61. package/src/frontend/layout.ts +475 -0
  62. package/src/frontend/ssr/react.ts +446 -0
  63. package/src/frontend/ssr/solid.ts +523 -0
  64. package/src/frontend/ssr/svelte.ts +546 -0
  65. package/src/frontend/ssr/vue.ts +504 -0
  66. package/src/frontend/ssr.ts +699 -0
  67. package/src/frontend/types.ts +2274 -0
  68. package/src/health/index.ts +604 -0
  69. package/src/index.ts +410 -0
  70. package/src/lock/index.ts +587 -0
  71. package/src/logger/index.ts +444 -0
  72. package/src/logger/transports/index.ts +969 -0
  73. package/src/metrics/index.ts +494 -0
  74. package/src/middleware/built-in.ts +360 -0
  75. package/src/middleware/index.ts +94 -0
  76. package/src/modules/filters.ts +458 -0
  77. package/src/modules/guards.ts +405 -0
  78. package/src/modules/index.ts +1256 -0
  79. package/src/modules/interceptors.ts +574 -0
  80. package/src/modules/lazy.ts +418 -0
  81. package/src/modules/lifecycle.ts +478 -0
  82. package/src/modules/metadata.ts +90 -0
  83. package/src/modules/pipes.ts +626 -0
  84. package/src/router/index.ts +339 -0
  85. package/src/router/linear.ts +371 -0
  86. package/src/router/regex.ts +292 -0
  87. package/src/router/tree.ts +562 -0
  88. package/src/rpc/index.ts +1263 -0
  89. package/src/security/index.ts +436 -0
  90. package/src/ssg/index.ts +631 -0
  91. package/src/storage/index.ts +456 -0
  92. package/src/telemetry/index.ts +1097 -0
  93. package/src/testing/index.ts +1586 -0
  94. package/src/types/index.ts +236 -0
  95. package/src/types/optional-deps.d.ts +219 -0
  96. package/src/validation/index.ts +276 -0
  97. package/src/websocket/index.ts +1004 -0
  98. package/tests/integration/cli.test.ts +1016 -0
  99. package/tests/integration/fullstack.test.ts +234 -0
  100. package/tests/unit/cache.test.ts +174 -0
  101. package/tests/unit/cli-commands.test.ts +892 -0
  102. package/tests/unit/cli.test.ts +1258 -0
  103. package/tests/unit/container.test.ts +279 -0
  104. package/tests/unit/context.test.ts +221 -0
  105. package/tests/unit/database.test.ts +183 -0
  106. package/tests/unit/linear-router.test.ts +280 -0
  107. package/tests/unit/lock.test.ts +336 -0
  108. package/tests/unit/middleware.test.ts +184 -0
  109. package/tests/unit/modules.test.ts +142 -0
  110. package/tests/unit/pubsub.test.ts +257 -0
  111. package/tests/unit/regex-router.test.ts +265 -0
  112. package/tests/unit/router.test.ts +373 -0
  113. package/tests/unit/rpc.test.ts +1248 -0
  114. package/tests/unit/security.test.ts +174 -0
  115. package/tests/unit/telemetry.test.ts +371 -0
  116. package/tests/unit/test-cache.test.ts +110 -0
  117. package/tests/unit/test-database.test.ts +282 -0
  118. package/tests/unit/tree-router.test.ts +325 -0
  119. package/tests/unit/validation.test.ts +794 -0
  120. package/tsconfig.json +27 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Interactive Prompts for Bueno CLI
3
+ *
4
+ * Provides interactive prompts for user input
5
+ * Falls back to simple input for non-TTY environments
6
+ */
7
+
8
+ import * as readline from 'readline';
9
+ import { colors } from './console';
10
+
11
+ export interface PromptOptions {
12
+ default?: string;
13
+ validate?: (value: string) => boolean | string;
14
+ }
15
+
16
+ export interface SelectOptions<T = string> {
17
+ default?: T;
18
+ pageSize?: number;
19
+ }
20
+
21
+ export interface ConfirmOptions {
22
+ default?: boolean;
23
+ }
24
+
25
+ export interface MultiSelectOptions<T = string> {
26
+ default?: T[];
27
+ pageSize?: number;
28
+ min?: number;
29
+ max?: number;
30
+ }
31
+
32
+ /**
33
+ * Check if running in interactive mode
34
+ */
35
+ export function isInteractive(): boolean {
36
+ return !!(process.stdin.isTTY && process.stdout.isTTY);
37
+ }
38
+
39
+ /**
40
+ * Create a readline interface
41
+ */
42
+ function createRL(): readline.ReadLine {
43
+ return readline.createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Prompt for text input
51
+ */
52
+ export async function prompt(
53
+ message: string,
54
+ options: PromptOptions = {},
55
+ ): Promise<string> {
56
+ const defaultValue = options.default;
57
+ const promptText = defaultValue
58
+ ? `${colors.cyan('?')} ${message} ${colors.dim(`(${defaultValue})`)}: `
59
+ : `${colors.cyan('?')} ${message}: `;
60
+
61
+ if (!isInteractive()) {
62
+ return defaultValue ?? '';
63
+ }
64
+
65
+ return new Promise((resolve) => {
66
+ const rl = createRL();
67
+
68
+ rl.question(promptText, (answer) => {
69
+ rl.close();
70
+
71
+ const value = answer.trim() || defaultValue || '';
72
+
73
+ if (options.validate) {
74
+ const result = options.validate(value);
75
+ if (result !== true) {
76
+ const errorMsg = typeof result === 'string' ? result : 'Invalid value';
77
+ process.stdout.write(`${colors.red('✗')} ${errorMsg}\n`);
78
+ // Re-prompt on validation failure
79
+ prompt(message, options).then(resolve);
80
+ return;
81
+ }
82
+ }
83
+
84
+ resolve(value);
85
+ });
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Prompt for confirmation (yes/no)
91
+ */
92
+ export async function confirm(
93
+ message: string,
94
+ options: ConfirmOptions = {},
95
+ ): Promise<boolean> {
96
+ const defaultValue = options.default ?? false;
97
+ const hint = defaultValue ? 'Y/n' : 'y/N';
98
+
99
+ if (!isInteractive()) {
100
+ return defaultValue;
101
+ }
102
+
103
+ const answer = await prompt(`${message} ${colors.dim(`(${hint})`)}`, {
104
+ default: defaultValue ? 'y' : 'n',
105
+ validate: (value) => {
106
+ if (!value) return true;
107
+ return ['y', 'yes', 'n', 'no'].includes(value.toLowerCase()) ||
108
+ 'Please enter y or n';
109
+ },
110
+ });
111
+
112
+ return ['y', 'yes'].includes(answer.toLowerCase());
113
+ }
114
+
115
+ /**
116
+ * Prompt for selection from a list
117
+ */
118
+ export async function select<T extends string>(
119
+ message: string,
120
+ choices: Array<{ value: T; name?: string; disabled?: boolean }>,
121
+ options: SelectOptions<T> = {},
122
+ ): Promise<T> {
123
+ if (!isInteractive()) {
124
+ return options.default ?? choices[0]?.value as T;
125
+ }
126
+
127
+ const pageSize = options.pageSize ?? 10;
128
+ let selectedIndex = choices.findIndex(
129
+ (c) => c.value === options.default && !c.disabled,
130
+ );
131
+ if (selectedIndex === -1) {
132
+ selectedIndex = choices.findIndex((c) => !c.disabled);
133
+ }
134
+
135
+ return new Promise((resolve) => {
136
+ // Hide cursor
137
+ process.stdout.write('\x1b[?25l');
138
+
139
+ const render = () => {
140
+ // Clear previous output
141
+ const lines = Math.min(choices.length, pageSize);
142
+ process.stdout.write(`\x1b[${lines + 1}A\x1b[0J`);
143
+
144
+ // Render prompt
145
+ process.stdout.write(`${colors.cyan('?')} ${message}\n`);
146
+
147
+ // Render choices
148
+ const start = Math.max(0, selectedIndex - pageSize + 1);
149
+ const end = Math.min(choices.length, start + pageSize);
150
+
151
+ for (let i = start; i < end; i++) {
152
+ const choice = choices[i];
153
+ if (!choice) continue;
154
+
155
+ const isSelected = i === selectedIndex;
156
+ const prefix = isSelected ? `${colors.cyan('❯')} ` : ' ';
157
+ const name = choice.name ?? choice.value;
158
+ const text = choice.disabled
159
+ ? colors.dim(`${name} (disabled)`)
160
+ : isSelected
161
+ ? colors.cyan(name)
162
+ : name;
163
+
164
+ process.stdout.write(`${prefix}${text}\n`);
165
+ }
166
+ };
167
+
168
+ // Initial render
169
+ process.stdout.write(`${colors.cyan('?')} ${message}\n`);
170
+ render();
171
+
172
+ // Handle key input
173
+ const stdin = process.stdin;
174
+ stdin.setRawMode(true);
175
+ stdin.resume();
176
+ stdin.setEncoding('utf8');
177
+
178
+ const cleanup = () => {
179
+ stdin.setRawMode(false);
180
+ stdin.pause();
181
+ stdin.removeListener('data', handler);
182
+ // Show cursor
183
+ process.stdout.write('\x1b[?25h');
184
+ };
185
+
186
+ const handler = (key: string) => {
187
+ if (key === '\u001b[A' || key === 'k') {
188
+ // Up
189
+ do {
190
+ selectedIndex = (selectedIndex - 1 + choices.length) % choices.length;
191
+ } while (choices[selectedIndex]?.disabled);
192
+ render();
193
+ } else if (key === '\u001b[B' || key === 'j') {
194
+ // Down
195
+ do {
196
+ selectedIndex = (selectedIndex + 1) % choices.length;
197
+ } while (choices[selectedIndex]?.disabled);
198
+ render();
199
+ } else if (key === '\r' || key === '\n') {
200
+ // Enter
201
+ cleanup();
202
+ const selected = choices[selectedIndex];
203
+ if (selected) {
204
+ process.stdout.write(
205
+ `\x1b[${Math.min(choices.length, pageSize) + 1}A\x1b[0J`,
206
+ );
207
+ process.stdout.write(
208
+ `${colors.cyan('?')} ${message} ${colors.cyan(selected.name ?? selected.value)}\n`,
209
+ );
210
+ resolve(selected.value);
211
+ }
212
+ } else if (key === '\u001b' || key === '\u0003') {
213
+ // Escape or Ctrl+C
214
+ cleanup();
215
+ process.stdout.write('\n');
216
+ process.exit(130);
217
+ }
218
+ };
219
+
220
+ stdin.on('data', handler);
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Prompt for multiple selections
226
+ */
227
+ export async function multiSelect<T extends string>(
228
+ message: string,
229
+ choices: Array<{ value: T; name?: string; disabled?: boolean }>,
230
+ options: MultiSelectOptions<T> = {},
231
+ ): Promise<T[]> {
232
+ if (!isInteractive()) {
233
+ return options.default ?? [];
234
+ }
235
+
236
+ const pageSize = options.pageSize ?? 10;
237
+ const selected = new Set<T>(options.default ?? []);
238
+ let currentIndex = 0;
239
+
240
+ return new Promise((resolve) => {
241
+ // Hide cursor
242
+ process.stdout.write('\x1b[?25l');
243
+
244
+ const render = () => {
245
+ // Clear previous output
246
+ const lines = Math.min(choices.length, pageSize);
247
+ process.stdout.write(`\x1b[${lines + 1}A\x1b[0J`);
248
+
249
+ // Render prompt
250
+ process.stdout.write(`${colors.cyan('?')} ${message}\n`);
251
+
252
+ // Render choices
253
+ const start = Math.max(0, currentIndex - pageSize + 1);
254
+ const end = Math.min(choices.length, start + pageSize);
255
+
256
+ for (let i = start; i < end; i++) {
257
+ const choice = choices[i];
258
+ if (!choice) continue;
259
+
260
+ const isCurrent = i === currentIndex;
261
+ const isSelected = selected.has(choice.value);
262
+ const checkbox = isSelected ? `${colors.green('◉')}` : '○';
263
+ const prefix = isCurrent ? `${colors.cyan('❯')} ` : ' ';
264
+ const name = choice.name ?? choice.value;
265
+ const text = choice.disabled
266
+ ? colors.dim(`${name} (disabled)`)
267
+ : isCurrent
268
+ ? colors.cyan(name)
269
+ : name;
270
+
271
+ process.stdout.write(`${prefix}${checkbox} ${text}\n`);
272
+ }
273
+ };
274
+
275
+ // Initial render
276
+ process.stdout.write(`${colors.cyan('?')} ${message}\n`);
277
+ render();
278
+
279
+ // Handle key input
280
+ const stdin = process.stdin;
281
+ stdin.setRawMode(true);
282
+ stdin.resume();
283
+ stdin.setEncoding('utf8');
284
+
285
+ const cleanup = () => {
286
+ stdin.setRawMode(false);
287
+ stdin.pause();
288
+ stdin.removeListener('data', handler);
289
+ // Show cursor
290
+ process.stdout.write('\x1b[?25h');
291
+ };
292
+
293
+ const handler = (key: string) => {
294
+ if (key === '\u001b[A' || key === 'k') {
295
+ // Up
296
+ do {
297
+ currentIndex = (currentIndex - 1 + choices.length) % choices.length;
298
+ } while (choices[currentIndex]?.disabled);
299
+ render();
300
+ } else if (key === '\u001b[B' || key === 'j') {
301
+ // Down
302
+ do {
303
+ currentIndex = (currentIndex + 1) % choices.length;
304
+ } while (choices[currentIndex]?.disabled);
305
+ render();
306
+ } else if (key === ' ' || key === 'x') {
307
+ // Toggle selection
308
+ const choice = choices[currentIndex];
309
+ if (choice && !choice.disabled) {
310
+ if (selected.has(choice.value)) {
311
+ if (options.min === undefined || selected.size > options.min) {
312
+ selected.delete(choice.value);
313
+ }
314
+ } else {
315
+ if (options.max === undefined || selected.size < options.max) {
316
+ selected.add(choice.value);
317
+ }
318
+ }
319
+ render();
320
+ }
321
+ } else if (key === '\r' || key === '\n') {
322
+ // Enter
323
+ cleanup();
324
+ const result = Array.from(selected);
325
+ process.stdout.write(
326
+ `\x1b[${Math.min(choices.length, pageSize) + 1}A\x1b[0J`,
327
+ );
328
+ const names = result
329
+ .map((v) => choices.find((c) => c.value === v)?.name ?? v)
330
+ .join(', ');
331
+ process.stdout.write(
332
+ `${colors.cyan('?')} ${message} ${colors.cyan(names || 'none')}\n`,
333
+ );
334
+ resolve(result);
335
+ } else if (key === '\u001b' || key === '\u0003') {
336
+ // Escape or Ctrl+C
337
+ cleanup();
338
+ process.stdout.write('\n');
339
+ process.exit(130);
340
+ }
341
+ };
342
+
343
+ stdin.on('data', handler);
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Prompt for a number
349
+ */
350
+ export async function number(
351
+ message: string,
352
+ options: PromptOptions & { min?: number; max?: number } = {},
353
+ ): Promise<number> {
354
+ const value = await prompt(message, {
355
+ ...options,
356
+ validate: (v) => {
357
+ if (!v && options.default) return true;
358
+ const num = parseFloat(v);
359
+ if (isNaN(num)) return 'Please enter a valid number';
360
+ if (options.min !== undefined && num < options.min) {
361
+ return `Value must be at least ${options.min}`;
362
+ }
363
+ if (options.max !== undefined && num > options.max) {
364
+ return `Value must be at most ${options.max}`;
365
+ }
366
+ if (options.validate) {
367
+ return options.validate(v);
368
+ }
369
+ return true;
370
+ },
371
+ });
372
+
373
+ return parseFloat(value || options.default || '0');
374
+ }
375
+
376
+ /**
377
+ * Prompt for password (hidden input)
378
+ */
379
+ export async function password(
380
+ message: string,
381
+ options: Omit<PromptOptions, 'default'> = {},
382
+ ): Promise<string> {
383
+ if (!isInteractive()) {
384
+ return '';
385
+ }
386
+
387
+ return new Promise((resolve) => {
388
+ const stdin = process.stdin;
389
+ const stdout = process.stdout;
390
+
391
+ stdout.write(`${colors.cyan('?')} ${message}: `);
392
+
393
+ let value = '';
394
+
395
+ stdin.setRawMode(true);
396
+ stdin.resume();
397
+ stdin.setEncoding('utf8');
398
+
399
+ const cleanup = () => {
400
+ stdin.setRawMode(false);
401
+ stdin.pause();
402
+ stdin.removeListener('data', handler);
403
+ };
404
+
405
+ const handler = (key: string) => {
406
+ if (key === '\r' || key === '\n') {
407
+ cleanup();
408
+ stdout.write('\n');
409
+ resolve(value);
410
+ } else if (key === '\u0003') {
411
+ cleanup();
412
+ stdout.write('\n');
413
+ process.exit(130);
414
+ } else if (key === '\u007f' || key === '\b') {
415
+ // Backspace
416
+ value = value.slice(0, -1);
417
+ } else if (key[0] !== '\x1b') {
418
+ value += key;
419
+ }
420
+ };
421
+
422
+ stdin.on('data', handler);
423
+ });
424
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Progress Spinner for Bueno CLI
3
+ *
4
+ * Provides animated spinners and progress indicators
5
+ */
6
+
7
+ import { colors, isColorEnabled } from './console';
8
+
9
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
10
+ const SPINNER_INTERVAL = 80;
11
+
12
+ export interface SpinnerOptions {
13
+ text?: string;
14
+ color?: 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white';
15
+ }
16
+
17
+ export class Spinner {
18
+ private text: string;
19
+ private color: keyof typeof colors;
20
+ private frameIndex = 0;
21
+ private interval: Timer | null = null;
22
+ private isSpinning = false;
23
+ private stream = process.stdout;
24
+
25
+ constructor(options: SpinnerOptions = {}) {
26
+ this.text = options.text ?? '';
27
+ this.color = options.color ?? 'cyan';
28
+ }
29
+
30
+ /**
31
+ * Start the spinner
32
+ */
33
+ start(text?: string): this {
34
+ if (text) this.text = text;
35
+ if (this.isSpinning) return this;
36
+
37
+ this.isSpinning = true;
38
+ this.frameIndex = 0;
39
+
40
+ // Hide cursor
41
+ this.stream.write('\x1b[?25l');
42
+
43
+ this.interval = setInterval(() => {
44
+ this.render();
45
+ }, SPINNER_INTERVAL);
46
+
47
+ return this;
48
+ }
49
+
50
+ /**
51
+ * Update spinner text
52
+ */
53
+ update(text: string): this {
54
+ this.text = text;
55
+ if (this.isSpinning) {
56
+ this.render();
57
+ }
58
+ return this;
59
+ }
60
+
61
+ /**
62
+ * Stop the spinner with success
63
+ */
64
+ success(text?: string): this {
65
+ return this.stop(colors.green('✓'), text);
66
+ }
67
+
68
+ /**
69
+ * Stop the spinner with error
70
+ */
71
+ error(text?: string): this {
72
+ return this.stop(colors.red('✗'), text);
73
+ }
74
+
75
+ /**
76
+ * Stop the spinner with warning
77
+ */
78
+ warn(text?: string): this {
79
+ return this.stop(colors.yellow('⚠'), text);
80
+ }
81
+
82
+ /**
83
+ * Stop the spinner with info
84
+ */
85
+ info(text?: string): this {
86
+ return this.stop(colors.cyan('ℹ'), text);
87
+ }
88
+
89
+ /**
90
+ * Stop the spinner
91
+ */
92
+ stop(symbol?: string, text?: string): this {
93
+ if (!this.isSpinning) return this;
94
+
95
+ this.isSpinning = false;
96
+
97
+ if (this.interval) {
98
+ clearInterval(this.interval);
99
+ this.interval = null;
100
+ }
101
+
102
+ // Clear the line
103
+ this.stream.write('\r\x1b[K');
104
+
105
+ // Write final message
106
+ const finalText = text ?? this.text;
107
+ if (symbol) {
108
+ this.stream.write(`${symbol} ${finalText}\n`);
109
+ } else {
110
+ this.stream.write(`${finalText}\n`);
111
+ }
112
+
113
+ // Show cursor
114
+ this.stream.write('\x1b[?25h');
115
+
116
+ return this;
117
+ }
118
+
119
+ /**
120
+ * Clear the spinner
121
+ */
122
+ clear(): this {
123
+ if (!this.isSpinning) return this;
124
+
125
+ this.stream.write('\r\x1b[K');
126
+ return this;
127
+ }
128
+
129
+ /**
130
+ * Render current frame
131
+ */
132
+ private render(): void {
133
+ if (!isColorEnabled()) {
134
+ // Without colors, just show dots
135
+ const dots = '.'.repeat((this.frameIndex % 3) + 1);
136
+ this.stream.write(`\r${this.text}${dots} `);
137
+ this.frameIndex++;
138
+ return;
139
+ }
140
+
141
+ const frame = SPINNER_FRAMES[this.frameIndex % SPINNER_FRAMES.length];
142
+ const coloredFrame = colors[this.color](frame);
143
+
144
+ this.stream.write(`\r${coloredFrame} ${this.text}`);
145
+ this.frameIndex++;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Create and start a spinner
151
+ */
152
+ export function spinner(text: string, options?: Omit<SpinnerOptions, 'text'>): Spinner {
153
+ return new Spinner({ text, ...options }).start();
154
+ }
155
+
156
+ /**
157
+ * Progress bar
158
+ */
159
+ export interface ProgressBarOptions {
160
+ total: number;
161
+ width?: number;
162
+ text?: string;
163
+ completeChar?: string;
164
+ incompleteChar?: string;
165
+ }
166
+
167
+ export class ProgressBar {
168
+ private total: number;
169
+ private width: number;
170
+ private text: string;
171
+ private completeChar: string;
172
+ private incompleteChar: string;
173
+ private current = 0;
174
+ private stream = process.stdout;
175
+
176
+ constructor(options: ProgressBarOptions) {
177
+ this.total = options.total;
178
+ this.width = options.width ?? 40;
179
+ this.text = options.text ?? '';
180
+ this.completeChar = options.completeChar ?? '█';
181
+ this.incompleteChar = options.incompleteChar ?? '░';
182
+ }
183
+
184
+ /**
185
+ * Start the progress bar
186
+ */
187
+ start(): this {
188
+ this.current = 0;
189
+ this.render();
190
+ return this;
191
+ }
192
+
193
+ /**
194
+ * Update progress
195
+ */
196
+ update(current: number): this {
197
+ this.current = Math.min(current, this.total);
198
+ this.render();
199
+ return this;
200
+ }
201
+
202
+ /**
203
+ * Increment progress
204
+ */
205
+ increment(amount = 1): this {
206
+ return this.update(this.current + amount);
207
+ }
208
+
209
+ /**
210
+ * Complete the progress bar
211
+ */
212
+ complete(): this {
213
+ this.current = this.total;
214
+ this.render();
215
+ this.stream.write('\n');
216
+ return this;
217
+ }
218
+
219
+ /**
220
+ * Render the progress bar
221
+ */
222
+ private render(): void {
223
+ const percent = this.current / this.total;
224
+ const completeWidth = Math.round(this.width * percent);
225
+ const incompleteWidth = this.width - completeWidth;
226
+
227
+ const complete = this.completeChar.repeat(completeWidth);
228
+ const incomplete = this.incompleteChar.repeat(incompleteWidth);
229
+
230
+ const bar = colors.green(complete) + colors.dim(incomplete);
231
+ const percentText = `${Math.round(percent * 100)}%`.padStart(4);
232
+
233
+ const line = `\r${this.text} [${bar}] ${percentText} ${this.current}/${this.total}`;
234
+
235
+ this.stream.write(`\r\x1b[K${line}`);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Create a progress bar
241
+ */
242
+ export function progressBar(options: ProgressBarOptions): ProgressBar {
243
+ return new ProgressBar(options);
244
+ }
245
+
246
+ /**
247
+ * Task list with progress
248
+ */
249
+ export interface TaskOptions {
250
+ text: string;
251
+ task: () => Promise<void>;
252
+ }
253
+
254
+ export async function runTasks(tasks: TaskOptions[]): Promise<void> {
255
+ for (const task of tasks) {
256
+ const s = spinner(task.text);
257
+ try {
258
+ await task.task();
259
+ s.success();
260
+ } catch (error) {
261
+ s.error();
262
+ throw error;
263
+ }
264
+ }
265
+ }