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,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the options declared on an argument: every ArgDef field and how it
|
|
3
|
+
* shows up in parsing, validation and help.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect, afterEach } from 'bun:test';
|
|
7
|
+
import { parseArgs, CliParseError } from '../parser.js';
|
|
8
|
+
import { validate } from '../validation.js';
|
|
9
|
+
import { renderHelp } from '../help.js';
|
|
10
|
+
import { defineCommand } from '../runner.js';
|
|
11
|
+
import { runCli, stripAnsi } from '../testing.js';
|
|
12
|
+
import type { ArgsDef, CommandDef, ValueSource } from '../types.js';
|
|
13
|
+
|
|
14
|
+
function cmd(args: ArgsDef, extra?: Partial<CommandDef>): CommandDef {
|
|
15
|
+
return { meta: { name: 'test' }, args, ...extra };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseAndValidate(argv: string[], command: CommandDef) {
|
|
19
|
+
const result = parseArgs(argv, command);
|
|
20
|
+
validate(result, command);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- overridesWith ----
|
|
25
|
+
|
|
26
|
+
describe('overridesWith', () => {
|
|
27
|
+
const c = cmd({
|
|
28
|
+
debug: { type: 'boolean', overridesWith: ['release'] },
|
|
29
|
+
release: { type: 'boolean', overridesWith: ['debug'] },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('the later flag wins', () => {
|
|
33
|
+
const result = parseArgs(['--debug', '--release'], c);
|
|
34
|
+
expect(result.args.release).toBe(true);
|
|
35
|
+
expect(result.args.debug).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('order is what decides, not declaration', () => {
|
|
39
|
+
const result = parseArgs(['--release', '--debug'], c);
|
|
40
|
+
expect(result.args.debug).toBe(true);
|
|
41
|
+
expect(result.args.release).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('an overridden arg falls back to its default', () => {
|
|
45
|
+
const withDefault = cmd({
|
|
46
|
+
mode: { type: 'string', default: 'auto' },
|
|
47
|
+
fast: { type: 'boolean', overridesWith: ['mode'] },
|
|
48
|
+
});
|
|
49
|
+
const result = parseArgs(['--mode', 'slow', '--fast'], withDefault);
|
|
50
|
+
expect(result.args.mode).toBe('auto');
|
|
51
|
+
expect(result.args.fast).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---- ignoreCase ----
|
|
56
|
+
|
|
57
|
+
describe('ignoreCase', () => {
|
|
58
|
+
test('matches possible values case-insensitively', () => {
|
|
59
|
+
const c = cmd({ env: { type: 'string', valueParser: ['dev', 'prod'], ignoreCase: true } });
|
|
60
|
+
// clap keeps the value as typed rather than canonicalising it.
|
|
61
|
+
expect(parseAndValidate(['--env', 'DEV'], c).args.env).toBe('DEV');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('still rejects a value that is not in the list', () => {
|
|
65
|
+
const c = cmd({ env: { type: 'string', valueParser: ['dev', 'prod'], ignoreCase: true } });
|
|
66
|
+
expect(() => parseAndValidate(['--env', 'stage'], c)).toThrow(CliParseError);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('case still matters without the flag', () => {
|
|
70
|
+
const c = cmd({ env: { type: 'string', valueParser: ['dev', 'prod'] } });
|
|
71
|
+
expect(() => parseAndValidate(['--env', 'DEV'], c)).toThrow(CliParseError);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---- PossibleValue objects ----
|
|
76
|
+
|
|
77
|
+
describe('possible values with help and aliases', () => {
|
|
78
|
+
const c = cmd({
|
|
79
|
+
mode: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'Build mode',
|
|
82
|
+
valueParser: [
|
|
83
|
+
{ name: 'fast', help: 'Skip the slow checks' },
|
|
84
|
+
{ name: 'thorough', aliases: ['full'] },
|
|
85
|
+
{ name: 'legacy', hidden: true },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('accepts a name', () => {
|
|
91
|
+
expect(parseAndValidate(['--mode', 'fast'], c).args.mode).toBe('fast');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('accepts an alias, keeping the value as typed', () => {
|
|
95
|
+
expect(parseAndValidate(['--mode', 'full'], c).args.mode).toBe('full');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('accepts a hidden value', () => {
|
|
99
|
+
expect(parseAndValidate(['--mode', 'legacy'], c).args.mode).toBe('legacy');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('rejects anything else', () => {
|
|
103
|
+
expect(() => parseAndValidate(['--mode', 'nope'], c)).toThrow(CliParseError);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('help lists visible values only', () => {
|
|
107
|
+
const help = stripAnsi(renderHelp(c));
|
|
108
|
+
expect(help).toContain('[possible values: fast, thorough]');
|
|
109
|
+
expect(help).not.toContain('legacy');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('long help renders per-value help', () => {
|
|
113
|
+
const help = stripAnsi(renderHelp(c));
|
|
114
|
+
expect(help).toContain('Possible values:');
|
|
115
|
+
expect(help).toContain('- fast: Skip the slow checks');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('short help omits the per-value block', () => {
|
|
119
|
+
expect(stripAnsi(renderHelp(c, undefined, true))).not.toContain('Possible values:');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---- Help presentation ----
|
|
124
|
+
|
|
125
|
+
describe('help presentation', () => {
|
|
126
|
+
test('valueNames renders one placeholder per value', () => {
|
|
127
|
+
const c = cmd({
|
|
128
|
+
point: { type: 'string', numArgs: { min: 3, max: 3 }, valueNames: ['X', 'Y', 'Z'] },
|
|
129
|
+
});
|
|
130
|
+
expect(stripAnsi(renderHelp(c))).toContain('--point <X> <Y> <Z>');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('displayOrder sorts within a section', () => {
|
|
134
|
+
const c = cmd({
|
|
135
|
+
zebra: { type: 'boolean', description: 'Z', displayOrder: 1 },
|
|
136
|
+
alpha: { type: 'boolean', description: 'A', displayOrder: 2 },
|
|
137
|
+
});
|
|
138
|
+
const help = stripAnsi(renderHelp(c));
|
|
139
|
+
expect(help.indexOf('--zebra')).toBeLessThan(help.indexOf('--alpha'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('longDescription is used only in long help', () => {
|
|
143
|
+
const c = cmd({
|
|
144
|
+
verbose: { type: 'boolean', description: 'Short', longDescription: 'The longer story' },
|
|
145
|
+
});
|
|
146
|
+
expect(stripAnsi(renderHelp(c))).toContain('The longer story');
|
|
147
|
+
expect(stripAnsi(renderHelp(c, undefined, true))).toContain('Short');
|
|
148
|
+
expect(stripAnsi(renderHelp(c, undefined, true))).not.toContain('The longer story');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('nextLineHelp puts the description on its own line', () => {
|
|
152
|
+
const c = cmd({ verbose: { type: 'boolean', description: 'Talk a lot', nextLineHelp: true } });
|
|
153
|
+
const lines = stripAnsi(renderHelp(c)).split('\n');
|
|
154
|
+
const flagLine = lines.findIndex((l) => l.includes('--verbose'));
|
|
155
|
+
expect(lines[flagLine]).not.toContain('Talk a lot');
|
|
156
|
+
expect(lines[flagLine + 1]).toContain('Talk a lot');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('hideDefaultValue suppresses the default note', () => {
|
|
160
|
+
const c = cmd({ port: { type: 'number', default: 80, hideDefaultValue: true } });
|
|
161
|
+
expect(stripAnsi(renderHelp(c))).not.toContain('[default:');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('env in help', () => {
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
delete process.env['CLAP_TS_TOKEN'];
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('shows the current value by default', () => {
|
|
171
|
+
process.env['CLAP_TS_TOKEN'] = 'secret';
|
|
172
|
+
const c = cmd({ token: { type: 'string', env: 'CLAP_TS_TOKEN' } });
|
|
173
|
+
expect(stripAnsi(renderHelp(c))).toContain('[env: CLAP_TS_TOKEN=secret]');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('hideEnvValues keeps the name but drops the value', () => {
|
|
177
|
+
process.env['CLAP_TS_TOKEN'] = 'secret';
|
|
178
|
+
const c = cmd({ token: { type: 'string', env: 'CLAP_TS_TOKEN', hideEnvValues: true } });
|
|
179
|
+
const help = stripAnsi(renderHelp(c));
|
|
180
|
+
expect(help).toContain('[env: CLAP_TS_TOKEN]');
|
|
181
|
+
expect(help).not.toContain('secret');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('hideEnv drops the note entirely', () => {
|
|
185
|
+
process.env['CLAP_TS_TOKEN'] = 'secret';
|
|
186
|
+
const c = cmd({ token: { type: 'string', env: 'CLAP_TS_TOKEN', hideEnv: true } });
|
|
187
|
+
expect(stripAnsi(renderHelp(c))).not.toContain('[env:');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ---- Positional index ----
|
|
192
|
+
|
|
193
|
+
describe('positional index', () => {
|
|
194
|
+
test('an explicit index overrides declaration order', () => {
|
|
195
|
+
const c = cmd({
|
|
196
|
+
second: { type: 'positional', index: 2 },
|
|
197
|
+
first: { type: 'positional', index: 1 },
|
|
198
|
+
});
|
|
199
|
+
const result = parseArgs(['a', 'b'], c);
|
|
200
|
+
expect(result.args.first).toBe('a');
|
|
201
|
+
expect(result.args.second).toBe('b');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---- Conditional defaults and requirements ----
|
|
206
|
+
|
|
207
|
+
describe('defaultValueIfs', () => {
|
|
208
|
+
const c = cmd({
|
|
209
|
+
mode: { type: 'string' },
|
|
210
|
+
level: {
|
|
211
|
+
type: 'string',
|
|
212
|
+
defaultValueIfs: [
|
|
213
|
+
['mode', 'fast', '1'],
|
|
214
|
+
['mode', 'thorough', '9'],
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('applies the first matching condition', () => {
|
|
220
|
+
expect(parseArgs(['--mode', 'thorough'], c).args.level).toBe('9');
|
|
221
|
+
expect(parseArgs(['--mode', 'fast'], c).args.level).toBe('1');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('applies nothing when no condition holds', () => {
|
|
225
|
+
expect(parseArgs(['--mode', 'other'], c).args.level).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('requiredIfEqAny and requiredIfEqAll', () => {
|
|
230
|
+
test('any: one matching condition is enough', () => {
|
|
231
|
+
const c = cmd({
|
|
232
|
+
env: { type: 'string' },
|
|
233
|
+
region: { type: 'string' },
|
|
234
|
+
token: {
|
|
235
|
+
type: 'string',
|
|
236
|
+
requiredIfEqAny: [
|
|
237
|
+
['env', 'prod'],
|
|
238
|
+
['region', 'eu'],
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
expect(() => parseAndValidate(['--env', 'prod'], c)).toThrow('--token');
|
|
243
|
+
expect(() => parseAndValidate(['--region', 'eu'], c)).toThrow('--token');
|
|
244
|
+
expect(() => parseAndValidate(['--env', 'dev'], c)).not.toThrow();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('all: every condition must hold', () => {
|
|
248
|
+
const c = cmd({
|
|
249
|
+
env: { type: 'string' },
|
|
250
|
+
region: { type: 'string' },
|
|
251
|
+
token: {
|
|
252
|
+
type: 'string',
|
|
253
|
+
requiredIfEqAll: [
|
|
254
|
+
['env', 'prod'],
|
|
255
|
+
['region', 'eu'],
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
expect(() => parseAndValidate(['--env', 'prod', '--region', 'eu'], c)).toThrow('--token');
|
|
260
|
+
expect(() => parseAndValidate(['--env', 'prod'], c)).not.toThrow();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('requiredUnlessPresentAll', () => {
|
|
265
|
+
const c = cmd({
|
|
266
|
+
a: { type: 'boolean' },
|
|
267
|
+
b: { type: 'boolean' },
|
|
268
|
+
config: { type: 'string', required: true, requiredUnlessPresentAll: ['a', 'b'] },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('waived only when every named arg is present', () => {
|
|
272
|
+
expect(() => parseAndValidate(['--a', '--b'], c)).not.toThrow();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('still required when only one is present', () => {
|
|
276
|
+
expect(() => parseAndValidate(['--a'], c)).toThrow('--config');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ---- Groups ----
|
|
281
|
+
|
|
282
|
+
describe('arg-level group membership', () => {
|
|
283
|
+
const c = cmd({
|
|
284
|
+
json: { type: 'boolean', groups: ['format'] },
|
|
285
|
+
yaml: { type: 'boolean', groups: ['format'] },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('members of an implicit group are mutually exclusive', () => {
|
|
289
|
+
expect(() => parseAndValidate(['--json', '--yaml'], c)).toThrow('cannot be used together');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('one member alone is fine', () => {
|
|
293
|
+
expect(() => parseAndValidate(['--json'], c)).not.toThrow();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('group constraints', () => {
|
|
298
|
+
test('conflictsWith rejects the named arg', () => {
|
|
299
|
+
const c = cmd(
|
|
300
|
+
{
|
|
301
|
+
json: { type: 'boolean' },
|
|
302
|
+
quiet: { type: 'boolean' },
|
|
303
|
+
},
|
|
304
|
+
{ groups: [{ name: 'format', args: ['json'], conflictsWith: ['quiet'] }] },
|
|
305
|
+
);
|
|
306
|
+
expect(() => parseAndValidate(['--json', '--quiet'], c)).toThrow('cannot be used with');
|
|
307
|
+
expect(() => parseAndValidate(['--json'], c)).not.toThrow();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('requires demands the named arg', () => {
|
|
311
|
+
const c = cmd(
|
|
312
|
+
{
|
|
313
|
+
json: { type: 'boolean' },
|
|
314
|
+
out: { type: 'string' },
|
|
315
|
+
},
|
|
316
|
+
{ groups: [{ name: 'format', args: ['json'], requires: ['out'] }] },
|
|
317
|
+
);
|
|
318
|
+
expect(() => parseAndValidate(['--json'], c)).toThrow('--out');
|
|
319
|
+
expect(() => parseAndValidate(['--json', '--out', 'f'], c)).not.toThrow();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ---- Value source ----
|
|
324
|
+
|
|
325
|
+
describe('valueSources', () => {
|
|
326
|
+
afterEach(() => {
|
|
327
|
+
delete process.env['CLAP_TS_SRC'];
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const command = cmd({
|
|
331
|
+
fromCli: { type: 'string' },
|
|
332
|
+
fromEnv: { type: 'string', env: 'CLAP_TS_SRC' },
|
|
333
|
+
fromDefault: { type: 'string', default: 'd' },
|
|
334
|
+
unset: { type: 'string' },
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('distinguishes cli, env and default', () => {
|
|
338
|
+
process.env['CLAP_TS_SRC'] = 'e';
|
|
339
|
+
const { valueSources } = parseArgs(['--fromCli', 'c'], command);
|
|
340
|
+
expect(valueSources.get('fromCli')).toBe('cli');
|
|
341
|
+
expect(valueSources.get('fromEnv')).toBe('env');
|
|
342
|
+
expect(valueSources.get('fromDefault')).toBe('default');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('an unset arg has no source', () => {
|
|
346
|
+
expect(parseArgs([], command).valueSources.get('unset')).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('the command line beats an env value', () => {
|
|
350
|
+
process.env['CLAP_TS_SRC'] = 'e';
|
|
351
|
+
const { args, valueSources } = parseArgs(['--fromEnv', 'c'], command);
|
|
352
|
+
expect(args.fromEnv).toBe('c');
|
|
353
|
+
expect(valueSources.get('fromEnv')).toBe('cli');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('the command line beats a default', () => {
|
|
357
|
+
const { valueSources } = parseArgs(['--fromDefault', 'x'], command);
|
|
358
|
+
expect(valueSources.get('fromDefault')).toBe('cli');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('reaches the run handler through the context', async () => {
|
|
362
|
+
let seen: ReadonlyMap<string, ValueSource> = new Map();
|
|
363
|
+
const root = defineCommand({
|
|
364
|
+
meta: { name: 'app' },
|
|
365
|
+
args: { port: { type: 'number', default: 80 }, host: { type: 'string' } },
|
|
366
|
+
run({ valueSources }) {
|
|
367
|
+
seen = valueSources;
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
await runCli(root, ['--host', 'h']);
|
|
371
|
+
expect(seen.get('host')).toBe('cli');
|
|
372
|
+
expect(seen.get('port')).toBe('default');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('a conditional default reports as a default', () => {
|
|
376
|
+
const c = cmd({
|
|
377
|
+
mode: { type: 'string' },
|
|
378
|
+
level: { type: 'string', defaultValueIf: ['mode', 'fast', '9'] },
|
|
379
|
+
});
|
|
380
|
+
expect(parseArgs(['--mode', 'fast'], c).valueSources.get('level')).toBe('default');
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ---- Actions ----
|
|
385
|
+
|
|
386
|
+
describe('setTrue and setFalse actions', () => {
|
|
387
|
+
test('setFalse turns the flag into a switch-off', () => {
|
|
388
|
+
const c = cmd({ color: { type: 'boolean', action: 'setFalse', long: 'no-color' } });
|
|
389
|
+
expect(parseArgs(['--no-color'], c).args.color).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('setTrue is the plain flag behaviour', () => {
|
|
393
|
+
const c = cmd({ color: { type: 'boolean', action: 'setTrue' } });
|
|
394
|
+
expect(parseArgs(['--color'], c).args.color).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('neither consumes a following token', () => {
|
|
398
|
+
const c = cmd({
|
|
399
|
+
color: { type: 'boolean', action: 'setFalse' },
|
|
400
|
+
file: { type: 'positional' },
|
|
401
|
+
});
|
|
402
|
+
const result = parseArgs(['--color', 'x'], c);
|
|
403
|
+
expect(result.args.color).toBe(false);
|
|
404
|
+
expect(result.args.file).toBe('x');
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('help and version actions', () => {
|
|
409
|
+
test('an arg can trigger help', () => {
|
|
410
|
+
const c = cmd({ usage: { type: 'boolean', short: '?', action: 'help' } });
|
|
411
|
+
expect(parseArgs(['-?'], c).helpRequested).toBe(true);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('helpShort asks for the short form', () => {
|
|
415
|
+
const c = cmd({ brief: { type: 'boolean', action: 'helpShort' } });
|
|
416
|
+
const result = parseArgs(['--brief'], c);
|
|
417
|
+
expect(result.helpRequested).toBe(true);
|
|
418
|
+
expect(result.helpIsShort).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('an arg can trigger version', () => {
|
|
422
|
+
const c = cmd({ rev: { type: 'boolean', action: 'version' } });
|
|
423
|
+
expect(parseArgs(['--rev'], c).versionRequested).toBe(true);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe('defaultMissingValues', () => {
|
|
428
|
+
test('a bare multi-value flag falls back to the listed values', () => {
|
|
429
|
+
const c = cmd({
|
|
430
|
+
point: {
|
|
431
|
+
type: 'string',
|
|
432
|
+
numArgs: { min: 0, max: 3 },
|
|
433
|
+
defaultMissingValues: ['0', '0', '0'],
|
|
434
|
+
},
|
|
435
|
+
other: { type: 'boolean' },
|
|
436
|
+
});
|
|
437
|
+
expect(parseArgs(['--point', '--other'], c).args.point).toEqual(['0', '0', '0']);
|
|
438
|
+
expect(parseArgs(['--point', '1', '2', '3'], c).args.point).toEqual(['1', '2', '3']);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ---- Conditional requires ----
|
|
443
|
+
|
|
444
|
+
describe('requiresIf', () => {
|
|
445
|
+
const c = cmd({
|
|
446
|
+
mode: { type: 'string', requiresIf: ['remote', 'url'] },
|
|
447
|
+
url: { type: 'string' },
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('requires the named arg only at the matching value', () => {
|
|
451
|
+
expect(() => parseAndValidate(['--mode', 'remote'], c)).toThrow('--url');
|
|
452
|
+
expect(() => parseAndValidate(['--mode', 'local'], c)).not.toThrow();
|
|
453
|
+
expect(() => parseAndValidate(['--mode', 'remote', '--url', 'u'], c)).not.toThrow();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('requiresIfs handles several values', () => {
|
|
457
|
+
const many = cmd({
|
|
458
|
+
mode: {
|
|
459
|
+
type: 'string',
|
|
460
|
+
requiresIfs: [
|
|
461
|
+
['remote', 'url'],
|
|
462
|
+
['file', 'path'],
|
|
463
|
+
],
|
|
464
|
+
},
|
|
465
|
+
url: { type: 'string' },
|
|
466
|
+
path: { type: 'string' },
|
|
467
|
+
});
|
|
468
|
+
expect(() => parseAndValidate(['--mode', 'remote'], many)).toThrow('--url');
|
|
469
|
+
expect(() => parseAndValidate(['--mode', 'file'], many)).toThrow('--path');
|
|
470
|
+
expect(() => parseAndValidate(['--mode', 'other'], many)).not.toThrow();
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe('singular group', () => {
|
|
475
|
+
test('group joins the arg to a named group', () => {
|
|
476
|
+
const c = cmd({
|
|
477
|
+
json: { type: 'boolean', group: 'format' },
|
|
478
|
+
yaml: { type: 'boolean', group: 'format' },
|
|
479
|
+
});
|
|
480
|
+
expect(() => parseAndValidate(['--json', '--yaml'], c)).toThrow('cannot be used together');
|
|
481
|
+
expect(() => parseAndValidate(['--json'], c)).not.toThrow();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('it errors like any other constraint', () => {
|
|
485
|
+
const c = cmd({ a: { type: 'boolean', group: 'g' }, b: { type: 'boolean', group: 'g' } });
|
|
486
|
+
expect(() => parseAndValidate(['--a', '--b'], c)).toThrow(CliParseError);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe('allowHyphenValues', () => {
|
|
491
|
+
test('accepts values starting with - when enabled', () => {
|
|
492
|
+
const command = cmd({
|
|
493
|
+
grep: { type: 'string', long: 'grep', allowHyphenValues: true },
|
|
494
|
+
});
|
|
495
|
+
const result = parseArgs(['--grep', '-pattern'], command);
|
|
496
|
+
expect(result.args['grep']).toBe('-pattern');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('with =value syntax works without allowHyphenValues', () => {
|
|
500
|
+
const command = cmd({
|
|
501
|
+
grep: { type: 'string', long: 'grep' },
|
|
502
|
+
});
|
|
503
|
+
const result = parseArgs(['--grep=-pattern'], command);
|
|
504
|
+
expect(result.args['grep']).toBe('-pattern');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('allowHyphenValues rewrites short flag -p -value to --long=-value in prescan', () => {
|
|
508
|
+
const command = cmd({
|
|
509
|
+
pattern: { type: 'string', short: 'p', long: 'pattern', allowHyphenValues: true },
|
|
510
|
+
});
|
|
511
|
+
const result = parseArgs(['-p', '-hello'], command);
|
|
512
|
+
expect(result.args['pattern']).toBe('-hello');
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe('allowNegativeNumbers', () => {
|
|
517
|
+
test('accepts negative numbers as values', () => {
|
|
518
|
+
const command = cmd({
|
|
519
|
+
offset: { type: 'number', long: 'offset', allowNegativeNumbers: true },
|
|
520
|
+
});
|
|
521
|
+
const result = parseArgs(['--offset', '-10'], command);
|
|
522
|
+
expect(result.args['offset']).toBe(-10);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('accepts negative floats', () => {
|
|
526
|
+
const command = cmd({
|
|
527
|
+
rate: { type: 'number', long: 'rate', allowNegativeNumbers: true },
|
|
528
|
+
});
|
|
529
|
+
const result = parseArgs(['--rate', '-3.14'], command);
|
|
530
|
+
expect(result.args['rate']).toBe(-3.14);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe('valueDelimiter', () => {
|
|
535
|
+
test('splits comma-separated values into array', () => {
|
|
536
|
+
const command = cmd({
|
|
537
|
+
tags: { type: 'string', long: 'tags', valueDelimiter: ',' },
|
|
538
|
+
});
|
|
539
|
+
const result = parseArgs(['--tags', 'a,b,c'], command);
|
|
540
|
+
expect(result.args['tags']).toEqual(['a', 'b', 'c']);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test('splits with append action', () => {
|
|
544
|
+
const command = cmd({
|
|
545
|
+
tags: { type: 'string', long: 'tags', action: 'append', valueDelimiter: ',' },
|
|
546
|
+
});
|
|
547
|
+
const result = parseArgs(['--tags', 'a,b', '--tags', 'c,d'], command);
|
|
548
|
+
expect(result.args['tags']).toEqual(['a', 'b', 'c', 'd']);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('splits env var values by delimiter', () => {
|
|
552
|
+
const original = process.env['TEST_TAGS'];
|
|
553
|
+
process.env['TEST_TAGS'] = 'x,y,z';
|
|
554
|
+
try {
|
|
555
|
+
const command = cmd({
|
|
556
|
+
tags: { type: 'string', long: 'tags', env: 'TEST_TAGS', valueDelimiter: ',' },
|
|
557
|
+
});
|
|
558
|
+
const result = parseArgs([], command);
|
|
559
|
+
expect(result.args['tags']).toEqual(['x', 'y', 'z']);
|
|
560
|
+
} finally {
|
|
561
|
+
if (original === undefined) {
|
|
562
|
+
delete process.env['TEST_TAGS'];
|
|
563
|
+
} else {
|
|
564
|
+
process.env['TEST_TAGS'] = original;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
describe('trailingVarArg', () => {
|
|
571
|
+
test('last positional consumes all remaining args', () => {
|
|
572
|
+
const command = cmd({
|
|
573
|
+
cmd: { type: 'positional', valueName: 'CMD' },
|
|
574
|
+
rest: { type: 'positional', valueName: 'ARGS', trailingVarArg: true },
|
|
575
|
+
});
|
|
576
|
+
const result = parseArgs(['echo', 'hello', 'world', 'foo'], command);
|
|
577
|
+
expect(result.args['cmd']).toBe('echo');
|
|
578
|
+
expect(result.args['rest']).toEqual(['hello', 'world', 'foo']);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test('empty trailing var arg', () => {
|
|
582
|
+
const command = cmd({
|
|
583
|
+
cmd: { type: 'positional', valueName: 'CMD' },
|
|
584
|
+
rest: { type: 'positional', valueName: 'ARGS', trailingVarArg: true },
|
|
585
|
+
});
|
|
586
|
+
const result = parseArgs(['echo'], command);
|
|
587
|
+
expect(result.args['cmd']).toBe('echo');
|
|
588
|
+
expect(result.args['rest']).toBeUndefined();
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
describe('last (positional after --)', () => {
|
|
593
|
+
test('positional only assigned from rest args', () => {
|
|
594
|
+
const command = cmd({
|
|
595
|
+
verbose: { type: 'boolean', long: 'verbose' },
|
|
596
|
+
script: { type: 'positional', valueName: 'SCRIPT', last: true },
|
|
597
|
+
});
|
|
598
|
+
const result = parseArgs(['--verbose', '--', 'run.sh'], command);
|
|
599
|
+
expect(result.args['verbose']).toBe(true);
|
|
600
|
+
expect(result.args['script']).toBe('run.sh');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('not assigned without --', () => {
|
|
604
|
+
const command = cmd({
|
|
605
|
+
script: { type: 'positional', valueName: 'SCRIPT', last: true },
|
|
606
|
+
});
|
|
607
|
+
const result = parseArgs(['run.sh'], command);
|
|
608
|
+
// run.sh goes to regular positionals, not to the `last` positional
|
|
609
|
+
expect(result.args['script']).toBeUndefined();
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
describe('defaultValueIf', () => {
|
|
614
|
+
test('applies conditional default when condition met', () => {
|
|
615
|
+
const command = cmd({
|
|
616
|
+
env: { type: 'string', long: 'env' },
|
|
617
|
+
port: { type: 'number', long: 'port', defaultValueIf: ['env', 'prod', 443] },
|
|
618
|
+
});
|
|
619
|
+
const result = parseArgs(['--env', 'prod'], command);
|
|
620
|
+
expect(result.args['port']).toBe(443);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test('does not apply when condition not met', () => {
|
|
624
|
+
const command = cmd({
|
|
625
|
+
env: { type: 'string', long: 'env' },
|
|
626
|
+
port: { type: 'number', long: 'port', defaultValueIf: ['env', 'prod', 443] },
|
|
627
|
+
});
|
|
628
|
+
const result = parseArgs(['--env', 'dev'], command);
|
|
629
|
+
expect(result.args['port']).toBeUndefined();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('explicit value overrides conditional default', () => {
|
|
633
|
+
const command = cmd({
|
|
634
|
+
env: { type: 'string', long: 'env' },
|
|
635
|
+
port: { type: 'number', long: 'port', defaultValueIf: ['env', 'prod', 443] },
|
|
636
|
+
});
|
|
637
|
+
const result = parseArgs(['--env', 'prod', '--port', '8080'], command);
|
|
638
|
+
expect(result.args['port']).toBe(8080);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
describe('valueParser as function', () => {
|
|
643
|
+
test('custom parser transforms value', () => {
|
|
644
|
+
const command = cmd({
|
|
645
|
+
port: {
|
|
646
|
+
type: 'string',
|
|
647
|
+
long: 'port',
|
|
648
|
+
valueParser: (v: string) => {
|
|
649
|
+
const n = Number.parseInt(v, 10);
|
|
650
|
+
if (n < 1 || n > 65535) {
|
|
651
|
+
throw new Error('port must be 1-65535');
|
|
652
|
+
}
|
|
653
|
+
return n;
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
const result = parseArgs(['--port', '8080'], command);
|
|
658
|
+
expect(result.args['port']).toBe(8080);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test('custom parser error produces CliParseError', () => {
|
|
662
|
+
const command = cmd({
|
|
663
|
+
port: {
|
|
664
|
+
type: 'string',
|
|
665
|
+
long: 'port',
|
|
666
|
+
valueParser: (v: string) => {
|
|
667
|
+
const n = Number.parseInt(v, 10);
|
|
668
|
+
if (n < 1 || n > 65535) {
|
|
669
|
+
throw new Error('port must be 1-65535');
|
|
670
|
+
}
|
|
671
|
+
return n;
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
expect(() => parseArgs(['--port', '99999'], command)).toThrow(CliParseError);
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
describe('visibleAlias', () => {
|
|
680
|
+
test('visible aliases work as parse-time aliases', () => {
|
|
681
|
+
const command = cmd({
|
|
682
|
+
output: { type: 'string', long: 'output', visibleAlias: ['out'] },
|
|
683
|
+
});
|
|
684
|
+
const result = parseArgs(['--out', 'file.txt'], command);
|
|
685
|
+
expect(result.args['output']).toBe('file.txt');
|
|
686
|
+
});
|
|
687
|
+
});
|