Skip to content

Commit 00d355b

Browse files
authored
feat: single command mode (#25)
* feat: infer `single` from `name` parsing * chore: replace `&&` chain w/ `if` block * feat: add `isOne` param for fallback assertion * fix: prevent `command()` calls with “single” enabled * chore: reuse `isOne` parameter * fix: only include `--version` flag on default/root help * chore: fix tests for `—version` move; - also, `util.help` was mutating; expectant saved duplicate output! * chore: add `util.help` test for single output * chore: update README for single-command mode
1 parent 1058f3b commit 00d355b

File tree

9 files changed

+290
-59
lines changed

9 files changed

+290
-59
lines changed

lib/index.js

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,28 @@ const ALL = '__all__';
55
const DEF = '__default__';
66

77
class Sade {
8-
constructor(name) {
8+
constructor(name, isOne) {
9+
let [bin, ...rest] = name.split(/\s+/);
10+
isOne = isOne || rest.length > 0;
11+
912
this.tree = {};
10-
this.name = name;
11-
this.ver = '0.0.0';
13+
this.name = bin;
1214
this.default = '';
15+
this.ver = '0.0.0';
1316
// set internal shapes;
1417
this.command(ALL);
15-
this.command(`${DEF} <command>`)
16-
.option('-v, --version', 'Displays current version');
18+
this.command([DEF].concat(isOne ? rest : '<command>').join(' '));
19+
this.single = isOne;
1720
this.curr = ''; // reset
1821
}
1922

2023
command(str, desc, opts={}) {
21-
let cmd=[], usage=[], rgx=/(\[|<)/;
24+
if (this.single) {
25+
throw new Error('Disable "single" mode to add commands');
26+
}
27+
2228
// All non-([|<) are commands
29+
let cmd=[], usage=[], rgx=/(\[|<)/;
2330
str.split(/\s+/).forEach(x => {
2431
(rgx.test(x.charAt(0)) ? usage : cmd).push(x);
2532
});
@@ -31,12 +38,13 @@ class Sade {
3138
throw new Error(`Command already exists: ${cmd}`);
3239
}
3340

41+
// re-include `cmd` for commands
42+
cmd.includes('__') || usage.unshift(cmd);
43+
usage = usage.join(' '); // to string
44+
3445
this.curr = cmd;
3546
if (opts.default) this.default=cmd;
3647

37-
!~cmd.indexOf('__') && usage.unshift(cmd); // re-include `cmd`
38-
usage = usage.join(' '); // to string
39-
4048
this.tree[cmd] = { usage, options:[], alias:{}, default:{}, examples:[] };
4149
if (desc) this.describe(desc);
4250

@@ -52,7 +60,7 @@ class Sade {
5260
let cmd = this.tree[ this.curr || ALL ];
5361

5462
let [flag, alias] = $.parse(str);
55-
(alias && alias.length > 1) && ([flag, alias]=[alias, flag]);
63+
if (alias && alias.length > 1) [flag, alias]=[alias, flag];
5664

5765
str = `--${flag}`;
5866
if (alias && alias.length > 0) {
@@ -91,41 +99,43 @@ class Sade {
9199
let offset = 2; // argv slicer
92100
let alias = { h:'help', v:'version' };
93101
let argv = mri(arr.slice(offset), { alias });
102+
let isSingle = this.single;
94103
let bin = this.name;
95-
96-
// Loop thru possible command(s)
97-
let tmp, name='';
98-
let i=1, len=argv._.length + 1;
99-
for (; i < len; i++) {
100-
tmp = argv._.slice(0, i).join(' ');
101-
if (this.tree[tmp] !== void 0) {
102-
name=tmp; offset=(i + 2); // argv slicer
104+
let tmp, name = '';
105+
let isVoid, cmd;
106+
107+
if (isSingle) {
108+
cmd = this.tree[DEF];
109+
} else {
110+
// Loop thru possible command(s)
111+
let i=1, len=argv._.length + 1;
112+
for (; i < len; i++) {
113+
tmp = argv._.slice(0, i).join(' ');
114+
if (this.tree[tmp] !== void 0) {
115+
name=tmp; offset=(i + 2); // argv slicer
116+
}
103117
}
104-
}
105-
106-
let cmd = this.tree[name];
107-
let isVoid = (cmd === void 0);
108-
109-
if (isVoid) {
110-
if (this.default) {
111-
name = this.default;
112-
cmd = this.tree[name];
113-
arr.unshift(name);
114-
offset++;
115-
} else if (tmp) {
116-
return $.error(bin, `Invalid command: ${tmp}`);
117-
} //=> else: cmd not specified, wait for now...
118-
}
119118

120-
if (argv.version) {
121-
return console.log(`${bin}, ${this.ver}`);
119+
cmd = this.tree[name];
120+
isVoid = (cmd === void 0);
121+
122+
if (isVoid) {
123+
if (this.default) {
124+
name = this.default;
125+
cmd = this.tree[name];
126+
arr.unshift(name);
127+
offset++;
128+
} else if (tmp) {
129+
return $.error(bin, `Invalid command: ${tmp}`);
130+
} //=> else: cmd not specified, wait for now...
131+
}
122132
}
123133

124-
if (argv.help) {
125-
return this.help(!isVoid && name);
126-
}
134+
// show main help if relied on "default" for multi-cmd
135+
if (argv.help) return this.help(!isSingle && !isVoid && name);
136+
if (argv.version) return this._version();
127137

128-
if (cmd === void 0) {
138+
if (!isSingle && cmd === void 0) {
129139
return $.error(bin, 'No command specified.');
130140
}
131141

@@ -144,7 +154,7 @@ class Sade {
144154
let args = vals._.splice(0, reqs.length);
145155

146156
if (args.length < reqs.length) {
147-
name && (bin += ` ${name}`); // for help text
157+
if (name) bin += ` ${name}`; // for help text
148158
return $.error(bin, 'Insufficient arguments!');
149159
}
150160

@@ -159,9 +169,13 @@ class Sade {
159169

160170
help(str) {
161171
console.log(
162-
$.help(this.name, this.tree, str || DEF)
172+
$.help(this.name, this.tree, str || DEF, this.single)
163173
);
164174
}
175+
176+
_version() {
177+
console.log(`${this.name}, ${this.ver}`);
178+
}
165179
}
166180

167-
module.exports = str => new Sade(str);
181+
module.exports = (str, isOne) => new Sade(str, isOne);

lib/utils.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,23 @@ function section(str, arr, fn) {
3636
return out + NL;
3737
}
3838

39-
exports.help = function (bin, tree, key) {
39+
exports.help = function (bin, tree, key, single) {
4040
let out='', cmd=tree[key], pfx=`$ ${bin}`, all=tree[ALL];
41-
let prefix = s => `${pfx} ${s}`;
41+
let prefix = s => `${pfx} ${s}`.replace(/\s+/g, ' ');
4242

4343
// update ALL & CMD options
44-
all.options.push(['-h, --help', 'Displays this message']);
45-
cmd.options = (cmd.options || []).concat(all.options);
44+
let tail = [['-h, --help', 'Displays this message']];
45+
if (key === DEF) tail.unshift(['-v, --version', 'Displays current version']);
46+
cmd.options = (cmd.options || []).concat(all.options, tail);
4647

4748
// write options placeholder
48-
(cmd.options.length > 0) && (cmd.usage += ' [options]');
49+
if (cmd.options.length > 0) cmd.usage += ' [options]';
4950

5051
// description ~> text only; usage ~> prefixed
5152
out += section('Description', cmd.describe, noop);
5253
out += section('Usage', [cmd.usage], prefix);
5354

54-
if (key === DEF) {
55+
if (!single && key === DEF) {
5556
// General help :: print all non-internal commands & their 1st line of text
5657
let cmds = Object.keys(tree).filter(k => !/__/.test(k));
5758
let text = cmds.map(k => [k, (tree[k].describe || [''])[0]]);

readme.md

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,94 @@ prog
141141
```
142142

143143

144+
## Single Command Mode
145+
146+
In certain circumstances, you may only need `sade` for a single-command CLI application.
147+
148+
> **Note:** Until `v1.6.0`, this made for an awkward pairing.
149+
150+
To enable this, you may make use of the [`isSingle`](#issingle) argument. Doing so allows you to pass the program's entire [`usage` text](#usage-1) into the `name` argument.
151+
152+
With "Single Command Mode" enabled, your entire binary operates as one command. This means that any [`prog.command`](#progcommandusage-desc-opts) calls are disallowed & will instead throw an Error. Of course, you may still define a program version, a description, an example or two, and declare options. You are customizing the program's attributes as a whole.<sup>*</sup>
153+
154+
> <sup>*</sup> This is true for multi-command applications, too, up until your first `prog.command()` call!
155+
156+
***Example***
157+
158+
Let's reconstruct [`sirv-cli`](https://github.com/lukeed/sirv), which is a single-command application that (optionally) accepts a directory from which to serve files. It also offers a slew of option flags:
159+
160+
```js
161+
sade('sirv [dir]', true)
162+
.version('1.0.0')
163+
.describe('Run a static file server')
164+
.example('public -qeim 31536000')
165+
.example('--port 8080 --etag')
166+
.example('my-app --dev')
167+
.option('-D, --dev', 'Enable "dev" mode')
168+
.option('-e, --etag', 'Enable "Etag" header')
169+
// There are a lot...
170+
.option('-H, --host', 'Hostname to bind', 'localhost')
171+
.option('-p, --port', 'Port to bind', 5000)
172+
.action((dir, opts) => {
173+
// Program handler
174+
})
175+
.parse(process.argv);
176+
```
177+
178+
When `sirv --help` is run, the generated help text is trimmed, fully aware that there's only one command in this program:
179+
180+
```
181+
Description
182+
Run a static file server
183+
184+
Usage
185+
$ sirv [dir] [options]
186+
187+
Options
188+
-D, --dev Enable "dev" mode
189+
-e, --etag Enable "Etag" header
190+
-H, --host Hostname to bind (default localhost)
191+
-p, --port Port to bind (default 5000)
192+
-v, --version Displays current version
193+
-h, --help Displays this message
194+
195+
Examples
196+
$ sirv public -qeim 31536000
197+
$ sirv --port 8080 --etag
198+
$ sirv my-app --dev
199+
```
200+
201+
202+
144203
## API
145204

146-
### sade(name)
205+
### sade(name, isSingle)
206+
Returns: `Program`
207+
208+
Returns your chainable Sade instance, aka your `Program`.
147209

148210
#### name
149-
150211
Type: `String`<br>
151-
Returns: `Program`
212+
Required: `true`
213+
214+
The name of your `Program` / binary application.
215+
216+
#### isSingle
217+
Type: `Boolean`<br>
218+
Default: `name.includes(' ');`
219+
220+
If your `Program` is meant to have ***only one command***.<br>
221+
When `true`, this simplifies your generated `--help` output such that:
222+
223+
* the "root-level help" is your _only_ help text
224+
* the "root-level help" does not display an `Available Commands` section
225+
* the "root-level help" does not inject `$ name <command>` into the `Usage` section
226+
* the "root-level help" does not display `For more info, run any command with the `--help` flag` text
227+
228+
You may customize the `Usage` of your command by modifying the `name` argument directly.<br>
229+
Please read [Single Command Mode](#single-command-mode) for an example and more information.
152230

153-
The name of your bin/program. Returns the `Program` itself, wherein all other methods are available.
231+
> **Important:** Whenever `name` includes a custom usage, then `isSingle` is automatically assumed and enforced!
154232
155233
### prog.command(usage, desc, opts)
156234

test/fixtures/single1.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env node
2+
const sade = require('../../lib');
3+
4+
sade('bin <type> [dir]')
5+
.describe('hello description')
6+
.option('-g, --global', 'flag 1')
7+
.action((type, dir, opts) => {
8+
dir = dir || '~default~';
9+
console.log(`~> ran "single" w/ "${type}" and "${dir}" values`);
10+
})
11+
.parse(process.argv);

test/fixtures/single2.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env node
2+
const sade = require('../../lib');
3+
4+
sade('bin', true)
5+
.describe('hello description')
6+
.option('-g, --global', 'flag 1')
7+
.action(opts => {
8+
console.log(`~> ran "single" with: ${JSON.stringify(opts)}`);
9+
})
10+
.parse(process.argv);

test/fixtures/single3.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env node
2+
const sade = require('../../lib');
3+
4+
sade('bin', true)
5+
.command('foo <bar>')
6+
.action((bar, opts) => {
7+
console.log(`~> ran "foo" with: ${JSON.stringify(opts)}`);
8+
})
9+
.parse(process.argv);

test/index.js

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,6 @@ test('sade()', t => {
2727
for (k in ctx.tree) {
2828
isShapely(t, ctx.tree, k);
2929
}
30-
let obj = ctx.tree.__default__;
31-
t.deepEqual(obj.alias, { v:['version'] }, 'add `-v, --version` alias');
32-
t.deepEqual(obj.options[0], ['-v, --version', 'Displays current version'], 'add `-v, --version` flag');
3330
t.end();
3431
});
3532

@@ -215,7 +212,7 @@ test('prog.action (multi optional)', t => {
215212
(c=true) && run(); // +4 tests
216213
});
217214

218-
test('parse lazy', t => {
215+
test('prog.parse :: lazy', t => {
219216
t.plan(14);
220217

221218
let val='aaa', f=false;
@@ -248,3 +245,35 @@ test('parse lazy', t => {
248245

249246
bar.handler.apply(null, bar.args); // manual bcuz lazy; +2 tests
250247
});
248+
249+
test('prog.parse :: lazy :: single', t => {
250+
t.plan(14);
251+
252+
let val='aaa', f=false;
253+
254+
let ctx = sade('foo <src>').option('--force').action((src, opts) => {
255+
t.is(src, val, '~> receives `src` param first');
256+
f && t.ok(opts.force, '~> receives the `force` flag (true) when parsed');
257+
});
258+
259+
let run = _ => ctx.parse(['', '', val, f && '--force'], { lazy:true });
260+
261+
let foo = run();
262+
t.is(foo.constructor, Object, 'returns an object');
263+
t.same(Object.keys(foo), ['args', 'name', 'handler'], 'contains `args`,`name`,`handler` keys');
264+
t.ok(Array.isArray(foo.args), '~> returns the array of arguments');
265+
t.is(foo.args[0], val, '~> preserves the `src` value first');
266+
t.is(foo.args[1].constructor, Object, '~> preserves the `opts` value last');
267+
t.ok(Array.isArray(foo.args[1]._), '~> ensures `opts._` is still `[]` at least');
268+
t.is(typeof foo.handler, 'function', '~> returns the action handler');
269+
t.is(foo.name, '', '~> returns empty command name');
270+
271+
foo.handler.apply(null, foo.args); // must be manual bcuz lazy; +1 test
272+
273+
let bar = run(f=true);
274+
t.is(bar.constructor, Object, 'returns an object');
275+
t.is(bar.args[1].constructor, Object, '~> preserves the `opts` value last');
276+
t.is(bar.args[1].force, true, '~> attaches the `force:true` option');
277+
278+
bar.handler.apply(null, bar.args); // manual bcuz lazy; +2 tests
279+
});

0 commit comments

Comments
 (0)