Skip to content

Commit b2248fa

Browse files
feat(args): add cliApp() runner
1 parent 22e36fa commit b2248fa

File tree

3 files changed

+121
-0
lines changed

3 files changed

+121
-0
lines changed

packages/args/src/api.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Fn, IDeref, IObjectOf } from "@thi.ng/api";
2+
import type { ILogger } from "@thi.ng/logger";
23

34
export interface ArgSpecBase {
45
/**
@@ -196,3 +197,55 @@ export class Tuple<T> implements IDeref<T[]> {
196197
return this.value;
197198
}
198199
}
200+
201+
export interface CLIAppConfig<OPTS extends object> {
202+
/**
203+
* Shared args for all commands
204+
*/
205+
opts: Args<OPTS>;
206+
/**
207+
* Command spec registry
208+
*/
209+
commands: IObjectOf<Command<any, OPTS>>;
210+
/**
211+
* If true, the app will only use the single command entry in
212+
* {@link CLIAppConfig.commands} and not expect the first CLI args to be a
213+
* command name.
214+
*/
215+
single?: boolean;
216+
/**
217+
* Usage options, same as {@link UsageOpts}. Usage will be shown
218+
* automatically in case of arg parse errors.
219+
*/
220+
usage: Partial<UsageOpts>;
221+
/**
222+
* Arguments vector to use for arg parsing. If omitted, uses
223+
* `process.argv.slice(2)`
224+
*/
225+
argv?: string[];
226+
}
227+
228+
export interface Command<T extends BASE, BASE extends object> {
229+
/**
230+
* Command description (short, single line)
231+
*/
232+
desc: string;
233+
/**
234+
* Command specific CLI arg specs
235+
*/
236+
opts: Args<Omit<T, keyof BASE>>;
237+
/**
238+
* Number of required rest input value (after all options)
239+
*/
240+
inputs?: number;
241+
/**
242+
* Actual command function/implementation.
243+
*/
244+
fn: Fn<CommandCtx<T, BASE>, Promise<void>>;
245+
}
246+
247+
export interface CommandCtx<T extends BASE, BASE extends object> {
248+
logger: ILogger;
249+
opts: T;
250+
inputs: string[];
251+
}

packages/args/src/cli.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { illegalArgs } from "@thi.ng/errors";
2+
import { ConsoleLogger } from "@thi.ng/logger/console";
3+
import type { CLIAppConfig, Command, UsageOpts } from "./api.js";
4+
import { parse } from "./parse.js";
5+
import { usage } from "./usage.js";
6+
import { padRight } from "@thi.ng/strings/pad-right";
7+
import type { IObjectOf } from "@thi.ng/api";
8+
9+
export const cliApp = async <T extends object>(config: CLIAppConfig<T>) => {
10+
const argv = config.argv || process.argv.slice(2);
11+
let usageOpts = { prefix: "", ...config.usage };
12+
try {
13+
let cmdID: string;
14+
let cmd: Command<any, T>;
15+
let start = 0;
16+
if (config.single) {
17+
// single command mode, use 1st available name
18+
cmdID = Object.keys(config.commands)[0];
19+
if (!cmdID) illegalArgs("no command provided");
20+
cmd = config.commands[cmdID];
21+
} else {
22+
start = 1;
23+
cmdID = argv[0];
24+
cmd = config.commands[cmdID];
25+
usageOpts.prefix += __descriptions(config.commands);
26+
if (!cmd) __usageAndExit(config, usageOpts);
27+
}
28+
const parsed = parse<T>({ ...config.opts, ...cmd.opts }, argv, {
29+
showUsage: false,
30+
usageOpts,
31+
start,
32+
});
33+
const inputsOk =
34+
parsed && cmd.inputs !== undefined
35+
? cmd.inputs === parsed.rest.length
36+
: true;
37+
if (!(parsed && inputsOk)) {
38+
process.stderr.write(`expected ${cmd.inputs || 0} input(s)\n`);
39+
__usageAndExit(config, usageOpts);
40+
}
41+
await cmd.fn({
42+
logger: new ConsoleLogger("app", "DEBUG"),
43+
opts: parsed!.result,
44+
inputs: parsed!.rest,
45+
});
46+
} catch (e) {
47+
process.stderr.write((<Error>e).message + "\n\n");
48+
__usageAndExit(config, usageOpts);
49+
}
50+
};
51+
52+
const __usageAndExit = <T extends object>(
53+
config: CLIAppConfig<T>,
54+
usageOpts: Partial<UsageOpts>
55+
) => {
56+
process.stderr.write(usage(config.opts, usageOpts));
57+
process.exit(1);
58+
};
59+
60+
const __descriptions = (commands: IObjectOf<Command<any, any>>) =>
61+
[
62+
"\nAvailable commands:\n",
63+
...Object.keys(commands).map(
64+
(x) => `${padRight(16)(x)}: ${commands[x].desc}`
65+
),
66+
"\n\n",
67+
].join("\n");

packages/args/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./api.js";
22
export * from "./args.js";
3+
export * from "./cli.js";
34
export * from "./coerce.js";
45
export * from "./parse.js";
56
export * from "./usage.js";

0 commit comments

Comments
 (0)