cli-forge 1.2.3 → 1.3.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.
@@ -0,0 +1,376 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { PromptOption } from '../lib/prompt-types';
3
+
4
+ // Mock the dynamic import of @clack/prompts
5
+ const mockClack = {
6
+ text: vi.fn(),
7
+ confirm: vi.fn(),
8
+ select: vi.fn(),
9
+ multiselect: vi.fn(),
10
+ cancel: vi.fn(),
11
+ isCancel: vi.fn().mockReturnValue(false),
12
+ };
13
+
14
+ vi.mock('@clack/prompts', () => mockClack);
15
+
16
+ // Import after mock is set up
17
+ import { createClackPromptProvider } from './clack';
18
+
19
+ function makeOption(
20
+ overrides: Partial<PromptOption['config']> & { name: string }
21
+ ): PromptOption {
22
+ const { name, ...config } = overrides;
23
+ return {
24
+ name,
25
+ config: { type: 'string', key: name, ...config } as PromptOption['config'],
26
+ };
27
+ }
28
+
29
+ describe('createClackPromptProvider', () => {
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ mockClack.isCancel.mockReturnValue(false);
33
+ });
34
+
35
+ describe('type mapping', () => {
36
+ it('should use confirm() for boolean options', async () => {
37
+ mockClack.confirm.mockResolvedValue(true);
38
+ const provider = createClackPromptProvider();
39
+
40
+ const result = await provider.promptBatch!([
41
+ makeOption({ name: 'verbose', type: 'boolean' }),
42
+ ]);
43
+
44
+ expect(mockClack.confirm).toHaveBeenCalledWith(
45
+ expect.objectContaining({ message: 'verbose' })
46
+ );
47
+ expect(result).toEqual({ verbose: true });
48
+ });
49
+
50
+ it('should pass initialValue for boolean options with defaults', async () => {
51
+ mockClack.confirm.mockResolvedValue(false);
52
+ const provider = createClackPromptProvider();
53
+
54
+ await provider.promptBatch!([
55
+ makeOption({ name: 'verbose', type: 'boolean', default: true }),
56
+ ]);
57
+
58
+ expect(mockClack.confirm).toHaveBeenCalledWith(
59
+ expect.objectContaining({ initialValue: true })
60
+ );
61
+ });
62
+
63
+ it('should use text() for string options without choices', async () => {
64
+ mockClack.text.mockResolvedValue('hello');
65
+ const provider = createClackPromptProvider();
66
+
67
+ const result = await provider.promptBatch!([
68
+ makeOption({ name: 'name', type: 'string' }),
69
+ ]);
70
+
71
+ expect(mockClack.text).toHaveBeenCalledWith(
72
+ expect.objectContaining({ message: 'name' })
73
+ );
74
+ expect(result).toEqual({ name: 'hello' });
75
+ });
76
+
77
+ it('should use select() for string options with choices', async () => {
78
+ mockClack.select.mockResolvedValue('b');
79
+ const provider = createClackPromptProvider();
80
+
81
+ await provider.promptBatch!([
82
+ makeOption({
83
+ name: 'color',
84
+ type: 'string',
85
+ choices: ['a', 'b', 'c'],
86
+ } as any),
87
+ ]);
88
+
89
+ expect(mockClack.select).toHaveBeenCalledWith(
90
+ expect.objectContaining({
91
+ message: 'color',
92
+ options: [
93
+ { value: 'a', label: 'a' },
94
+ { value: 'b', label: 'b' },
95
+ { value: 'c', label: 'c' },
96
+ ],
97
+ })
98
+ );
99
+ });
100
+
101
+ it('should use multiselect() for array options with choices', async () => {
102
+ mockClack.multiselect.mockResolvedValue(['x', 'z']);
103
+ const provider = createClackPromptProvider();
104
+
105
+ const result = await provider.promptBatch!([
106
+ makeOption({
107
+ name: 'tags',
108
+ type: 'array',
109
+ choices: ['x', 'y', 'z'],
110
+ } as any),
111
+ ]);
112
+
113
+ expect(mockClack.multiselect).toHaveBeenCalledWith(
114
+ expect.objectContaining({
115
+ message: 'tags',
116
+ options: [
117
+ { value: 'x', label: 'x' },
118
+ { value: 'y', label: 'y' },
119
+ { value: 'z', label: 'z' },
120
+ ],
121
+ })
122
+ );
123
+ expect(result).toEqual({ tags: ['x', 'z'] });
124
+ });
125
+
126
+ it('should use text() for number options and coerce to Number', async () => {
127
+ mockClack.text.mockResolvedValue('42');
128
+ const provider = createClackPromptProvider();
129
+
130
+ const result = await provider.promptBatch!([
131
+ makeOption({ name: 'port', type: 'number' }),
132
+ ]);
133
+
134
+ expect(mockClack.text).toHaveBeenCalledWith(
135
+ expect.objectContaining({ message: 'port' })
136
+ );
137
+ expect(result).toEqual({ port: 42 });
138
+ });
139
+
140
+ it('should use default for number options when input is empty', async () => {
141
+ mockClack.text.mockResolvedValue('');
142
+ const provider = createClackPromptProvider();
143
+
144
+ const result = await provider.promptBatch!([
145
+ makeOption({ name: 'port', type: 'number', default: 3000 }),
146
+ ]);
147
+
148
+ expect(result).toEqual({ port: 3000 });
149
+ });
150
+
151
+ it('should validate number input rejects non-numeric values', async () => {
152
+ mockClack.text.mockResolvedValue('42');
153
+ const provider = createClackPromptProvider();
154
+
155
+ await provider.promptBatch!([
156
+ makeOption({ name: 'port', type: 'number' }),
157
+ ]);
158
+
159
+ // Extract the validate function that was passed to clack.text
160
+ const callArgs = mockClack.text.mock.calls[0][0];
161
+ expect(callArgs.validate('abc')).toBe('Please enter a valid number');
162
+ expect(callArgs.validate('42')).toBeUndefined();
163
+ expect(callArgs.validate('')).toBeUndefined();
164
+ });
165
+ });
166
+
167
+ describe('label resolution', () => {
168
+ it('should use prompt string as label when available', async () => {
169
+ mockClack.text.mockResolvedValue('val');
170
+ const provider = createClackPromptProvider();
171
+
172
+ await provider.promptBatch!([
173
+ makeOption({
174
+ name: 'token',
175
+ type: 'string',
176
+ description: 'API token',
177
+ prompt: 'Enter your API token',
178
+ }),
179
+ ]);
180
+
181
+ expect(mockClack.text).toHaveBeenCalledWith(
182
+ expect.objectContaining({ message: 'Enter your API token' })
183
+ );
184
+ });
185
+
186
+ it('should use description as label when prompt is boolean', async () => {
187
+ mockClack.text.mockResolvedValue('val');
188
+ const provider = createClackPromptProvider();
189
+
190
+ await provider.promptBatch!([
191
+ makeOption({
192
+ name: 'token',
193
+ type: 'string',
194
+ description: 'API token',
195
+ prompt: true,
196
+ }),
197
+ ]);
198
+
199
+ expect(mockClack.text).toHaveBeenCalledWith(
200
+ expect.objectContaining({ message: 'API token' })
201
+ );
202
+ });
203
+
204
+ it('should fall back to option name when no description or prompt string', async () => {
205
+ mockClack.text.mockResolvedValue('val');
206
+ const provider = createClackPromptProvider();
207
+
208
+ await provider.promptBatch!([
209
+ makeOption({ name: 'token', type: 'string' }),
210
+ ]);
211
+
212
+ expect(mockClack.text).toHaveBeenCalledWith(
213
+ expect.objectContaining({ message: 'token' })
214
+ );
215
+ });
216
+ });
217
+
218
+ describe('default value extraction', () => {
219
+ it('should pass primitive defaults as placeholder and defaultValue', async () => {
220
+ mockClack.text.mockResolvedValue('override');
221
+ const provider = createClackPromptProvider();
222
+
223
+ await provider.promptBatch!([
224
+ makeOption({ name: 'host', type: 'string', default: 'localhost' }),
225
+ ]);
226
+
227
+ expect(mockClack.text).toHaveBeenCalledWith(
228
+ expect.objectContaining({
229
+ placeholder: 'localhost',
230
+ defaultValue: 'localhost',
231
+ })
232
+ );
233
+ });
234
+
235
+ it('should extract value from { value, description } defaults', async () => {
236
+ mockClack.text.mockResolvedValue('override');
237
+ const provider = createClackPromptProvider();
238
+
239
+ await provider.promptBatch!([
240
+ makeOption({
241
+ name: 'host',
242
+ type: 'string',
243
+ default: { value: 'localhost', description: 'The default host' },
244
+ }),
245
+ ]);
246
+
247
+ expect(mockClack.text).toHaveBeenCalledWith(
248
+ expect.objectContaining({
249
+ placeholder: 'localhost',
250
+ defaultValue: 'localhost',
251
+ })
252
+ );
253
+ });
254
+
255
+ it('should call factory from { factory, description } defaults', async () => {
256
+ const factory = vi.fn().mockReturnValue('from-factory');
257
+ mockClack.text.mockResolvedValue('override');
258
+ const provider = createClackPromptProvider();
259
+
260
+ await provider.promptBatch!([
261
+ makeOption({
262
+ name: 'host',
263
+ type: 'string',
264
+ default: { factory, description: 'computed host' },
265
+ }),
266
+ ]);
267
+
268
+ expect(factory).toHaveBeenCalledOnce();
269
+ expect(mockClack.text).toHaveBeenCalledWith(
270
+ expect.objectContaining({
271
+ placeholder: 'from-factory',
272
+ defaultValue: 'from-factory',
273
+ })
274
+ );
275
+ });
276
+
277
+ it('should not pass placeholder/defaultValue when no default', async () => {
278
+ mockClack.text.mockResolvedValue('val');
279
+ const provider = createClackPromptProvider();
280
+
281
+ await provider.promptBatch!([
282
+ makeOption({ name: 'host', type: 'string' }),
283
+ ]);
284
+
285
+ expect(mockClack.text).toHaveBeenCalledWith(
286
+ expect.objectContaining({
287
+ placeholder: undefined,
288
+ defaultValue: undefined,
289
+ })
290
+ );
291
+ });
292
+ });
293
+
294
+ describe('cancellation handling', () => {
295
+ it('should throw when user cancels a text prompt', async () => {
296
+ const cancelSymbol = Symbol('cancel');
297
+ mockClack.text.mockResolvedValue(cancelSymbol);
298
+ mockClack.isCancel.mockReturnValue(true);
299
+ const provider = createClackPromptProvider();
300
+
301
+ await expect(
302
+ provider.promptBatch!([
303
+ makeOption({ name: 'name', type: 'string' }),
304
+ ])
305
+ ).rejects.toThrow('Prompt cancelled by user');
306
+
307
+ expect(mockClack.cancel).toHaveBeenCalledWith('Operation cancelled.');
308
+ });
309
+
310
+ it('should throw when user cancels a number prompt (raw cancel)', async () => {
311
+ const cancelSymbol = Symbol('cancel');
312
+ mockClack.text.mockResolvedValue(cancelSymbol);
313
+ mockClack.isCancel.mockReturnValue(true);
314
+ const provider = createClackPromptProvider();
315
+
316
+ await expect(
317
+ provider.promptBatch!([
318
+ makeOption({ name: 'port', type: 'number' }),
319
+ ])
320
+ ).rejects.toThrow('Prompt cancelled by user');
321
+ });
322
+
323
+ it('should throw when user cancels a confirm prompt', async () => {
324
+ const cancelSymbol = Symbol('cancel');
325
+ mockClack.confirm.mockResolvedValue(cancelSymbol);
326
+ mockClack.isCancel.mockReturnValue(true);
327
+ const provider = createClackPromptProvider();
328
+
329
+ await expect(
330
+ provider.promptBatch!([
331
+ makeOption({ name: 'verbose', type: 'boolean' }),
332
+ ])
333
+ ).rejects.toThrow('Prompt cancelled by user');
334
+ });
335
+ });
336
+
337
+ describe('batch behavior', () => {
338
+ it('should prompt for multiple options in order', async () => {
339
+ mockClack.text
340
+ .mockResolvedValueOnce('Alice')
341
+ .mockResolvedValueOnce('42');
342
+ const provider = createClackPromptProvider();
343
+
344
+ const result = await provider.promptBatch!([
345
+ makeOption({ name: 'name', type: 'string' }),
346
+ makeOption({ name: 'port', type: 'number' }),
347
+ ]);
348
+
349
+ expect(mockClack.text).toHaveBeenCalledTimes(2);
350
+ expect(result).toEqual({ name: 'Alice', port: 42 });
351
+ });
352
+
353
+ it('should handle mixed option types in a single batch', async () => {
354
+ mockClack.text.mockResolvedValueOnce('hello');
355
+ mockClack.confirm.mockResolvedValueOnce(true);
356
+ mockClack.select.mockResolvedValueOnce('prod');
357
+ const provider = createClackPromptProvider();
358
+
359
+ const result = await provider.promptBatch!([
360
+ makeOption({ name: 'name', type: 'string' }),
361
+ makeOption({ name: 'verbose', type: 'boolean' }),
362
+ makeOption({
363
+ name: 'env',
364
+ type: 'string',
365
+ choices: ['dev', 'prod'],
366
+ } as any),
367
+ ]);
368
+
369
+ expect(result).toEqual({
370
+ name: 'hello',
371
+ verbose: true,
372
+ env: 'prod',
373
+ });
374
+ });
375
+ });
376
+ });
@@ -0,0 +1,169 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ import type { PromptOption, PromptProvider } from '../lib/prompt-types';
3
+
4
+ /**
5
+ * Options for {@link createClackPromptProvider}.
6
+ */
7
+ export interface ClackPromptProviderOptions {
8
+ /**
9
+ * Custom input stream to read from instead of `process.stdin`.
10
+ * Useful for testing — pass a `Readable` with `isTTY = true` and
11
+ * a no-op `setRawMode` to emulate a terminal.
12
+ */
13
+ input?: Readable;
14
+ /**
15
+ * Custom output stream to write to instead of `process.stdout`.
16
+ */
17
+ output?: Writable;
18
+ }
19
+
20
+ /**
21
+ * Creates a prompt provider backed by @clack/prompts.
22
+ * Requires `@clack/prompts` as a peer dependency.
23
+ *
24
+ * The provider uses dynamic imports so that `@clack/prompts` is only
25
+ * loaded when prompting actually occurs.
26
+ */
27
+ export function createClackPromptProvider(
28
+ providerOptions?: ClackPromptProviderOptions
29
+ ): PromptProvider {
30
+ // Build the common stream options once; spread into every prompt call.
31
+ const streamOpts: { input?: Readable; output?: Writable } = {};
32
+ if (providerOptions?.input) streamOpts.input = providerOptions.input;
33
+ if (providerOptions?.output) streamOpts.output = providerOptions.output;
34
+
35
+ return {
36
+ async promptBatch(
37
+ options: PromptOption[]
38
+ ): Promise<Record<string, unknown>> {
39
+ const clack = await import('@clack/prompts');
40
+
41
+ const results: Record<string, unknown> = {};
42
+
43
+ for (const option of options) {
44
+ const message = getLabel(option);
45
+ const defaultValue = getDefault(option.config);
46
+
47
+ let value: unknown;
48
+
49
+ if (option.config.type === 'boolean') {
50
+ value = await clack.confirm({
51
+ ...streamOpts,
52
+ message,
53
+ initialValue:
54
+ typeof defaultValue === 'boolean' ? defaultValue : undefined,
55
+ });
56
+ } else if (hasChoices(option.config)) {
57
+ const choices = getChoices(option.config);
58
+ if (option.config.type === 'array') {
59
+ value = await clack.multiselect({
60
+ ...streamOpts,
61
+ message,
62
+ options: choices.map((c) => ({ value: c, label: String(c) })),
63
+ });
64
+ } else {
65
+ value = await clack.select({
66
+ ...streamOpts,
67
+ message,
68
+ options: choices.map((c) => ({ value: c, label: String(c) })),
69
+ });
70
+ }
71
+ } else if (option.config.type === 'number') {
72
+ const raw = await clack.text({
73
+ ...streamOpts,
74
+ message,
75
+ placeholder:
76
+ defaultValue !== undefined ? String(defaultValue) : undefined,
77
+ defaultValue:
78
+ defaultValue !== undefined ? String(defaultValue) : undefined,
79
+ validate: (val: string | undefined) => {
80
+ if (val && isNaN(Number(val))) {
81
+ return 'Please enter a valid number';
82
+ }
83
+ return undefined;
84
+ },
85
+ });
86
+
87
+ if (clack.isCancel(raw)) {
88
+ clack.cancel('Operation cancelled.');
89
+ throw new Error('Prompt cancelled by user');
90
+ }
91
+
92
+ value = raw !== undefined && raw !== '' ? Number(raw) : defaultValue;
93
+ } else {
94
+ // string, array without choices
95
+ value = await clack.text({
96
+ ...streamOpts,
97
+ message,
98
+ placeholder:
99
+ defaultValue !== undefined ? String(defaultValue) : undefined,
100
+ defaultValue:
101
+ defaultValue !== undefined ? String(defaultValue) : undefined,
102
+ });
103
+ }
104
+
105
+ // clack returns a Symbol when the user cancels (Ctrl+C)
106
+ if (clack.isCancel(value)) {
107
+ clack.cancel('Operation cancelled.');
108
+ throw new Error('Prompt cancelled by user');
109
+ }
110
+
111
+ results[option.name] = value;
112
+ }
113
+
114
+ return results;
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Default clack prompt provider, uses stdin/stdout
121
+ */
122
+ export const ClackPromptProvider = createClackPromptProvider();
123
+
124
+ /**
125
+ * Determine the label to show for a prompt option.
126
+ * Priority: prompt string > description > option name.
127
+ */
128
+ function getLabel(option: PromptOption): string {
129
+ if (typeof option.config.prompt === 'string') {
130
+ return option.config.prompt;
131
+ }
132
+ return option.config.description ?? option.name;
133
+ }
134
+
135
+ /**
136
+ * Extract the default value from an option config.
137
+ * Handles the three forms:
138
+ * - Primitive value directly
139
+ * - `{ value: T; description: string }` object
140
+ * - `{ factory: () => T; description: string }` object
141
+ */
142
+ function getDefault(config: PromptOption['config']): unknown {
143
+ if (config.default === undefined) return undefined;
144
+ if (typeof config.default === 'object' && config.default !== null) {
145
+ if ('factory' in config.default) {
146
+ return (config.default as { factory: () => unknown }).factory();
147
+ }
148
+ if ('value' in config.default) {
149
+ return (config.default as { value: unknown }).value;
150
+ }
151
+ }
152
+ return config.default;
153
+ }
154
+
155
+ function hasChoices(config: PromptOption['config']): boolean {
156
+ return (
157
+ 'choices' in config &&
158
+ (config as Record<string, unknown>)['choices'] !== undefined
159
+ );
160
+ }
161
+
162
+ function getChoices(config: PromptOption['config']): unknown[] {
163
+ const cfg = config as Record<string, unknown>;
164
+ const choices = cfg['choices'];
165
+ if (typeof choices === 'function') {
166
+ return choices();
167
+ }
168
+ return (choices as unknown[]) ?? [];
169
+ }