clap-ts 0.3.0 → 0.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.
Files changed (43) hide show
  1. package/dist/parser.js +5 -5
  2. package/dist/types.d.ts +14 -1
  3. package/package.json +17 -1
  4. package/src/__tests__/arg-options.test.ts +687 -0
  5. package/src/__tests__/argfile.test.ts +127 -0
  6. package/src/__tests__/clap-parity.test.ts +682 -0
  7. package/src/__tests__/command-options.test.ts +713 -0
  8. package/src/__tests__/completions.test.ts +423 -0
  9. package/src/__tests__/config.test.ts +261 -0
  10. package/src/__tests__/deprecation.test.ts +104 -0
  11. package/src/__tests__/help.test.ts +312 -0
  12. package/src/__tests__/install.test.ts +120 -0
  13. package/src/__tests__/log.test.ts +189 -0
  14. package/src/__tests__/man.test.ts +135 -0
  15. package/src/__tests__/markdown.test.ts +114 -0
  16. package/src/__tests__/output.test.ts +249 -0
  17. package/src/__tests__/parser.test.ts +627 -0
  18. package/src/__tests__/plugins.test.ts +182 -0
  19. package/src/__tests__/progress.test.ts +221 -0
  20. package/src/__tests__/prompt.test.ts +265 -0
  21. package/src/__tests__/runner.test.ts +459 -0
  22. package/src/__tests__/spec.test.ts +107 -0
  23. package/src/__tests__/testing.test.ts +93 -0
  24. package/src/__tests__/validation.test.ts +267 -0
  25. package/src/argfile.ts +188 -0
  26. package/src/completions.ts +865 -0
  27. package/src/config.ts +184 -0
  28. package/src/help.ts +779 -0
  29. package/src/index.ts +58 -0
  30. package/src/install.ts +226 -0
  31. package/src/log.ts +225 -0
  32. package/src/man.ts +289 -0
  33. package/src/markdown.ts +210 -0
  34. package/src/output.ts +453 -0
  35. package/src/parser.ts +1240 -0
  36. package/src/plugins.ts +193 -0
  37. package/src/progress.ts +295 -0
  38. package/src/prompt.ts +388 -0
  39. package/src/runner.ts +769 -0
  40. package/src/spec.ts +197 -0
  41. package/src/testing.ts +159 -0
  42. package/src/types.ts +618 -0
  43. package/src/validation.ts +627 -0
package/src/prompt.ts ADDED
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Interactive prompts, derived from the argument definitions a command already
3
+ * has.
4
+ *
5
+ * ```ts
6
+ * import { promptMissing } from 'clap-ts/prompt';
7
+ *
8
+ * await runMain(main, { fillMissing: promptMissing() });
9
+ * ```
10
+ *
11
+ * A required argument nobody supplied is asked for rather than rejected: a
12
+ * boolean becomes a confirm, an argument with possible values becomes a
13
+ * numbered list, and one marked `secret` is read without echoing. `ctx
14
+ * .valueSources` reports those as `'prompt'`.
15
+ *
16
+ * Nothing prompts when stdin is not a terminal, so a script or a CI job still
17
+ * fails fast with the usual error instead of hanging on input that will never
18
+ * arrive.
19
+ */
20
+
21
+ import { createInterface } from 'node:readline/promises';
22
+ import { styleText } from 'node:util';
23
+ import type { ArgDef, CommandDef, MissingArg, PossibleValue } from './types.js';
24
+ import { possibleValues } from './parser.js';
25
+
26
+ /** Somewhere to ask questions. The default talks to the terminal. */
27
+ export interface PromptIO {
28
+ /** Show `text` and resolve with the reply. */
29
+ question(text: string): Promise<string>;
30
+ /** As `question`, but the reply is not echoed. */
31
+ secret(text: string): Promise<string>;
32
+ /** Write to the prompt's output, for lists and errors. */
33
+ write(text: string): void;
34
+ /** Release the terminal. */
35
+ close(): void;
36
+ /** Whether anyone is there to answer. */
37
+ readonly interactive: boolean;
38
+ }
39
+
40
+ let sharedIO: PromptIO | undefined;
41
+
42
+ /**
43
+ * The PromptIO backed by the real terminal.
44
+ *
45
+ * Shared, because a readline interface takes ownership of stdin: a second one
46
+ * finds the stream already consumed, so two prompts in a row would fail with
47
+ * the input reported as ended. `close()` releases it, and the next call builds
48
+ * a fresh one.
49
+ */
50
+ export function terminalIO(): PromptIO {
51
+ sharedIO ??= createTerminalIO();
52
+ return sharedIO;
53
+ }
54
+
55
+ function createTerminalIO(): PromptIO {
56
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
57
+ let rl: ReturnType<typeof createInterface> | undefined;
58
+ let lines: AsyncIterator<string> | undefined;
59
+
60
+ const iface = (): ReturnType<typeof createInterface> => {
61
+ // terminal: true against a pipe makes readline echo the whole buffer and
62
+ // reorder it, so this follows whatever stdin actually is.
63
+ rl ??= createInterface({
64
+ input: process.stdin,
65
+ output: process.stdout,
66
+ terminal: interactive,
67
+ });
68
+ return rl;
69
+ };
70
+
71
+ /**
72
+ * Pull one line.
73
+ *
74
+ * readline consumes a pipe greedily, so a second `question()` would find the
75
+ * stream already drained and never resolve. Reading through the async
76
+ * iterator keeps the lines queued and hands them out one at a time.
77
+ */
78
+ const nextLine = async (): Promise<string> => {
79
+ lines ??= iface()[Symbol.asyncIterator]();
80
+ const { value, done } = await lines.next();
81
+ if (done === true) {
82
+ throw new Error('input ended while waiting for an answer');
83
+ }
84
+ return value;
85
+ };
86
+
87
+ /**
88
+ * readline decides what to echo through `_writeToOutput`, so overriding that
89
+ * for one question masks the reply without touching process.stdout. Swapping
90
+ * the stream's own write instead breaks the interface: the next read reports
91
+ * the input as ended.
92
+ */
93
+ interface Maskable {
94
+ _writeToOutput?: (chunk: string) => void;
95
+ output?: { write(chunk: string): void };
96
+ }
97
+
98
+ const ask = async (text: string, mask: boolean): Promise<string> => {
99
+ const active = iface();
100
+ process.stdout.write(text);
101
+ if (!mask) {
102
+ return nextLine();
103
+ }
104
+
105
+ const hooked = active as unknown as Maskable;
106
+ const original = hooked._writeToOutput;
107
+ // Echo a bullet per printable character, and nothing for control keys.
108
+ hooked._writeToOutput = (chunk: string) => {
109
+ if (chunk.length === 1 && chunk >= ' ') {
110
+ hooked.output?.write('*');
111
+ }
112
+ };
113
+ try {
114
+ return await nextLine();
115
+ } finally {
116
+ if (original === undefined) {
117
+ delete hooked._writeToOutput;
118
+ } else {
119
+ hooked._writeToOutput = original;
120
+ }
121
+ process.stdout.write('\n');
122
+ }
123
+ };
124
+
125
+ return {
126
+ interactive,
127
+ question: (text) => ask(text, false),
128
+ secret: (text) => ask(text, true),
129
+ write: (text) => {
130
+ process.stdout.write(text);
131
+ },
132
+ close: () => {
133
+ rl?.close();
134
+ rl = undefined;
135
+ lines = undefined;
136
+ sharedIO = undefined;
137
+ },
138
+ };
139
+ }
140
+
141
+ /** A PromptIO that answers from a list, for tests. */
142
+ export function scriptedIO(answers: readonly string[]): PromptIO & { output: string } {
143
+ let index = 0;
144
+ const io = {
145
+ interactive: true,
146
+ output: '',
147
+ question: (text: string) => {
148
+ io.output += text;
149
+ const answer = answers[index++] ?? '';
150
+ io.output += `${answer}\n`;
151
+ return Promise.resolve(answer);
152
+ },
153
+ secret: (text: string) => io.question(text),
154
+ write: (text: string) => {
155
+ io.output += text;
156
+ },
157
+ close: () => {},
158
+ };
159
+ return io;
160
+ }
161
+
162
+ function paint(text: string, codes: Parameters<typeof styleText>[0]): string {
163
+ return styleText('red', 'x') === 'x' ? text : styleText(codes, text);
164
+ }
165
+
166
+ /** Common to every prompt. */
167
+ export interface AskOptions {
168
+ /** Where to ask (default: the terminal). */
169
+ readonly io?: PromptIO;
170
+ }
171
+
172
+ /** A prompt that can offer a text default when the reply is empty. */
173
+ export interface TextAskOptions extends AskOptions {
174
+ readonly defaultValue?: string;
175
+ }
176
+
177
+ /** A confirm, whose default is a boolean rather than text. */
178
+ export interface ConfirmOptions extends AskOptions {
179
+ readonly defaultValue?: boolean;
180
+ }
181
+
182
+ /** Ask for a line of text, returning the default when the reply is empty. */
183
+ export async function input(message: string, opts?: TextAskOptions): Promise<string> {
184
+ const io = opts?.io ?? terminalIO();
185
+ const hint = opts?.defaultValue === undefined ? '' : ` (${opts.defaultValue})`;
186
+ const answer = (await io.question(`${paint('?', 'green')} ${message}${hint}: `)).trim();
187
+ return answer.length > 0 ? answer : (opts?.defaultValue ?? '');
188
+ }
189
+
190
+ /** Ask for a secret, without echoing it. */
191
+ export async function password(message: string, opts?: AskOptions): Promise<string> {
192
+ const io = opts?.io ?? terminalIO();
193
+ return (await io.secret(`${paint('?', 'green')} ${message}: `)).trim();
194
+ }
195
+
196
+ /** Ask a yes or no question. */
197
+ export async function confirm(message: string, opts?: ConfirmOptions): Promise<boolean> {
198
+ const io = opts?.io ?? terminalIO();
199
+ const fallback = opts?.defaultValue ?? false;
200
+ const hint = fallback ? 'Y/n' : 'y/N';
201
+ const answer = (await io.question(`${paint('?', 'green')} ${message} (${hint}): `))
202
+ .trim()
203
+ .toLowerCase();
204
+ if (answer.length === 0) {
205
+ return fallback;
206
+ }
207
+ return answer === 'y' || answer === 'yes' || answer === 'true' || answer === '1';
208
+ }
209
+
210
+ /**
211
+ * Offer a numbered list and take an index or the value itself.
212
+ *
213
+ * A numbered list rather than an arrow-key menu, so it behaves the same over
214
+ * ssh, in a dumb terminal and under a test.
215
+ */
216
+ export async function select(
217
+ message: string,
218
+ choices: readonly (string | PossibleValue)[],
219
+ opts?: TextAskOptions,
220
+ ): Promise<string> {
221
+ const io = opts?.io ?? terminalIO();
222
+ const values = choices.map((c) => (typeof c === 'string' ? { name: c } : c));
223
+ if (values.length === 0) {
224
+ throw new Error('select needs at least one choice');
225
+ }
226
+
227
+ for (;;) {
228
+ io.write(`${paint('?', 'green')} ${message}\n`);
229
+ values.forEach((choice, i) => {
230
+ const help = choice.help === undefined ? '' : ` ${choice.help}`;
231
+ io.write(` ${String(i + 1)}) ${choice.name}${help}\n`);
232
+ });
233
+
234
+ const hint = opts?.defaultValue === undefined ? '' : ` (${opts.defaultValue})`;
235
+ const answer = (await io.question(` choice${hint}: `)).trim();
236
+
237
+ if (answer.length === 0 && opts?.defaultValue !== undefined) {
238
+ return opts.defaultValue;
239
+ }
240
+ const index = Number.parseInt(answer, 10);
241
+ if (!Number.isNaN(index) && index >= 1 && index <= values.length) {
242
+ return values[index - 1]!.name;
243
+ }
244
+ const named = values.find((c) => c.name === answer);
245
+ if (named !== undefined) {
246
+ return named.name;
247
+ }
248
+ io.write(` ${paint('not one of the choices', 'yellow')}\n`);
249
+ }
250
+ }
251
+
252
+ /** Offer a numbered list and take several answers, separated by commas. */
253
+ export async function multiselect(
254
+ message: string,
255
+ choices: readonly (string | PossibleValue)[],
256
+ opts?: AskOptions,
257
+ ): Promise<string[]> {
258
+ const io = opts?.io ?? terminalIO();
259
+ const values = choices.map((c) => (typeof c === 'string' ? { name: c } : c));
260
+
261
+ for (;;) {
262
+ io.write(`${paint('?', 'green')} ${message}\n`);
263
+ values.forEach((choice, i) => {
264
+ io.write(` ${String(i + 1)}) ${choice.name}\n`);
265
+ });
266
+ const answer = (await io.question(' choices (comma separated): ')).trim();
267
+ if (answer.length === 0) {
268
+ return [];
269
+ }
270
+
271
+ const picked: string[] = [];
272
+ let bad = false;
273
+ for (const part of answer.split(',').map((p) => p.trim())) {
274
+ const index = Number.parseInt(part, 10);
275
+ if (!Number.isNaN(index) && index >= 1 && index <= values.length) {
276
+ picked.push(values[index - 1]!.name);
277
+ continue;
278
+ }
279
+ const named = values.find((c) => c.name === part);
280
+ if (named === undefined) {
281
+ bad = true;
282
+ break;
283
+ }
284
+ picked.push(named.name);
285
+ }
286
+ if (!bad) {
287
+ return picked;
288
+ }
289
+ io.write(` ${paint('not one of the choices', 'yellow')}\n`);
290
+ }
291
+ }
292
+
293
+ /** The message shown when asking for an argument. */
294
+ function messageFor(key: string, def: ArgDef): string {
295
+ return def.description ?? def.longDescription ?? `Value for ${def.long ?? key}`;
296
+ }
297
+
298
+ /**
299
+ * Ask for one argument, choosing the prompt from its definition.
300
+ *
301
+ * A boolean is a confirm, an argument with possible values is a list, one
302
+ * marked `secret` is not echoed, and everything else is a line of text. The
303
+ * result is validated against the argument's own parser before being accepted.
304
+ */
305
+ export async function promptForArg(
306
+ key: string,
307
+ def: ArgDef,
308
+ opts?: AskOptions,
309
+ ): Promise<string | boolean | string[]> {
310
+ const io = opts?.io ?? terminalIO();
311
+ const message = messageFor(key, def);
312
+ const values = possibleValues(def).filter((v) => !v.hidden);
313
+
314
+ if (def.type === 'boolean') {
315
+ return confirm(message, { io });
316
+ }
317
+ if (values.length > 0) {
318
+ return def.action === 'append'
319
+ ? multiselect(message, values, { io })
320
+ : select(message, values, { io });
321
+ }
322
+ if (def.secret === true) {
323
+ return password(message, { io });
324
+ }
325
+
326
+ for (;;) {
327
+ const answer = await input(message, {
328
+ io,
329
+ ...(def.default === undefined ? {} : { defaultValue: String(def.default) }),
330
+ });
331
+ if (answer.length === 0) {
332
+ io.write(` ${paint('a value is required', 'yellow')}\n`);
333
+ continue;
334
+ }
335
+ if (typeof def.valueParser === 'function') {
336
+ try {
337
+ def.valueParser(answer);
338
+ } catch (error) {
339
+ io.write(` ${paint(error instanceof Error ? error.message : String(error), 'yellow')}\n`);
340
+ continue;
341
+ }
342
+ }
343
+ if (def.type === 'number' && Number.isNaN(Number(answer))) {
344
+ io.write(` ${paint('expected a number', 'yellow')}\n`);
345
+ continue;
346
+ }
347
+ return answer;
348
+ }
349
+ }
350
+
351
+ export interface PromptMissingOptions {
352
+ /** Where to ask (default: the terminal). */
353
+ readonly io?: PromptIO;
354
+ /**
355
+ * Ask even when stdin is not a terminal. Off by default, so a script fails
356
+ * with the usual error rather than waiting for input that never comes.
357
+ */
358
+ readonly force?: boolean;
359
+ }
360
+
361
+ /**
362
+ * A `fillMissing` hook that asks for each required argument still empty.
363
+ *
364
+ * ```ts
365
+ * await runMain(main, { fillMissing: promptMissing() });
366
+ * ```
367
+ */
368
+ export function promptMissing(
369
+ opts?: PromptMissingOptions,
370
+ ): (missing: readonly MissingArg[], command: CommandDef<any>) => Promise<Record<string, unknown> | undefined> {
371
+ return async (missing) => {
372
+ const io = opts?.io ?? terminalIO();
373
+ if (!io.interactive && opts?.force !== true) {
374
+ io.close();
375
+ return undefined;
376
+ }
377
+
378
+ const filled: Record<string, unknown> = {};
379
+ try {
380
+ for (const { key, def } of missing) {
381
+ filled[key] = await promptForArg(key, def, { io });
382
+ }
383
+ } finally {
384
+ io.close();
385
+ }
386
+ return filled;
387
+ };
388
+ }