Skip to content

Commit 7e24ad3

Browse files
feat: support eof in parameters (#7)
1 parent 7e0003d commit 7e24ad3

File tree

8 files changed

+327
-27
lines changed

8 files changed

+327
-27
lines changed

README.md

+32
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,38 @@ argv._.optionalParameter // => "b" (string | undefined)
155155
argv._.optionalSpread // => ["c", "d"] (string[])
156156
```
157157

158+
#### End-of-flags
159+
End-of-flags (`--`) (aka _end-of-options_) allows users to pass in a subset of arguments. This is useful for passing in arguments that should be parsed separately from the rest of the arguments or passing in arguments that look like flags.
160+
161+
An example of this is [`npm run`](https://docs.npmjs.com/cli/v8/commands/npm-run-script):
162+
```sh
163+
$ npm run <script> -- <script arguments>
164+
```
165+
The `--` indicates that all arguments afterwards should be passed into the _script_ rather than _npm_.
166+
167+
All end-of-flag arguments will be accessible from `argv._['--']`.
168+
169+
Additionally, you can specify `--` in the `parameters` array to parse end-of-flags arguments.
170+
171+
Example:
172+
173+
```ts
174+
const argv = cli({
175+
name: 'npm-run',
176+
parameters: [
177+
'<script>',
178+
'--',
179+
'[arguments...]'
180+
]
181+
})
182+
183+
// $ npm-run echo -- hello world
184+
185+
argv._.script // => "echo" (string)
186+
argv._.arguments // => ["hello", "world] (string[])
187+
```
188+
189+
158190
### Flags
159191
Flags (aka Options) are key-value pairs passed into the script in the format `--flag-name <value>`.
160192

examples/npm/commands/run-script.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { command } from '../../../src';
2+
3+
export const runScript = command({
4+
name: 'run-script',
5+
6+
alias: ['run', 'rum', 'urn'],
7+
8+
parameters: ['<command>', '--', '[args...]'],
9+
10+
help: {
11+
description: 'Run a script',
12+
},
13+
}, (argv) => {
14+
console.log('run', {
15+
command: argv._.command,
16+
args: argv._.args,
17+
});
18+
});

examples/npm/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
import { cli } from '../../src';
99
import { install } from './commands/install';
10+
import { runScript } from './commands/run-script';
1011

1112
const argv = cli({
1213
name: 'npm',
1314

1415
commands: [
1516
install,
17+
runScript,
1618
],
1719
});
1820

src/cli.ts

+75-26
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ const { stringify } = JSON;
1818

1919
const specialCharactersPattern = /[|\\{}()[\]^$+*?.]/;
2020

21+
type ParsedParameter = {
22+
name: string;
23+
required: boolean;
24+
spread: boolean;
25+
};
26+
2127
function parseParameters(parameters: string[]) {
22-
const parsedParameters: {
23-
name: string;
24-
required: boolean;
25-
spread: boolean;
26-
}[] = [];
28+
const parsedParameters: ParsedParameter[] = [];
2729

2830
let hasOptional: string | undefined;
2931
let hasSpread: string | undefined;
@@ -78,6 +80,38 @@ function parseParameters(parameters: string[]) {
7880
return parsedParameters;
7981
}
8082

83+
function mapParametersToArguments(
84+
mapping: Record<string, string | string[]>,
85+
parameters: ParsedParameter[],
86+
cliArguments: string[],
87+
showHelp: () => void,
88+
) {
89+
for (let i = 0; i < parameters.length; i += 1) {
90+
const { name, required, spread } = parameters[i];
91+
const camelCaseName = camelCase(name);
92+
if (camelCaseName in mapping) {
93+
throw new Error(`Invalid parameter: ${stringify(name)} is used more than once.`);
94+
}
95+
96+
const value = spread ? cliArguments.slice(i) : cliArguments[i];
97+
98+
if (spread) {
99+
i = parameters.length;
100+
}
101+
102+
if (
103+
required
104+
&& (!value || (spread && value.length === 0))
105+
) {
106+
console.error(`Error: Missing required parameter ${stringify(name)}\n`);
107+
showHelp();
108+
return process.exit(1);
109+
}
110+
111+
mapping[camelCaseName] = value;
112+
}
113+
}
114+
81115
function helpEnabled(help: false | undefined | HelpOptions): help is (HelpOptions | undefined) {
82116
return (
83117
// Default
@@ -165,28 +199,43 @@ function cliBase<
165199
}
166200

167201
if (options.parameters) {
168-
const parsedParameters = parseParameters(options.parameters);
169-
const _ = parsed._ as (typeof parsed._ & Record<string, string | string[]>);
170-
171-
for (let i = 0; i < parsedParameters.length; i += 1) {
172-
const { name, required, spread } = parsedParameters[i];
173-
const value = spread ? parsed._.slice(i) : parsed._[i];
174-
175-
if (spread) {
176-
i = parsedParameters.length;
177-
}
178-
179-
if (
180-
required
181-
&& (!value || (spread && value.length === 0))
182-
) {
183-
console.error(`Error: Missing required parameter ${stringify(name)}\n`);
184-
showHelp();
185-
return process.exit(1);
186-
}
187-
188-
_[camelCase(name)] = value;
202+
let { parameters } = options;
203+
let cliArguments = parsed._ as string[];
204+
const hasEof = parameters.indexOf('--');
205+
const eofParameters = parameters.slice(hasEof + 1);
206+
const mapping: Record<string, string | string[]> = Object.create(null);
207+
208+
if (hasEof > -1 && eofParameters.length > 0) {
209+
parameters = parameters.slice(0, hasEof);
210+
211+
const eofArguments = parsed._['--'];
212+
cliArguments = cliArguments.slice(0, -eofArguments.length || undefined);
213+
214+
mapParametersToArguments(
215+
mapping,
216+
parseParameters(parameters),
217+
cliArguments,
218+
showHelp,
219+
);
220+
mapParametersToArguments(
221+
mapping,
222+
parseParameters(eofParameters),
223+
eofArguments,
224+
showHelp,
225+
);
226+
} else {
227+
mapParametersToArguments(
228+
mapping,
229+
parseParameters(parameters),
230+
cliArguments,
231+
showHelp,
232+
);
189233
}
234+
235+
Object.assign(
236+
parsed._,
237+
mapping,
238+
);
190239
}
191240

192241
const parsedWithApi = {

src/render-help/generate-help.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,19 @@ function getUsage(options: Options) {
9898
options.parameters
9999
&& options.parameters.length > 0
100100
) {
101-
usage.push(options.parameters.join(' '));
101+
const { parameters } = options;
102+
const hasEof = parameters.indexOf('--');
103+
const hasRequiredParametersAfterEof = hasEof > -1 && parameters.slice(hasEof + 1).some(parameter => parameter.startsWith('<'));
104+
usage.push(
105+
parameters
106+
.map((parameter) => {
107+
if (parameter !== '--') {
108+
return parameter;
109+
}
110+
return hasRequiredParametersAfterEof ? '--' : '[--]';
111+
})
112+
.join(' '),
113+
);
102114
}
103115

104116
if (usage.length > 1) {

tests/__snapshots__/help.spec.ts.snap

+27
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ EXAMPLES:
2323
Received value: 123"
2424
`;
2525

26+
exports[`invalid usage missing required parameter 1`] = `
27+
"Error: Missing required parameter \\"value-a\\"
28+
"
29+
`;
30+
2631
exports[`show help command help 1`] = `
2732
"my-cli test
2833
@@ -175,6 +180,28 @@ exports[`show help parameters with no name 1`] = `
175180
"
176181
`;
177182
183+
exports[`show help parameters with optional -- 1`] = `
184+
"my-cli
185+
186+
USAGE:
187+
my-cli [flags...] <arg-a> [arg-b] [--] [arg-c]
188+
189+
FLAGS:
190+
-h, --help Show help
191+
"
192+
`;
193+
194+
exports[`show help parameters with required -- 1`] = `
195+
"my-cli
196+
197+
USAGE:
198+
my-cli [flags...] <arg-a> [arg-b] -- <arg-c>
199+
200+
FLAGS:
201+
-h, --help Show help
202+
"
203+
`;
204+
178205
exports[`show help undefined flags 1`] = `
179206
"FLAGS:
180207
-h, --help Show help

tests/arguments.spec.ts

+108
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,42 @@ describe('error handling', () => {
2626
}).toThrow('Invalid parameter: "[value.a]". Invalid character found "."');
2727
});
2828

29+
test('duplicate parameters', () => {
30+
expect(() => {
31+
const parsed = cli(
32+
{
33+
parameters: ['[value-a]', '[value-a]', '[value-a]'],
34+
},
35+
);
36+
37+
expect<string[]>(parsed._).toStrictEqual([]);
38+
}).toThrow('Invalid parameter: "value-a" is used more than once');
39+
});
40+
41+
test('duplicate parameters across --', () => {
42+
expect(() => {
43+
const parsed = cli(
44+
{
45+
parameters: ['[value-a]', '--', '[value-a]'],
46+
},
47+
);
48+
49+
expect<string[]>(parsed._).toStrictEqual([]);
50+
}).toThrow('Invalid parameter: "value-a" is used more than once');
51+
});
52+
53+
test('multiple --', () => {
54+
expect(() => {
55+
const parsed = cli(
56+
{
57+
parameters: ['[value-a]', '--', '[value-b]', '--', '[value-c]'],
58+
},
59+
);
60+
61+
expect<string[]>(parsed._).toStrictEqual([]);
62+
}).toThrow('Invalid parameter: "--". Must be wrapped in <> (required parameter) or [] (optional parameter)');
63+
});
64+
2965
test('optional parameter before required parameter', () => {
3066
expect(() => {
3167
const parsed = cli(
@@ -105,6 +141,19 @@ describe('error handling', () => {
105141
expect(mockConsoleError).toHaveBeenCalledWith('Error: Missing required parameter "value-a"\n');
106142
expect(mockProcessExit).toHaveBeenCalledWith(1);
107143
});
144+
145+
test('missing -- parameter', () => {
146+
cli(
147+
{
148+
parameters: ['--', '<value-a>'],
149+
},
150+
undefined,
151+
[],
152+
);
153+
154+
expect(mockConsoleError).toHaveBeenCalledWith('Error: Missing required parameter "value-a"\n');
155+
expect(mockProcessExit).toHaveBeenCalledWith(1);
156+
});
108157
});
109158
});
110159

@@ -130,6 +179,46 @@ describe('parses arguments', () => {
130179
expect(callback).toHaveBeenCalled();
131180
});
132181

182+
test('simple parsing across --', () => {
183+
const callback = jest.fn();
184+
const parsed = cli(
185+
{
186+
parameters: ['<value-a>', '[value-b]', '[value c]', '--', '<value-d>', '[value-e]', '[value f]'],
187+
},
188+
(callbackParsed) => {
189+
expect<string>(callbackParsed._.valueA).toBe('valueA');
190+
expect<string | undefined>(callbackParsed._.valueB).toBe('valueB');
191+
expect<string | undefined>(callbackParsed._.valueD).toBe('valueD');
192+
callback();
193+
},
194+
['valueA', 'valueB', '--', 'valueD'],
195+
);
196+
197+
expect<string>(parsed._.valueA).toBe('valueA');
198+
expect<string | undefined>(parsed._.valueB).toBe('valueB');
199+
expect<string | undefined>(parsed._.valueD).toBe('valueD');
200+
expect(callback).toHaveBeenCalled();
201+
});
202+
203+
test('simple parsing with empty --', () => {
204+
const callback = jest.fn();
205+
const parsed = cli(
206+
{
207+
parameters: ['<value-a>', '[value-b]', '[value c]', '--', '[value-d]'],
208+
},
209+
(callbackParsed) => {
210+
expect<string>(callbackParsed._.valueA).toBe('valueA');
211+
expect<string | undefined>(callbackParsed._.valueB).toBe('valueB');
212+
callback();
213+
},
214+
['valueA', 'valueB'],
215+
);
216+
217+
expect<string>(parsed._.valueA).toBe('valueA');
218+
expect<string | undefined>(parsed._.valueB).toBe('valueB');
219+
expect(callback).toHaveBeenCalled();
220+
});
221+
133222
test('spread', () => {
134223
const callback = jest.fn();
135224
const parsed = cli(
@@ -147,6 +236,25 @@ describe('parses arguments', () => {
147236
expect(callback).toHaveBeenCalled();
148237
});
149238

239+
test('spread with --', () => {
240+
const callback = jest.fn();
241+
const parsed = cli(
242+
{
243+
parameters: ['<value-a...>', '--', '<value-b...>'],
244+
},
245+
(callbackParsed) => {
246+
expect<string[]>(callbackParsed._.valueA).toStrictEqual(['valueA', 'valueB']);
247+
expect<string[]>(callbackParsed._.valueB).toStrictEqual(['valueC', 'valueD']);
248+
callback();
249+
},
250+
['valueA', 'valueB', '--', 'valueC', 'valueD'],
251+
);
252+
253+
expect<string[]>(parsed._.valueA).toStrictEqual(['valueA', 'valueB']);
254+
expect<string[]>(parsed._.valueB).toStrictEqual(['valueC', 'valueD']);
255+
expect(callback).toHaveBeenCalled();
256+
});
257+
150258
test('command', () => {
151259
const callback = jest.fn();
152260

0 commit comments

Comments
 (0)