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.
- package/dist/parser.js +5 -5
- package/dist/types.d.ts +14 -1
- package/package.json +17 -1
- package/src/__tests__/arg-options.test.ts +687 -0
- package/src/__tests__/argfile.test.ts +127 -0
- package/src/__tests__/clap-parity.test.ts +682 -0
- package/src/__tests__/command-options.test.ts +713 -0
- package/src/__tests__/completions.test.ts +423 -0
- package/src/__tests__/config.test.ts +261 -0
- package/src/__tests__/deprecation.test.ts +104 -0
- package/src/__tests__/help.test.ts +312 -0
- package/src/__tests__/install.test.ts +120 -0
- package/src/__tests__/log.test.ts +189 -0
- package/src/__tests__/man.test.ts +135 -0
- package/src/__tests__/markdown.test.ts +114 -0
- package/src/__tests__/output.test.ts +249 -0
- package/src/__tests__/parser.test.ts +627 -0
- package/src/__tests__/plugins.test.ts +182 -0
- package/src/__tests__/progress.test.ts +221 -0
- package/src/__tests__/prompt.test.ts +265 -0
- package/src/__tests__/runner.test.ts +459 -0
- package/src/__tests__/spec.test.ts +107 -0
- package/src/__tests__/testing.test.ts +93 -0
- package/src/__tests__/validation.test.ts +267 -0
- package/src/argfile.ts +188 -0
- package/src/completions.ts +865 -0
- package/src/config.ts +184 -0
- package/src/help.ts +779 -0
- package/src/index.ts +58 -0
- package/src/install.ts +226 -0
- package/src/log.ts +225 -0
- package/src/man.ts +289 -0
- package/src/markdown.ts +210 -0
- package/src/output.ts +453 -0
- package/src/parser.ts +1240 -0
- package/src/plugins.ts +193 -0
- package/src/progress.ts +295 -0
- package/src/prompt.ts +388 -0
- package/src/runner.ts +769 -0
- package/src/spec.ts +197 -0
- package/src/testing.ts +159 -0
- package/src/types.ts +618 -0
- package/src/validation.ts +627 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the settings declared on a command: every CommandMeta field, plus
|
|
3
|
+
* the dispatch and help behaviour each one changes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect } from 'bun:test';
|
|
7
|
+
import { parseArgs, CliParseError, getRawArgs } from '../parser.js';
|
|
8
|
+
import { defineCommand } from '../runner.js';
|
|
9
|
+
import { runCli, stripAnsi } from '../testing.js';
|
|
10
|
+
import { renderHelp, renderUsage } from '../help.js';
|
|
11
|
+
import type { ArgsDef, CommandDef } from '../types.js';
|
|
12
|
+
|
|
13
|
+
function cmd(args: ArgsDef, extra?: Partial<CommandDef>): CommandDef {
|
|
14
|
+
return { meta: { name: 'test' }, args, ...extra };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---- help subcommand ----
|
|
18
|
+
|
|
19
|
+
describe('built-in help subcommand', () => {
|
|
20
|
+
const root = defineCommand({
|
|
21
|
+
meta: { name: 'app' },
|
|
22
|
+
subCommands: {
|
|
23
|
+
serve: defineCommand({
|
|
24
|
+
meta: { name: 'serve', description: 'Run the server' },
|
|
25
|
+
args: { port: { type: 'number', description: 'Port to bind' } },
|
|
26
|
+
run() {},
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('app help prints the root help', async () => {
|
|
32
|
+
const { plainStdout: out } = await runCli(root, ['help']);
|
|
33
|
+
expect(out).toContain('Usage: app');
|
|
34
|
+
expect(out).toContain('serve');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('app help serve prints the subcommand help', async () => {
|
|
38
|
+
const { plainStdout: out } = await runCli(root, ['help', 'serve']);
|
|
39
|
+
expect(out).toContain('Usage: app serve');
|
|
40
|
+
expect(out).toContain('Port to bind');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('it is listed in the commands section', () => {
|
|
44
|
+
expect(stripAnsi(renderHelp(root))).toContain('help');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('disableHelpSubcommand takes it away', () => {
|
|
48
|
+
const off: CommandDef = { ...root, meta: { ...root.meta, disableHelpSubcommand: true } };
|
|
49
|
+
const listed = stripAnsi(renderHelp(off))
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter((l) => l.trimStart().startsWith('help'));
|
|
52
|
+
expect(listed).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---- built-in flags ----
|
|
57
|
+
|
|
58
|
+
describe('disabling built-in flags', () => {
|
|
59
|
+
test('disableHelpFlag drops -h and --help', () => {
|
|
60
|
+
const c: CommandDef = { meta: { name: 'x', disableHelpFlag: true } };
|
|
61
|
+
const result = parseArgs(['--help'], c);
|
|
62
|
+
expect(result.helpRequested).toBe(false);
|
|
63
|
+
expect(result.unknown).toEqual(['--help']);
|
|
64
|
+
expect(stripAnsi(renderHelp(c))).not.toContain('--help');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('disableVersionFlag drops -V and --version', () => {
|
|
68
|
+
const c: CommandDef = { meta: { name: 'x', version: '1.0.0', disableVersionFlag: true } };
|
|
69
|
+
expect(parseArgs(['--version'], c).versionRequested).toBe(false);
|
|
70
|
+
expect(stripAnsi(renderHelp(c))).not.toContain('--version');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---- version ----
|
|
75
|
+
|
|
76
|
+
describe('version output', () => {
|
|
77
|
+
const root = defineCommand({
|
|
78
|
+
meta: {
|
|
79
|
+
name: 'app',
|
|
80
|
+
version: '1.2.3',
|
|
81
|
+
longVersion: '1.2.3 (build abcdef)',
|
|
82
|
+
propagateVersion: true,
|
|
83
|
+
},
|
|
84
|
+
subCommands: {
|
|
85
|
+
serve: defineCommand({ meta: { name: 'serve' }, run() {} }),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('-V prints the short version', async () => {
|
|
90
|
+
const { plainStdout: out } = await runCli(root, ['-V']);
|
|
91
|
+
expect(out.trim()).toBe('app 1.2.3');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('--version prints the long version', async () => {
|
|
95
|
+
const { plainStdout: out } = await runCli(root, ['--version']);
|
|
96
|
+
expect(out.trim()).toBe('app 1.2.3 (build abcdef)');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('propagateVersion gives the subcommand a --version', async () => {
|
|
100
|
+
const { plainStdout: out } = await runCli(root, ['serve', '--version']);
|
|
101
|
+
expect(out).toContain('1.2.3');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('without propagateVersion the subcommand has none', () => {
|
|
105
|
+
const sub: CommandDef = { meta: { name: 'serve' } };
|
|
106
|
+
expect(parseArgs(['--version'], sub).unknown).toEqual(['--version']);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---- help layout ----
|
|
111
|
+
|
|
112
|
+
describe('help layout settings', () => {
|
|
113
|
+
test('termWidth fixes the wrap width', () => {
|
|
114
|
+
const c: CommandDef = {
|
|
115
|
+
meta: { name: 'x', termWidth: 40 },
|
|
116
|
+
args: {
|
|
117
|
+
verbose: {
|
|
118
|
+
type: 'boolean',
|
|
119
|
+
description: 'A description long enough that it has to wrap somewhere sensible',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
for (const line of stripAnsi(renderHelp(c)).split('\n')) {
|
|
124
|
+
expect(line.length).toBeLessThanOrEqual(40);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('maxTermWidth caps the terminal width', () => {
|
|
129
|
+
const c: CommandDef = {
|
|
130
|
+
meta: { name: 'x', maxTermWidth: 30 },
|
|
131
|
+
args: {
|
|
132
|
+
verbose: { type: 'boolean', description: 'Another description that will need wrapping' },
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
for (const line of stripAnsi(renderHelp(c)).split('\n')) {
|
|
136
|
+
expect(line.length).toBeLessThanOrEqual(30);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('overrideUsage replaces the usage line', () => {
|
|
141
|
+
const c: CommandDef = { meta: { name: 'x', overrideUsage: 'x <thing> [--flag]' } };
|
|
142
|
+
expect(stripAnsi(renderHelp(c))).toContain('Usage: x <thing> [--flag]');
|
|
143
|
+
expect(stripAnsi(renderUsage(c))).toContain('Usage: x <thing> [--flag]');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('overrideHelp replaces the whole output', () => {
|
|
147
|
+
const c: CommandDef = { meta: { name: 'x', overrideHelp: 'just this' } };
|
|
148
|
+
expect(renderHelp(c)).toBe('just this\n');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('disableColoredHelp strips styling', () => {
|
|
152
|
+
const c: CommandDef = { meta: { name: 'x', version: '1.0.0', disableColoredHelp: true } };
|
|
153
|
+
expect(renderHelp(c)).toBe(stripAnsi(renderHelp(c)));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('long-only before and after text', () => {
|
|
157
|
+
const c: CommandDef = {
|
|
158
|
+
meta: { name: 'x', beforeLongHelp: 'PREAMBLE', afterLongHelp: 'POSTSCRIPT' },
|
|
159
|
+
};
|
|
160
|
+
const long = stripAnsi(renderHelp(c));
|
|
161
|
+
expect(long).toContain('PREAMBLE');
|
|
162
|
+
expect(long).toContain('POSTSCRIPT');
|
|
163
|
+
const short = stripAnsi(renderHelp(c, undefined, true));
|
|
164
|
+
expect(short).not.toContain('PREAMBLE');
|
|
165
|
+
expect(short).not.toContain('POSTSCRIPT');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('author is available to a template', () => {
|
|
169
|
+
const c: CommandDef = {
|
|
170
|
+
meta: { name: 'x', author: 'Salama Ashoush', helpTemplate: '{name} by {author}\n' },
|
|
171
|
+
};
|
|
172
|
+
expect(renderHelp(c)).toBe('x by Salama Ashoush\n');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('subcommand presentation', () => {
|
|
177
|
+
const root: CommandDef = {
|
|
178
|
+
meta: { name: 'app', subcommandHelpHeading: 'Operations', subcommandValueName: 'OP' },
|
|
179
|
+
subCommands: {
|
|
180
|
+
zebra: { meta: { name: 'zebra', description: 'Z', displayOrder: 2 } },
|
|
181
|
+
alpha: { meta: { name: 'alpha', description: 'A', displayOrder: 1 } },
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
test('custom heading and usage placeholder', () => {
|
|
186
|
+
const help = stripAnsi(renderHelp(root));
|
|
187
|
+
expect(help).toContain('Operations:');
|
|
188
|
+
expect(help).toContain('[OP]');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('displayOrder sorts the list', () => {
|
|
192
|
+
const help = stripAnsi(renderHelp(root));
|
|
193
|
+
expect(help.indexOf('alpha')).toBeLessThan(help.indexOf('zebra'));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---- flag-invoked subcommands ----
|
|
198
|
+
|
|
199
|
+
describe('flag-invoked subcommands', () => {
|
|
200
|
+
const sync = defineCommand({
|
|
201
|
+
meta: { name: 'sync', description: 'Sync', shortFlag: 'S', longFlag: 'sync' },
|
|
202
|
+
args: { refresh: { type: 'boolean', short: 'y' } },
|
|
203
|
+
run() {},
|
|
204
|
+
});
|
|
205
|
+
const root = defineCommand({ meta: { name: 'pac' }, subCommands: { sync } });
|
|
206
|
+
|
|
207
|
+
test('-S selects the subcommand', () => {
|
|
208
|
+
const result = parseArgs(['-S', '-y'], root);
|
|
209
|
+
expect(result.subCommand).toBe('sync');
|
|
210
|
+
expect(result.subCommandArgs).toEqual(['-y']);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('--sync selects the subcommand', () => {
|
|
214
|
+
expect(parseArgs(['--sync'], root).subCommand).toBe('sync');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('the flag forms show up in help', () => {
|
|
218
|
+
expect(stripAnsi(renderHelp(root))).toContain('sync (-S, --sync)');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('hidden command aliases', () => {
|
|
223
|
+
const root: CommandDef = {
|
|
224
|
+
meta: { name: 'app' },
|
|
225
|
+
subCommands: {
|
|
226
|
+
install: { meta: { name: 'install', aliases: ['i'], hiddenAliases: ['add'] } },
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
test('a hidden alias resolves', () => {
|
|
231
|
+
expect(parseArgs(['add'], root).subCommand).toBe('install');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('but does not appear in help', () => {
|
|
235
|
+
const help = stripAnsi(renderHelp(root));
|
|
236
|
+
expect(help).toContain('install (i)');
|
|
237
|
+
expect(help).not.toContain('add');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---- parsing settings ----
|
|
242
|
+
|
|
243
|
+
describe('command-level value settings', () => {
|
|
244
|
+
test('allowHyphenValues applies to every arg', () => {
|
|
245
|
+
const c: CommandDef = {
|
|
246
|
+
meta: { name: 'x', allowHyphenValues: true },
|
|
247
|
+
args: { grep: { type: 'string' } },
|
|
248
|
+
};
|
|
249
|
+
expect(parseArgs(['--grep', '-pattern'], c).args.grep).toBe('-pattern');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('allowNegativeNumbers applies to every arg', () => {
|
|
253
|
+
const c: CommandDef = {
|
|
254
|
+
meta: { name: 'x', allowNegativeNumbers: true },
|
|
255
|
+
args: { offset: { type: 'number' } },
|
|
256
|
+
};
|
|
257
|
+
expect(parseArgs(['--offset', '-10'], c).args.offset).toBe(-10);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('allowMissingPositional', () => {
|
|
262
|
+
const c: CommandDef = {
|
|
263
|
+
meta: { name: 'cp', allowMissingPositional: true },
|
|
264
|
+
args: {
|
|
265
|
+
src: { type: 'positional' },
|
|
266
|
+
dest: { type: 'positional', required: true },
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
test('one value fills the trailing required positional', () => {
|
|
271
|
+
const result = parseArgs(['only'], c);
|
|
272
|
+
expect(result.args.src).toBeUndefined();
|
|
273
|
+
expect(result.args.dest).toBe('only');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('two values fill both in order', () => {
|
|
277
|
+
const result = parseArgs(['a', 'b'], c);
|
|
278
|
+
expect(result.args.src).toBe('a');
|
|
279
|
+
expect(result.args.dest).toBe('b');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('argsOverrideSelf', () => {
|
|
284
|
+
test('repeating a single-value arg is an error by default', () => {
|
|
285
|
+
const c: CommandDef = { meta: { name: 'd' }, args: { port: { type: 'number' } } };
|
|
286
|
+
expect(() => parseArgs(['--port', '1', '--port', '2'], c)).toThrow(
|
|
287
|
+
"the argument '--port' cannot be used multiple times",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('the setting makes the last one win', () => {
|
|
292
|
+
const c: CommandDef = {
|
|
293
|
+
meta: { name: 'd', argsOverrideSelf: true },
|
|
294
|
+
args: { port: { type: 'number' } },
|
|
295
|
+
};
|
|
296
|
+
expect(parseArgs(['--port', '1', '--port', '2'], c).args.port).toBe(2);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('append args are unaffected', () => {
|
|
300
|
+
const c: CommandDef = {
|
|
301
|
+
meta: { name: 'd' },
|
|
302
|
+
args: { header: { type: 'string', short: 'H', action: 'append' } },
|
|
303
|
+
};
|
|
304
|
+
expect(parseArgs(['-H', 'a', '-H', 'b'], c).args.header).toEqual(['a', 'b']);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('subcommandPrecedenceOverArg', () => {
|
|
309
|
+
const args = { arg: { type: 'string' as const, action: 'append' as const, numArgs: { min: 1, max: 99 } } };
|
|
310
|
+
const subCommands = { sub: { meta: { name: 'sub' } } };
|
|
311
|
+
|
|
312
|
+
test('by default a multi-value arg swallows the subcommand name', () => {
|
|
313
|
+
const c: CommandDef = { meta: { name: 'cmd' }, args, subCommands };
|
|
314
|
+
const result = parseArgs(['--arg', '1', '2', '3', 'sub'], c);
|
|
315
|
+
expect(result.args.arg).toEqual(['1', '2', '3', 'sub']);
|
|
316
|
+
expect(result.subCommand).toBeUndefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('the setting stops collection at the subcommand', () => {
|
|
320
|
+
const c: CommandDef = {
|
|
321
|
+
meta: { name: 'cmd', subcommandPrecedenceOverArg: true },
|
|
322
|
+
args,
|
|
323
|
+
subCommands,
|
|
324
|
+
};
|
|
325
|
+
const result = parseArgs(['--arg', '1', '2', '3', 'sub'], c);
|
|
326
|
+
expect(result.args.arg).toEqual(['1', '2', '3']);
|
|
327
|
+
expect(result.subCommand).toBe('sub');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('noBinaryName', () => {
|
|
332
|
+
test('nothing is stripped from the front of argv', () => {
|
|
333
|
+
const original = process.argv;
|
|
334
|
+
try {
|
|
335
|
+
process.argv = ['node', 'script.js', '--flag'];
|
|
336
|
+
expect(getRawArgs(undefined, true)).toEqual(['node', 'script.js', '--flag']);
|
|
337
|
+
expect(getRawArgs(undefined, false)).toEqual(['--flag']);
|
|
338
|
+
} finally {
|
|
339
|
+
process.argv = original;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('errors still surface as CliParseError', () => {
|
|
345
|
+
test('an unknown flag on a disabled-help command', () => {
|
|
346
|
+
const c: CommandDef = { meta: { name: 'x' }, args: { a: { type: 'string' } } };
|
|
347
|
+
expect(() => parseArgs(['--a'], c)).toThrow(CliParseError);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('binName and displayName', () => {
|
|
352
|
+
const command: CommandDef = {
|
|
353
|
+
meta: { name: 'stash', binName: 'git stash', displayName: 'git-stash', version: '1.0' },
|
|
354
|
+
args: { verbose: { type: 'boolean' } },
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
test('binName drives the usage line', () => {
|
|
358
|
+
expect(stripAnsi(renderHelp(command))).toContain('Usage: git stash');
|
|
359
|
+
expect(stripAnsi(renderUsage(command))).toContain('Usage: git stash');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('displayName drives the help header', () => {
|
|
363
|
+
expect(stripAnsi(renderHelp(command))).toContain('git-stash v1.0');
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('color choice', () => {
|
|
368
|
+
const args = { verbose: { type: 'boolean' as const, description: 'V' } };
|
|
369
|
+
|
|
370
|
+
test("'never' strips styling", () => {
|
|
371
|
+
const c: CommandDef = { meta: { name: 'x', color: 'never' }, args };
|
|
372
|
+
expect(renderHelp(c)).toBe(stripAnsi(renderHelp(c)));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("'always' emits codes even when stdout is not a terminal", () => {
|
|
376
|
+
const c: CommandDef = { meta: { name: 'x', color: 'always' }, args };
|
|
377
|
+
expect(renderHelp(c)).not.toBe(stripAnsi(renderHelp(c)));
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("'auto' is the default, and defers to stream detection", () => {
|
|
381
|
+
// Asserting whether detection says yes here would pin the test to the
|
|
382
|
+
// runner: bun 1.2 and bun 1.4 disagree about a piped stdout. The contract
|
|
383
|
+
// is that 'auto' behaves exactly as setting nothing does.
|
|
384
|
+
const auto: CommandDef = { meta: { name: 'x', color: 'auto' }, args };
|
|
385
|
+
const unset: CommandDef = { meta: { name: 'x' }, args };
|
|
386
|
+
expect(renderHelp(auto)).toBe(renderHelp(unset));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("'never' and 'always' both override detection, whichever way it went", () => {
|
|
390
|
+
const never: CommandDef = { meta: { name: 'x', color: 'never' }, args };
|
|
391
|
+
const always: CommandDef = { meta: { name: 'x', color: 'always' }, args };
|
|
392
|
+
expect(renderHelp(never)).not.toBe(renderHelp(always));
|
|
393
|
+
expect(stripAnsi(renderHelp(always))).toBe(renderHelp(never));
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('flattenHelp', () => {
|
|
398
|
+
const command: CommandDef = {
|
|
399
|
+
meta: { name: 'stash', flattenHelp: true },
|
|
400
|
+
subCommands: {
|
|
401
|
+
push: {
|
|
402
|
+
meta: { name: 'push', description: 'Save changes' },
|
|
403
|
+
args: {
|
|
404
|
+
message: { type: 'string', short: 'm', description: 'Note' },
|
|
405
|
+
all: { type: 'boolean', description: 'Everything' },
|
|
406
|
+
secret: { type: 'boolean', hidden: true },
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
pop: { meta: { name: 'pop', description: 'Restore' } },
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
test("summarises each subcommand's own args in place", () => {
|
|
414
|
+
const help = stripAnsi(renderHelp(command));
|
|
415
|
+
expect(help).toContain('--message');
|
|
416
|
+
expect(help).toContain('Everything');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('keeps hidden args out', () => {
|
|
420
|
+
expect(stripAnsi(renderHelp(command))).not.toContain('--secret');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('aligns the flattened descriptions', () => {
|
|
424
|
+
const lines = stripAnsi(renderHelp(command)).split('\n');
|
|
425
|
+
const message = lines.find((l) => l.includes('--message'))!;
|
|
426
|
+
const all = lines.find((l) => l.includes('--all'))!;
|
|
427
|
+
expect(message.indexOf('Note')).toBe(all.indexOf('Everything'));
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('is off by default', () => {
|
|
431
|
+
const off: CommandDef = { ...command, meta: { name: 'stash' } };
|
|
432
|
+
expect(stripAnsi(renderHelp(off))).not.toContain('--message');
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe('nextHelpHeading and nextDisplayOrder', () => {
|
|
437
|
+
test('nextHelpHeading renames the default section', () => {
|
|
438
|
+
const c: CommandDef = {
|
|
439
|
+
meta: { name: 'x', nextHelpHeading: 'Global' },
|
|
440
|
+
args: { verbose: { type: 'boolean', description: 'V' } },
|
|
441
|
+
};
|
|
442
|
+
const help = stripAnsi(renderHelp(c));
|
|
443
|
+
expect(help).toContain('Global:');
|
|
444
|
+
expect(help).not.toContain('Options:');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('an explicit displayOrder still sorts ahead of the implicit base', () => {
|
|
448
|
+
const c: CommandDef = {
|
|
449
|
+
meta: { name: 'x', nextDisplayOrder: 100 },
|
|
450
|
+
args: {
|
|
451
|
+
zulu: { type: 'boolean', description: 'z' },
|
|
452
|
+
alpha: { type: 'boolean', description: 'a', displayOrder: 1 },
|
|
453
|
+
mike: { type: 'boolean', description: 'm' },
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
const help = stripAnsi(renderHelp(c));
|
|
457
|
+
expect(help.indexOf('--alpha')).toBeLessThan(help.indexOf('--zulu'));
|
|
458
|
+
// The two without an order keep declaration order between themselves.
|
|
459
|
+
expect(help.indexOf('--zulu')).toBeLessThan(help.indexOf('--mike'));
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe('ignoreErrors', () => {
|
|
464
|
+
const c: CommandDef = {
|
|
465
|
+
meta: { name: 'x', ignoreErrors: true },
|
|
466
|
+
args: { port: { type: 'number' }, flag: { type: 'boolean' } },
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
test('collects the message instead of throwing', () => {
|
|
470
|
+
const result = parseArgs(['--port', 'abc', '--flag'], c);
|
|
471
|
+
expect(result.errors).toHaveLength(1);
|
|
472
|
+
expect(result.errors[0]).toContain("invalid value 'abc'");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('keeps parsing the tokens after the bad one', () => {
|
|
476
|
+
expect(parseArgs(['--port', 'abc', '--flag'], c).args.flag).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('without it the same input throws', () => {
|
|
480
|
+
const strict: CommandDef = { meta: { name: 'x' }, args: { port: { type: 'number' } } };
|
|
481
|
+
expect(() => parseArgs(['--port', 'abc'], strict)).toThrow(CliParseError);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('errors is empty on a clean parse', () => {
|
|
485
|
+
expect(parseArgs(['--flag'], c).errors).toEqual([]);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe('subcommand flag aliases', () => {
|
|
490
|
+
const command: CommandDef = {
|
|
491
|
+
meta: { name: 'pac' },
|
|
492
|
+
subCommands: {
|
|
493
|
+
sync: {
|
|
494
|
+
meta: {
|
|
495
|
+
name: 'sync',
|
|
496
|
+
description: 'Sync',
|
|
497
|
+
shortFlag: 'S',
|
|
498
|
+
longFlagAliases: ['syncdb'],
|
|
499
|
+
visibleLongFlagAliases: ['sy'],
|
|
500
|
+
visibleShortFlagAliases: ['Y'],
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
test('every flag form resolves to the subcommand', () => {
|
|
507
|
+
for (const argv of [['-S'], ['--syncdb'], ['--sy'], ['-Y']]) {
|
|
508
|
+
expect(parseArgs(argv, command).subCommand).toBe('sync');
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('only the visible forms appear in help', () => {
|
|
513
|
+
const help = stripAnsi(renderHelp(command));
|
|
514
|
+
expect(help).toContain('-S');
|
|
515
|
+
expect(help).toContain('--sy');
|
|
516
|
+
expect(help).toContain('-Y');
|
|
517
|
+
expect(help).not.toContain('syncdb');
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe('helpExpected', () => {
|
|
522
|
+
test('rejects an arg with no description', () => {
|
|
523
|
+
const c: CommandDef = {
|
|
524
|
+
meta: { name: 'x', helpExpected: true },
|
|
525
|
+
args: { undocumented: { type: 'boolean' } },
|
|
526
|
+
};
|
|
527
|
+
expect(() => parseArgs([], c)).toThrow('helpExpected is set');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('accepts a described arg, and ignores hidden ones', () => {
|
|
531
|
+
const c: CommandDef = {
|
|
532
|
+
meta: { name: 'x', helpExpected: true },
|
|
533
|
+
args: {
|
|
534
|
+
documented: { type: 'boolean', description: 'Yes' },
|
|
535
|
+
internal: { type: 'boolean', hidden: true },
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
expect(() => parseArgs([], c)).not.toThrow();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('dontDelimitTrailingValues', () => {
|
|
543
|
+
test('a value taken from after -- is left unsplit', () => {
|
|
544
|
+
const c: CommandDef = {
|
|
545
|
+
meta: { name: 'x', dontDelimitTrailingValues: true },
|
|
546
|
+
args: { tags: { type: 'positional', last: true, valueDelimiter: ',' } },
|
|
547
|
+
};
|
|
548
|
+
expect(parseArgs(['--', 'a,b,c'], c).args.tags).toBe('a,b,c');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('without it the same value is split', () => {
|
|
552
|
+
const c: CommandDef = {
|
|
553
|
+
meta: { name: 'x' },
|
|
554
|
+
args: { tags: { type: 'positional', last: true, valueDelimiter: ',' } },
|
|
555
|
+
};
|
|
556
|
+
expect(parseArgs(['--', 'a,b,c'], c).args.tags).toEqual(['a', 'b', 'c']);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
describe('inferLongArgs', () => {
|
|
561
|
+
test('resolves partial long flag when unambiguous', () => {
|
|
562
|
+
const command = cmd(
|
|
563
|
+
{ verbose: { type: 'boolean', long: 'verbose' } },
|
|
564
|
+
{ meta: { name: 'test', inferLongArgs: true } },
|
|
565
|
+
);
|
|
566
|
+
const result = parseArgs(['--verb'], command);
|
|
567
|
+
expect(result.args['verbose']).toBe(true);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('does not resolve ambiguous prefix', () => {
|
|
571
|
+
const command = cmd(
|
|
572
|
+
{
|
|
573
|
+
verbose: { type: 'boolean', long: 'verbose' },
|
|
574
|
+
version: { type: 'boolean', long: 'version-check' },
|
|
575
|
+
},
|
|
576
|
+
{ meta: { name: 'test', inferLongArgs: true } },
|
|
577
|
+
);
|
|
578
|
+
const result = parseArgs(['--ver'], command);
|
|
579
|
+
// ambiguous: matches both verbose and version-check
|
|
580
|
+
expect(result.unknown).toContain('--ver');
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe('subcommandNegatesReqs', () => {
|
|
585
|
+
test('parent required args skipped when subcommand present', async () => {
|
|
586
|
+
let ran = false;
|
|
587
|
+
const root = defineCommand({
|
|
588
|
+
meta: { name: 'app', subcommandNegatesReqs: true },
|
|
589
|
+
args: {
|
|
590
|
+
config: { type: 'string', long: 'config', required: true },
|
|
591
|
+
},
|
|
592
|
+
subCommands: {
|
|
593
|
+
init: defineCommand({
|
|
594
|
+
meta: { name: 'init' },
|
|
595
|
+
run() { ran = true; },
|
|
596
|
+
}),
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
await runCli(root, ['init']);
|
|
600
|
+
expect(ran).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe('subcommandRequired', () => {
|
|
605
|
+
test('errors when no subcommand provided', async () => {
|
|
606
|
+
const root = defineCommand({
|
|
607
|
+
meta: { name: 'app', subcommandRequired: true },
|
|
608
|
+
subCommands: {
|
|
609
|
+
serve: defineCommand({ meta: { name: 'serve' }, run() {} }),
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
const { plainStderr, exitCode } = await runCli(root, [], { showHelpOnEmpty: false });
|
|
613
|
+
expect(plainStderr).toContain('a subcommand is required but one was not provided');
|
|
614
|
+
expect(exitCode).toBe(2);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test('works when subcommand provided', async () => {
|
|
618
|
+
let ran = false;
|
|
619
|
+
const root = defineCommand({
|
|
620
|
+
meta: { name: 'app', subcommandRequired: true },
|
|
621
|
+
subCommands: {
|
|
622
|
+
serve: defineCommand({
|
|
623
|
+
meta: { name: 'serve' },
|
|
624
|
+
run() { ran = true; },
|
|
625
|
+
}),
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
await runCli(root, ['serve']);
|
|
629
|
+
expect(ran).toBe(true);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe('inferSubcommands', () => {
|
|
634
|
+
test('resolves partial subcommand name', async () => {
|
|
635
|
+
let ran = '';
|
|
636
|
+
const root = defineCommand({
|
|
637
|
+
meta: { name: 'app', inferSubcommands: true },
|
|
638
|
+
subCommands: {
|
|
639
|
+
serve: defineCommand({
|
|
640
|
+
meta: { name: 'serve' },
|
|
641
|
+
run() { ran = 'serve'; },
|
|
642
|
+
}),
|
|
643
|
+
build: defineCommand({
|
|
644
|
+
meta: { name: 'build' },
|
|
645
|
+
run() { ran = 'build'; },
|
|
646
|
+
}),
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
await runCli(root, ['ser']);
|
|
650
|
+
expect(ran).toBe('serve');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test('resolves unique prefix', async () => {
|
|
654
|
+
let ran = '';
|
|
655
|
+
const root = defineCommand({
|
|
656
|
+
meta: { name: 'app', inferSubcommands: true },
|
|
657
|
+
subCommands: {
|
|
658
|
+
serve: defineCommand({ meta: { name: 'serve' }, run() { ran = 'serve'; } }),
|
|
659
|
+
build: defineCommand({ meta: { name: 'build' }, run() { ran = 'build'; } }),
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
await runCli(root, ['b']);
|
|
663
|
+
expect(ran).toBe('build');
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
describe('allowExternalSubcommands', () => {
|
|
668
|
+
test('unknown subcommand passed to parent handler', async () => {
|
|
669
|
+
let receivedSubCmd = '';
|
|
670
|
+
const root = defineCommand({
|
|
671
|
+
meta: { name: 'git', allowExternalSubcommands: true },
|
|
672
|
+
run({ subCommand }) {
|
|
673
|
+
receivedSubCmd = subCommand ?? '';
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
await runCli(root, ['my-plugin', 'arg1']);
|
|
677
|
+
expect(receivedSubCmd).toBe('my-plugin');
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe('argsConflictsWithSubcommands', () => {
|
|
682
|
+
test('errors when args and subcommand both present', async () => {
|
|
683
|
+
const root = defineCommand({
|
|
684
|
+
meta: { name: 'app', argsConflictsWithSubcommands: true },
|
|
685
|
+
args: {
|
|
686
|
+
verbose: { type: 'boolean', long: 'verbose' },
|
|
687
|
+
},
|
|
688
|
+
subCommands: {
|
|
689
|
+
serve: defineCommand({ meta: { name: 'serve' }, run() {} }),
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
const { plainStderr, exitCode } = await runCli(root, ['--verbose', 'serve']);
|
|
693
|
+
expect(plainStderr).toContain('cannot be used with subcommands');
|
|
694
|
+
expect(exitCode).toBe(2);
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
describe('argRequiredElseHelp', () => {
|
|
699
|
+
test('shows help when no args provided', async () => {
|
|
700
|
+
const root = defineCommand({
|
|
701
|
+
meta: { name: 'tool', argRequiredElseHelp: true },
|
|
702
|
+
args: {
|
|
703
|
+
input: { type: 'string', long: 'input' },
|
|
704
|
+
},
|
|
705
|
+
run() {
|
|
706
|
+
throw new Error('should not run');
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
const { plainStdout, exitCode } = await runCli(root, [], { showHelpOnEmpty: false });
|
|
710
|
+
expect(plainStdout).toContain('Usage:');
|
|
711
|
+
expect(exitCode).toBe(0);
|
|
712
|
+
});
|
|
713
|
+
});
|