cli-forge 1.2.3 → 1.4.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/dist/bin/cli.d.ts +1 -1
- package/dist/bin/commands/generate-documentation.d.ts +2 -2
- package/dist/bin/commands/generate-documentation.js +36 -6
- package/dist/bin/commands/generate-documentation.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/configuration-providers.d.ts +3 -2
- package/dist/lib/configuration-providers.js +38 -2
- package/dist/lib/configuration-providers.js.map +1 -1
- package/dist/lib/documentation.d.ts +6 -1
- package/dist/lib/documentation.js +4 -0
- package/dist/lib/documentation.js.map +1 -1
- package/dist/lib/format-help.js +9 -0
- package/dist/lib/format-help.js.map +1 -1
- package/dist/lib/internal-cli.d.ts +14 -2
- package/dist/lib/internal-cli.js +61 -3
- package/dist/lib/internal-cli.js.map +1 -1
- package/dist/lib/prompt-types.d.ts +44 -0
- package/dist/lib/prompt-types.js +3 -0
- package/dist/lib/prompt-types.js.map +1 -0
- package/dist/lib/public-api.d.ts +45 -12
- package/dist/lib/public-api.js.map +1 -1
- package/dist/lib/resolve-prompts.d.ts +13 -0
- package/dist/lib/resolve-prompts.js +121 -0
- package/dist/lib/resolve-prompts.js.map +1 -0
- package/dist/prompt-providers/clack.d.ts +29 -0
- package/dist/prompt-providers/clack.js +136 -0
- package/dist/prompt-providers/clack.js.map +1 -0
- package/package.json +11 -2
- package/src/bin/commands/generate-documentation.ts +70 -9
- package/src/index.ts +1 -0
- package/src/lib/composable-builder.ts +3 -3
- package/src/lib/configuration-providers.ts +53 -4
- package/src/lib/documentation.ts +11 -0
- package/src/lib/format-help.ts +10 -0
- package/src/lib/internal-cli.spec.ts +300 -0
- package/src/lib/internal-cli.ts +80 -7
- package/src/lib/prompt-types.ts +48 -0
- package/src/lib/public-api.ts +31 -19
- package/src/lib/resolve-prompts.spec.ts +311 -0
- package/src/lib/resolve-prompts.ts +156 -0
- package/src/prompt-providers/clack.spec.ts +376 -0
- package/src/prompt-providers/clack.ts +169 -0
- package/tsconfig.lib.json.tsbuildinfo +1 -1
- package/typedoc.json +10 -0
- package/.eslintrc.json +0 -36
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { InternalOptionConfig } from '@cli-forge/parser';
|
|
2
|
+
import type {
|
|
3
|
+
PromptConfig,
|
|
4
|
+
PromptOption,
|
|
5
|
+
PromptOptionConfig,
|
|
6
|
+
PromptProvider,
|
|
7
|
+
} from './prompt-types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Collects options that need prompting, matches them to providers,
|
|
11
|
+
* executes prompts, and returns the prompted values.
|
|
12
|
+
*/
|
|
13
|
+
export async function resolvePrompts(opts: {
|
|
14
|
+
configuredOptions: Record<string, InternalOptionConfig>;
|
|
15
|
+
configuredImplies: Record<string, Set<string>>;
|
|
16
|
+
promptConfigs: Map<string, PromptOptionConfig<any>>;
|
|
17
|
+
providers: PromptProvider[];
|
|
18
|
+
currentArgs: Record<string, unknown>;
|
|
19
|
+
}): Promise<Record<string, unknown>> {
|
|
20
|
+
const {
|
|
21
|
+
configuredOptions,
|
|
22
|
+
configuredImplies,
|
|
23
|
+
promptConfigs,
|
|
24
|
+
providers,
|
|
25
|
+
currentArgs,
|
|
26
|
+
} = opts;
|
|
27
|
+
|
|
28
|
+
// Step 1: Collect promptable options
|
|
29
|
+
const promptableOptions: PromptOption[] = [];
|
|
30
|
+
|
|
31
|
+
for (const [name, config] of Object.entries(configuredOptions)) {
|
|
32
|
+
// Skip internal options
|
|
33
|
+
if (
|
|
34
|
+
name === 'help' ||
|
|
35
|
+
name === 'version' ||
|
|
36
|
+
name === 'unmatched' ||
|
|
37
|
+
name === '--'
|
|
38
|
+
) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Already has a value — skip unless prompt is explicitly true/string
|
|
43
|
+
const hasValue = currentArgs[name] !== undefined;
|
|
44
|
+
|
|
45
|
+
const promptSetting = promptConfigs.get(name);
|
|
46
|
+
let resolved: PromptConfig | null | undefined;
|
|
47
|
+
|
|
48
|
+
if (typeof promptSetting === 'function') {
|
|
49
|
+
resolved = promptSetting(currentArgs);
|
|
50
|
+
// Callback: null/undefined treated as false
|
|
51
|
+
if (resolved === null || resolved === undefined) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
} else if (promptSetting !== undefined) {
|
|
55
|
+
// Static value
|
|
56
|
+
resolved = promptSetting;
|
|
57
|
+
} else {
|
|
58
|
+
// Not specified: prompt only if required and missing value
|
|
59
|
+
if (hasValue) continue;
|
|
60
|
+
|
|
61
|
+
const isRequired = config.required === true;
|
|
62
|
+
const isImplied = isOptionImplied(name, configuredImplies, currentArgs);
|
|
63
|
+
|
|
64
|
+
if (!isRequired && !isImplied) continue;
|
|
65
|
+
if (providers.length === 0) continue; // No providers, let validation handle it
|
|
66
|
+
|
|
67
|
+
resolved = true; // Will prompt
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (resolved === false) continue;
|
|
71
|
+
if (hasValue && resolved !== true && typeof resolved !== 'string') continue;
|
|
72
|
+
|
|
73
|
+
promptableOptions.push({
|
|
74
|
+
name,
|
|
75
|
+
config: {
|
|
76
|
+
...config,
|
|
77
|
+
prompt: resolved === true ? true : resolved ?? undefined,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (promptableOptions.length === 0) {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Step 2: Match options to providers
|
|
87
|
+
const filteredProviders = providers.filter((p) => p.filter);
|
|
88
|
+
const fallbackProviders = providers.filter((p) => !p.filter);
|
|
89
|
+
|
|
90
|
+
const providerGroups = new Map<PromptProvider, PromptOption[]>();
|
|
91
|
+
const unmatchedOptions: PromptOption[] = [];
|
|
92
|
+
|
|
93
|
+
for (const option of promptableOptions) {
|
|
94
|
+
let matched = false;
|
|
95
|
+
for (const provider of filteredProviders) {
|
|
96
|
+
if (provider.filter!(option.name, option.config)) {
|
|
97
|
+
if (!providerGroups.has(provider)) {
|
|
98
|
+
providerGroups.set(provider, []);
|
|
99
|
+
}
|
|
100
|
+
providerGroups.get(provider)!.push(option);
|
|
101
|
+
matched = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!matched) {
|
|
106
|
+
unmatchedOptions.push(option);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Assign unmatched options to first fallback provider
|
|
111
|
+
if (unmatchedOptions.length > 0) {
|
|
112
|
+
if (fallbackProviders.length === 0) {
|
|
113
|
+
const names = unmatchedOptions.map((o) => `'${o.name}'`).join(', ');
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Option(s) ${names} require prompting but no prompt provider is available`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const fallback = fallbackProviders[0];
|
|
119
|
+
if (!providerGroups.has(fallback)) {
|
|
120
|
+
providerGroups.set(fallback, []);
|
|
121
|
+
}
|
|
122
|
+
providerGroups.get(fallback)!.push(...unmatchedOptions);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Step 3: Execute prompts
|
|
126
|
+
const results: Record<string, unknown> = {};
|
|
127
|
+
|
|
128
|
+
for (const [provider, options] of providerGroups) {
|
|
129
|
+
if (provider.promptBatch) {
|
|
130
|
+
const batchResults = await provider.promptBatch(options);
|
|
131
|
+
Object.assign(results, batchResults);
|
|
132
|
+
} else if (provider.prompt) {
|
|
133
|
+
for (const option of options) {
|
|
134
|
+
results[option.name] = await provider.prompt(option);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if an option is implied by another option that has been set.
|
|
144
|
+
*/
|
|
145
|
+
function isOptionImplied(
|
|
146
|
+
name: string,
|
|
147
|
+
configuredImplies: Record<string, Set<string>>,
|
|
148
|
+
currentArgs: Record<string, unknown>
|
|
149
|
+
): boolean {
|
|
150
|
+
for (const [trigger, implied] of Object.entries(configuredImplies)) {
|
|
151
|
+
if (implied.has(name) && currentArgs[trigger] !== undefined) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
@@ -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
|
+
}
|