clap-ts 0.2.0 → 0.3.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/README.md +718 -102
- package/dist/argfile.d.ts +55 -0
- package/dist/argfile.js +155 -0
- package/dist/completions.d.ts +4 -1
- package/dist/completions.js +199 -16
- package/dist/config.d.ts +75 -0
- package/dist/config.js +134 -0
- package/dist/help.d.ts +4 -4
- package/dist/help.js +276 -81
- package/dist/index.d.ts +2 -3
- package/dist/index.js +3 -3
- package/dist/install.d.ts +55 -0
- package/dist/install.js +185 -0
- package/dist/log.d.ts +78 -0
- package/dist/log.js +164 -0
- package/dist/man.d.ts +28 -0
- package/dist/man.js +234 -0
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.js +165 -0
- package/dist/output.d.ts +111 -0
- package/dist/output.js +356 -0
- package/dist/parser.d.ts +40 -19
- package/dist/parser.js +789 -461
- package/dist/plugins.d.ts +58 -0
- package/dist/plugins.js +145 -0
- package/dist/progress.d.ts +89 -0
- package/dist/progress.js +205 -0
- package/dist/prompt.d.ts +99 -0
- package/dist/prompt.js +299 -0
- package/dist/runner.d.ts +5 -2
- package/dist/runner.js +341 -135
- package/dist/spec.d.ts +83 -0
- package/dist/spec.js +124 -0
- package/dist/testing.d.ts +59 -0
- package/dist/testing.js +113 -0
- package/dist/types.d.ts +279 -11
- package/dist/validation.js +191 -69
- package/package.json +63 -6
package/dist/install.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put generated completions and man pages where the system will find them.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { withInstallers } from 'clap-ts/install';
|
|
6
|
+
*
|
|
7
|
+
* runMain(withInstallers(main));
|
|
8
|
+
* // my-tool completions install zsh
|
|
9
|
+
* // my-tool man install
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Everything respects `XDG_DATA_HOME`, and nothing is written until the target
|
|
13
|
+
* directory is created, so a dry run can report the path without touching disk.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { generateCompletions } from './completions.js';
|
|
19
|
+
import { generateManPages } from './man.js';
|
|
20
|
+
const XDG_DATA = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share');
|
|
21
|
+
const XDG_CONFIG = process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
|
|
22
|
+
/**
|
|
23
|
+
* The per-user completion path for a shell.
|
|
24
|
+
*
|
|
25
|
+
* bash, zsh and fish load these directories on their own. powershell, elvish
|
|
26
|
+
* and nushell have no drop-in directory, so those report a line to add to the
|
|
27
|
+
* profile instead.
|
|
28
|
+
*/
|
|
29
|
+
export function completionTarget(shell, binaryName) {
|
|
30
|
+
switch (shell) {
|
|
31
|
+
case 'bash':
|
|
32
|
+
return { dir: join(XDG_DATA, 'bash-completion', 'completions'), file: binaryName };
|
|
33
|
+
case 'zsh':
|
|
34
|
+
return {
|
|
35
|
+
dir: join(XDG_DATA, 'zsh', 'site-functions'),
|
|
36
|
+
file: `_${binaryName}`,
|
|
37
|
+
manualStep: `add ${join(XDG_DATA, 'zsh', 'site-functions')} to $fpath before compinit`,
|
|
38
|
+
};
|
|
39
|
+
case 'fish':
|
|
40
|
+
return { dir: join(XDG_CONFIG, 'fish', 'completions'), file: `${binaryName}.fish` };
|
|
41
|
+
case 'powershell':
|
|
42
|
+
return {
|
|
43
|
+
dir: join(XDG_CONFIG, 'powershell', 'completions'),
|
|
44
|
+
file: `${binaryName}.ps1`,
|
|
45
|
+
manualStep: `add \`. ${join(XDG_CONFIG, 'powershell', 'completions', `${binaryName}.ps1`)}\` to $PROFILE`,
|
|
46
|
+
};
|
|
47
|
+
case 'elvish':
|
|
48
|
+
return {
|
|
49
|
+
dir: join(XDG_CONFIG, 'elvish', 'lib'),
|
|
50
|
+
file: `${binaryName}.elv`,
|
|
51
|
+
manualStep: `add \`use ${binaryName}\` to ~/.config/elvish/rc.elv`,
|
|
52
|
+
};
|
|
53
|
+
case 'nushell':
|
|
54
|
+
return {
|
|
55
|
+
dir: join(XDG_CONFIG, 'nushell', 'completions'),
|
|
56
|
+
file: `${binaryName}.nu`,
|
|
57
|
+
manualStep: `add \`source ${join(XDG_CONFIG, 'nushell', 'completions', `${binaryName}.nu`)}\` to your config`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function write(dir, file, contents, dryRun) {
|
|
62
|
+
const path = join(dir, file);
|
|
63
|
+
if (!dryRun) {
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
writeFileSync(path, contents);
|
|
66
|
+
}
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
/** Write the completion script for one shell to its per-user location. */
|
|
70
|
+
export function installCompletions(command, shell, opts) {
|
|
71
|
+
const binaryName = opts?.binaryName ?? command.meta.binName ?? command.meta.name;
|
|
72
|
+
const target = completionTarget(shell, binaryName);
|
|
73
|
+
const dir = opts?.dir ?? target.dir;
|
|
74
|
+
const dryRun = opts?.dryRun === true;
|
|
75
|
+
const path = write(dir, target.file, generateCompletions(command, shell, binaryName), dryRun);
|
|
76
|
+
return {
|
|
77
|
+
paths: [path],
|
|
78
|
+
...(target.manualStep === undefined ? {} : { manualStep: target.manualStep }),
|
|
79
|
+
dryRun,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Write a man page per command into the per-user man directory. */
|
|
83
|
+
export function installManPages(command, opts) {
|
|
84
|
+
const binaryName = opts?.binaryName ?? command.meta.binName ?? command.meta.name;
|
|
85
|
+
const dryRun = opts?.dryRun === true;
|
|
86
|
+
const pages = generateManPages(command, { name: binaryName });
|
|
87
|
+
const dir = opts?.dir ?? join(XDG_DATA, 'man', 'man1');
|
|
88
|
+
const paths = [];
|
|
89
|
+
for (const [file, roff] of pages) {
|
|
90
|
+
paths.push(write(dir, file, roff, dryRun));
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
paths,
|
|
94
|
+
manualStep: `add ${join(XDG_DATA, 'man')} to $MANPATH if your system does not read it already`,
|
|
95
|
+
dryRun,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const SHELLS = ['bash', 'zsh', 'fish', 'powershell', 'elvish', 'nushell'];
|
|
99
|
+
/**
|
|
100
|
+
* Add `completions` and `man` subcommands that both print and install.
|
|
101
|
+
*
|
|
102
|
+
* `tool completions bash` writes the script to stdout as before;
|
|
103
|
+
* `tool completions install bash` puts it where the shell will find it.
|
|
104
|
+
*/
|
|
105
|
+
export function withInstallers(rootCommand) {
|
|
106
|
+
const completions = {
|
|
107
|
+
meta: {
|
|
108
|
+
name: 'completions',
|
|
109
|
+
description: 'Print or install a shell completion script',
|
|
110
|
+
aliases: ['completion'],
|
|
111
|
+
// `completions install zsh` names the shell on the subcommand, so the
|
|
112
|
+
// shell positional here is only required when printing.
|
|
113
|
+
subcommandNegatesReqs: true,
|
|
114
|
+
},
|
|
115
|
+
args: {
|
|
116
|
+
shell: {
|
|
117
|
+
type: 'positional',
|
|
118
|
+
valueName: 'SHELL',
|
|
119
|
+
required: true,
|
|
120
|
+
valueParser: [...SHELLS],
|
|
121
|
+
description: `Target shell: ${SHELLS.join(', ')}`,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
subCommands: {
|
|
125
|
+
install: {
|
|
126
|
+
meta: { name: 'install', description: 'Write the script where the shell will find it' },
|
|
127
|
+
args: {
|
|
128
|
+
shell: {
|
|
129
|
+
type: 'positional',
|
|
130
|
+
valueName: 'SHELL',
|
|
131
|
+
required: true,
|
|
132
|
+
valueParser: [...SHELLS],
|
|
133
|
+
description: 'Shell to install for',
|
|
134
|
+
},
|
|
135
|
+
dryRun: { type: 'boolean', description: 'Report the path without writing' },
|
|
136
|
+
},
|
|
137
|
+
run({ args, stdout }) {
|
|
138
|
+
const result = installCompletions(rootCommand, args['shell'], {
|
|
139
|
+
dryRun: args['dryRun'] === true,
|
|
140
|
+
});
|
|
141
|
+
const verb = result.dryRun ? 'would write' : 'wrote';
|
|
142
|
+
stdout.write(`${verb} ${result.paths[0]}\n`);
|
|
143
|
+
if (result.manualStep !== undefined) {
|
|
144
|
+
stdout.write(`note: ${result.manualStep}\n`);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
run({ args, stdout }) {
|
|
150
|
+
stdout.write(generateCompletions(rootCommand, args['shell']));
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const man = {
|
|
154
|
+
meta: { name: 'man', description: 'Print or install man pages' },
|
|
155
|
+
args: {
|
|
156
|
+
dryRun: { type: 'boolean', description: 'Report the paths without writing' },
|
|
157
|
+
},
|
|
158
|
+
subCommands: {
|
|
159
|
+
install: {
|
|
160
|
+
meta: { name: 'install', description: 'Write man pages where man will find them' },
|
|
161
|
+
args: { dryRun: { type: 'boolean', description: 'Report the paths without writing' } },
|
|
162
|
+
run({ args, stdout }) {
|
|
163
|
+
const result = installManPages(rootCommand, { dryRun: args['dryRun'] === true });
|
|
164
|
+
const verb = result.dryRun ? 'would write' : 'wrote';
|
|
165
|
+
for (const path of result.paths) {
|
|
166
|
+
stdout.write(`${verb} ${path}\n`);
|
|
167
|
+
}
|
|
168
|
+
if (result.manualStep !== undefined) {
|
|
169
|
+
stdout.write(`note: ${result.manualStep}\n`);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
run({ stdout }) {
|
|
175
|
+
for (const roff of generateManPages(rootCommand).values()) {
|
|
176
|
+
stdout.write(roff);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
...rootCommand,
|
|
182
|
+
subCommands: { ...rootCommand.subCommands, completions, man },
|
|
183
|
+
lazySubCommands: rootCommand.lazySubCommands,
|
|
184
|
+
};
|
|
185
|
+
}
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A levelled logger wired to the verbosity arguments a CLI already declares.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { loggerFrom } from 'clap-ts/log';
|
|
6
|
+
*
|
|
7
|
+
* const main = defineCommand({
|
|
8
|
+
* meta: { name: 'tool' },
|
|
9
|
+
* args: {
|
|
10
|
+
* verbose: { type: 'boolean', short: 'v', action: 'count', description: 'More output' },
|
|
11
|
+
* quiet: { type: 'boolean', short: 'q', description: 'Errors only' },
|
|
12
|
+
* },
|
|
13
|
+
* run(ctx) {
|
|
14
|
+
* const log = loggerFrom(ctx);
|
|
15
|
+
* log.info('starting'); // shown by default
|
|
16
|
+
* log.debug('details'); // shown with -v
|
|
17
|
+
* },
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Everything goes to stderr, leaving stdout for the command's actual output so
|
|
22
|
+
* a pipeline is not polluted by progress chatter.
|
|
23
|
+
*/
|
|
24
|
+
import type { OutputSink } from './types.js';
|
|
25
|
+
/** Ordered from quietest to loudest. */
|
|
26
|
+
export declare const LEVELS: readonly ['silent', 'error', 'warn', 'info', 'debug', 'trace'];
|
|
27
|
+
export type LogLevel = (typeof LEVELS)[number];
|
|
28
|
+
export interface Logger {
|
|
29
|
+
readonly level: LogLevel;
|
|
30
|
+
/** Whether a message at this level would be shown. */
|
|
31
|
+
enabled(level: LogLevel): boolean;
|
|
32
|
+
error(message: string, ...rest: unknown[]): void;
|
|
33
|
+
warn(message: string, ...rest: unknown[]): void;
|
|
34
|
+
info(message: string, ...rest: unknown[]): void;
|
|
35
|
+
debug(message: string, ...rest: unknown[]): void;
|
|
36
|
+
trace(message: string, ...rest: unknown[]): void;
|
|
37
|
+
/** A logger writing the same place at a different level. */
|
|
38
|
+
withLevel(level: LogLevel): Logger;
|
|
39
|
+
}
|
|
40
|
+
export interface LoggerOptions {
|
|
41
|
+
/** Level to log at (default 'info'). */
|
|
42
|
+
readonly level?: LogLevel;
|
|
43
|
+
/** Where messages go (default process.stderr). */
|
|
44
|
+
readonly sink?: OutputSink;
|
|
45
|
+
/** Prefix each line, for instance with the tool name. */
|
|
46
|
+
readonly prefix?: string;
|
|
47
|
+
/** Force colour on or off; defaults to whatever the stream supports. */
|
|
48
|
+
readonly color?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/** Build a logger writing to a sink. */
|
|
51
|
+
export declare function createLogger(opts?: LoggerOptions): Logger;
|
|
52
|
+
export interface LevelFromArgsOptions {
|
|
53
|
+
/** Name of the count-action verbosity arg (default 'verbose'). */
|
|
54
|
+
readonly verboseKey?: string;
|
|
55
|
+
/** Name of the quiet flag (default 'quiet'). */
|
|
56
|
+
readonly quietKey?: string;
|
|
57
|
+
/** Name of an explicit level arg, which overrides the other two. */
|
|
58
|
+
readonly levelKey?: string;
|
|
59
|
+
/** Level with no flags given (default 'info'). */
|
|
60
|
+
readonly base?: LogLevel;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Work out a level from parsed arguments.
|
|
64
|
+
*
|
|
65
|
+
* An explicit level wins. Otherwise `--quiet` drops to errors only, and each
|
|
66
|
+
* `-v` climbs one step, so `-vv` reaches trace from the default of info.
|
|
67
|
+
*/
|
|
68
|
+
export declare function levelFromArgs(args: Record<string, unknown>, opts?: LevelFromArgsOptions): LogLevel;
|
|
69
|
+
/**
|
|
70
|
+
* A logger for a command context, taking its level from the parsed arguments
|
|
71
|
+
* and writing to the context's stderr so the testing helpers capture it.
|
|
72
|
+
*/
|
|
73
|
+
export declare function loggerFrom(ctx: {
|
|
74
|
+
readonly args: unknown;
|
|
75
|
+
readonly stderr: OutputSink;
|
|
76
|
+
}, opts?: LoggerOptions & LevelFromArgsOptions): Logger;
|
|
77
|
+
/** A logger that discards everything, for tests and dry runs. */
|
|
78
|
+
export declare const silentLogger: Logger;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A levelled logger wired to the verbosity arguments a CLI already declares.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { loggerFrom } from 'clap-ts/log';
|
|
6
|
+
*
|
|
7
|
+
* const main = defineCommand({
|
|
8
|
+
* meta: { name: 'tool' },
|
|
9
|
+
* args: {
|
|
10
|
+
* verbose: { type: 'boolean', short: 'v', action: 'count', description: 'More output' },
|
|
11
|
+
* quiet: { type: 'boolean', short: 'q', description: 'Errors only' },
|
|
12
|
+
* },
|
|
13
|
+
* run(ctx) {
|
|
14
|
+
* const log = loggerFrom(ctx);
|
|
15
|
+
* log.info('starting'); // shown by default
|
|
16
|
+
* log.debug('details'); // shown with -v
|
|
17
|
+
* },
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Everything goes to stderr, leaving stdout for the command's actual output so
|
|
22
|
+
* a pipeline is not polluted by progress chatter.
|
|
23
|
+
*/
|
|
24
|
+
import { styleText } from 'node:util';
|
|
25
|
+
/** Ordered from quietest to loudest. */
|
|
26
|
+
export const LEVELS = ['silent', 'error', 'warn', 'info', 'debug', 'trace'];
|
|
27
|
+
const RANK = {
|
|
28
|
+
silent: 0,
|
|
29
|
+
error: 1,
|
|
30
|
+
warn: 2,
|
|
31
|
+
info: 3,
|
|
32
|
+
debug: 4,
|
|
33
|
+
trace: 5,
|
|
34
|
+
};
|
|
35
|
+
const PLAIN = (text) => text;
|
|
36
|
+
function labels(color) {
|
|
37
|
+
const paint = (codes, text) => color ? styleText(codes, text, { validateStream: false }) : text;
|
|
38
|
+
return {
|
|
39
|
+
error: paint('red', 'error'),
|
|
40
|
+
warn: paint('yellow', 'warning'),
|
|
41
|
+
info: paint('cyan', 'info'),
|
|
42
|
+
debug: paint('magenta', 'debug'),
|
|
43
|
+
trace: paint('gray', 'trace'),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Render one extra value.
|
|
48
|
+
*
|
|
49
|
+
* An Error is the most common thing to log and `JSON.stringify` turns it into
|
|
50
|
+
* `{}`, losing the message. A cyclic object throws outright, which would take
|
|
51
|
+
* the CLI down for the sake of a log line, so serialisation falls back to
|
|
52
|
+
* String rather than propagating.
|
|
53
|
+
*/
|
|
54
|
+
function renderValue(value) {
|
|
55
|
+
if (typeof value === 'string') {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
if (value instanceof Error) {
|
|
59
|
+
return value.stack ?? `${value.name}: ${value.message}`;
|
|
60
|
+
}
|
|
61
|
+
if (value === undefined) {
|
|
62
|
+
return 'undefined';
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === 'bigint' || typeof value === 'symbol' || typeof value === 'function') {
|
|
65
|
+
return String(value);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return JSON.stringify(value, safeReplacer()) ?? String(value);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return String(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** A replacer that names a cycle instead of throwing on it. */
|
|
75
|
+
function safeReplacer() {
|
|
76
|
+
const seen = new WeakSet();
|
|
77
|
+
return (_key, value) => {
|
|
78
|
+
if (typeof value !== 'object' || value === null) {
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
if (seen.has(value)) {
|
|
82
|
+
return '[Circular]';
|
|
83
|
+
}
|
|
84
|
+
seen.add(value);
|
|
85
|
+
return value;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function format(rest) {
|
|
89
|
+
if (rest.length === 0) {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
return ` ${rest.map(renderValue).join(' ')}`;
|
|
93
|
+
}
|
|
94
|
+
/** Build a logger writing to a sink. */
|
|
95
|
+
export function createLogger(opts) {
|
|
96
|
+
const level = opts?.level ?? 'info';
|
|
97
|
+
const sink = opts?.sink ?? process.stderr;
|
|
98
|
+
const color = opts?.color ?? styleText('red', 'x') !== 'x';
|
|
99
|
+
const tag = labels(color);
|
|
100
|
+
const prefix = opts?.prefix === undefined ? '' : `${opts.prefix} `;
|
|
101
|
+
const threshold = RANK[level];
|
|
102
|
+
const write = (at, message, rest) => {
|
|
103
|
+
if (RANK[at] > threshold) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
sink.write(`${prefix}${tag[at]}: ${message}${format(rest)}\n`);
|
|
107
|
+
};
|
|
108
|
+
const logger = {
|
|
109
|
+
level,
|
|
110
|
+
enabled: (at) => RANK[at] <= threshold,
|
|
111
|
+
error: (message, ...rest) => {
|
|
112
|
+
write('error', message, rest);
|
|
113
|
+
},
|
|
114
|
+
warn: (message, ...rest) => {
|
|
115
|
+
write('warn', message, rest);
|
|
116
|
+
},
|
|
117
|
+
info: (message, ...rest) => {
|
|
118
|
+
write('info', message, rest);
|
|
119
|
+
},
|
|
120
|
+
debug: (message, ...rest) => {
|
|
121
|
+
write('debug', message, rest);
|
|
122
|
+
},
|
|
123
|
+
trace: (message, ...rest) => {
|
|
124
|
+
write('trace', message, rest);
|
|
125
|
+
},
|
|
126
|
+
withLevel: (next) => createLogger({ ...opts, level: next }),
|
|
127
|
+
};
|
|
128
|
+
return logger;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Work out a level from parsed arguments.
|
|
132
|
+
*
|
|
133
|
+
* An explicit level wins. Otherwise `--quiet` drops to errors only, and each
|
|
134
|
+
* `-v` climbs one step, so `-vv` reaches trace from the default of info.
|
|
135
|
+
*/
|
|
136
|
+
export function levelFromArgs(args, opts) {
|
|
137
|
+
const explicit = args[opts?.levelKey ?? 'logLevel'];
|
|
138
|
+
if (typeof explicit === 'string' && LEVELS.includes(explicit)) {
|
|
139
|
+
return explicit;
|
|
140
|
+
}
|
|
141
|
+
if (args[opts?.quietKey ?? 'quiet'] === true) {
|
|
142
|
+
return 'error';
|
|
143
|
+
}
|
|
144
|
+
const base = RANK[opts?.base ?? 'info'];
|
|
145
|
+
const verbose = args[opts?.verboseKey ?? 'verbose'];
|
|
146
|
+
const steps = typeof verbose === 'number' ? verbose : verbose === true ? 1 : 0;
|
|
147
|
+
return LEVELS[Math.min(base + steps, LEVELS.length - 1)];
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A logger for a command context, taking its level from the parsed arguments
|
|
151
|
+
* and writing to the context's stderr so the testing helpers capture it.
|
|
152
|
+
*/
|
|
153
|
+
export function loggerFrom(
|
|
154
|
+
// Structural rather than CommandContext<T>, so any command's context fits
|
|
155
|
+
// without the generic having to be named at the call site.
|
|
156
|
+
ctx, opts) {
|
|
157
|
+
return createLogger({
|
|
158
|
+
...opts,
|
|
159
|
+
sink: opts?.sink ?? ctx.stderr,
|
|
160
|
+
level: opts?.level ?? levelFromArgs(ctx.args, opts),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** A logger that discards everything, for tests and dry runs. */
|
|
164
|
+
export const silentLogger = createLogger({ level: 'silent', sink: { write: PLAIN } });
|
package/dist/man.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Man page generation, matching the roff clap_mangen produces.
|
|
3
|
+
*
|
|
4
|
+
* A page carries the sections `man` expects in order: NAME, SYNOPSIS,
|
|
5
|
+
* DESCRIPTION, OPTIONS, SUBCOMMANDS, then the extra text, VERSION and AUTHORS.
|
|
6
|
+
* Subcommands get their own pages, named `parent-child.1` the way clap does.
|
|
7
|
+
*/
|
|
8
|
+
import type { CommandDef, ManOptions } from './types.js';
|
|
9
|
+
export type { ManOptions } from './types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Render a man page for one command as roff source.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* writeFileSync('my-tool.1', renderManPage(command));
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare function renderManPage(command: CommandDef, opts?: ManOptions): string;
|
|
18
|
+
/**
|
|
19
|
+
* Render a man page for the command and every subcommand beneath it, keyed by
|
|
20
|
+
* file name. Nested pages are named `parent-child.1`, as clap_mangen does.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* for (const [file, roff] of generateManPages(command)) {
|
|
24
|
+
* writeFileSync(path.join(outDir, file), roff);
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function generateManPages(command: CommandDef, opts?: ManOptions): Map<string, string>;
|
package/dist/man.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Man page generation, matching the roff clap_mangen produces.
|
|
3
|
+
*
|
|
4
|
+
* A page carries the sections `man` expects in order: NAME, SYNOPSIS,
|
|
5
|
+
* DESCRIPTION, OPTIONS, SUBCOMMANDS, then the extra text, VERSION and AUTHORS.
|
|
6
|
+
* Subcommands get their own pages, named `parent-child.1` the way clap does.
|
|
7
|
+
*/
|
|
8
|
+
import { hasSubCommands, possibleValues, subCommandsOf } from './parser.js';
|
|
9
|
+
// ---- Escaping ----
|
|
10
|
+
/**
|
|
11
|
+
* Escape text for roff. A leading dot would start a request, a backslash starts
|
|
12
|
+
* an escape, a hyphen renders as a soft hyphen unless escaped, and an
|
|
13
|
+
* apostrophe goes through the \*(Aq string defined in the preamble.
|
|
14
|
+
*/
|
|
15
|
+
function esc(text) {
|
|
16
|
+
return text
|
|
17
|
+
.replaceAll('\\', '\\e')
|
|
18
|
+
.replaceAll("'", '\\*(Aq')
|
|
19
|
+
.replaceAll('-', '\\-')
|
|
20
|
+
.replaceAll(/^\./gm, '\\&.');
|
|
21
|
+
}
|
|
22
|
+
function bold(text) {
|
|
23
|
+
return `\\fB${esc(text)}\\fR`;
|
|
24
|
+
}
|
|
25
|
+
function italic(text) {
|
|
26
|
+
return `\\fI${esc(text)}\\fR`;
|
|
27
|
+
}
|
|
28
|
+
// ---- Arg helpers ----
|
|
29
|
+
function valuePlaceholder(key, def) {
|
|
30
|
+
if (def.valueNames && def.valueNames.length > 0) {
|
|
31
|
+
return def.valueNames.map((n) => italic(n)).join(' ');
|
|
32
|
+
}
|
|
33
|
+
return italic(def.valueName ?? (def.long ?? key).toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
/** The bracket pair around an option in the synopsis: required or optional. */
|
|
36
|
+
function markers(def) {
|
|
37
|
+
return def.required ? ['', ''] : ['[', ']'];
|
|
38
|
+
}
|
|
39
|
+
/** `\fB-c\fR|\fB--config\fR` for one option, with its value if it takes one. */
|
|
40
|
+
function optionForms(key, def) {
|
|
41
|
+
const forms = [];
|
|
42
|
+
if (def.short) {
|
|
43
|
+
forms.push(bold(`-${def.short}`));
|
|
44
|
+
}
|
|
45
|
+
forms.push(bold(`--${def.long ?? key}`));
|
|
46
|
+
let rendered = forms.join('|');
|
|
47
|
+
if (def.type !== 'boolean' && def.action !== 'count') {
|
|
48
|
+
rendered += `=${valuePlaceholder(key, def)}`;
|
|
49
|
+
}
|
|
50
|
+
return rendered;
|
|
51
|
+
}
|
|
52
|
+
function isVisible(def) {
|
|
53
|
+
return !def.hidden && !def.hideLongHelp;
|
|
54
|
+
}
|
|
55
|
+
// ---- Sections ----
|
|
56
|
+
function renderSynopsis(name, command) {
|
|
57
|
+
const argsDef = command.args ?? {};
|
|
58
|
+
const parts = [bold(name)];
|
|
59
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
60
|
+
if (def.type === 'positional' || !isVisible(def)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const [open, close] = markers(def);
|
|
64
|
+
const repeat = def.action === 'append' || def.action === 'count' ? '...' : '';
|
|
65
|
+
parts.push(`${open}${optionForms(key, def)}${close}${repeat}`);
|
|
66
|
+
}
|
|
67
|
+
if (command.meta.disableHelpFlag !== true) {
|
|
68
|
+
parts.push(`[${bold('-h')}|${bold('--help')}]`);
|
|
69
|
+
}
|
|
70
|
+
if (command.meta.version && command.meta.disableVersionFlag !== true) {
|
|
71
|
+
parts.push(`[${bold('-V')}|${bold('--version')}]`);
|
|
72
|
+
}
|
|
73
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
74
|
+
if (def.type !== 'positional' || !isVisible(def)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const name_ = italic(def.valueName ?? key);
|
|
78
|
+
const repeat = def.trailingVarArg ? '...' : '';
|
|
79
|
+
parts.push(def.required ? `${name_}${repeat}` : `[${name_}]${repeat}`);
|
|
80
|
+
}
|
|
81
|
+
if (hasSubCommands(command)) {
|
|
82
|
+
parts.push(`[${italic('subcommands')}]`);
|
|
83
|
+
}
|
|
84
|
+
return parts.join(' ');
|
|
85
|
+
}
|
|
86
|
+
/** A `.TP` entry: the term, its description, then any trailing notes. */
|
|
87
|
+
function renderEntry(term, def, key, lines) {
|
|
88
|
+
lines.push('.TP');
|
|
89
|
+
lines.push(term);
|
|
90
|
+
const description = def.longDescription ?? def.description ?? '';
|
|
91
|
+
lines.push(description ? esc(description) : '');
|
|
92
|
+
const notes = [];
|
|
93
|
+
if (def.default !== undefined && !def.hideDefaultValue) {
|
|
94
|
+
const shown = Array.isArray(def.default) ? def.default.join(', ') : String(def.default);
|
|
95
|
+
notes.push(`${italic('Default value:')} ${esc(shown)}`);
|
|
96
|
+
}
|
|
97
|
+
if (def.env && !def.hideEnv) {
|
|
98
|
+
notes.push(`${italic('Environment:')} ${esc(def.env)}`);
|
|
99
|
+
}
|
|
100
|
+
for (const note of notes) {
|
|
101
|
+
lines.push('.br');
|
|
102
|
+
lines.push(note);
|
|
103
|
+
}
|
|
104
|
+
const values = def.hidePossibleValues ? [] : possibleValues(def).filter((v) => !v.hidden);
|
|
105
|
+
if (values.length > 0) {
|
|
106
|
+
lines.push('.br');
|
|
107
|
+
lines.push(`${italic('Possible values:')}`);
|
|
108
|
+
lines.push('.RS 14');
|
|
109
|
+
for (const value of values) {
|
|
110
|
+
lines.push('.IP \\(bu 2');
|
|
111
|
+
lines.push(value.help ? `${esc(value.name)}: ${esc(value.help)}` : esc(value.name));
|
|
112
|
+
}
|
|
113
|
+
lines.push('.RE');
|
|
114
|
+
}
|
|
115
|
+
void key;
|
|
116
|
+
}
|
|
117
|
+
function renderOptions(command, lines) {
|
|
118
|
+
const argsDef = command.args ?? {};
|
|
119
|
+
const options = Object.entries(argsDef).filter(([, def]) => def.type !== 'positional' && isVisible(def));
|
|
120
|
+
const positionals = Object.entries(argsDef).filter(([, def]) => def.type === 'positional' && isVisible(def));
|
|
121
|
+
const hasHelp = command.meta.disableHelpFlag !== true;
|
|
122
|
+
const hasVersion = command.meta.version !== undefined && command.meta.disableVersionFlag !== true;
|
|
123
|
+
if (options.length === 0 && positionals.length === 0 && !hasHelp && !hasVersion) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
lines.push('.SH OPTIONS');
|
|
127
|
+
for (const [key, def] of options) {
|
|
128
|
+
const forms = [];
|
|
129
|
+
if (def.short) {
|
|
130
|
+
forms.push(bold(`-${def.short}`));
|
|
131
|
+
}
|
|
132
|
+
forms.push(bold(`--${def.long ?? key}`));
|
|
133
|
+
let term = forms.join(', ');
|
|
134
|
+
if (def.type !== 'boolean' && def.action !== 'count') {
|
|
135
|
+
term += `=${valuePlaceholder(key, def)}`;
|
|
136
|
+
}
|
|
137
|
+
renderEntry(term, def, key, lines);
|
|
138
|
+
}
|
|
139
|
+
if (hasHelp) {
|
|
140
|
+
lines.push('.TP', `${bold('-h')}, ${bold('--help')}`, 'Print help');
|
|
141
|
+
}
|
|
142
|
+
if (hasVersion) {
|
|
143
|
+
lines.push('.TP', `${bold('-V')}, ${bold('--version')}`, 'Print version');
|
|
144
|
+
}
|
|
145
|
+
for (const [key, def] of positionals) {
|
|
146
|
+
const name = italic(def.valueName ?? key);
|
|
147
|
+
renderEntry(def.required ? name : `[${name}]`, def, key, lines);
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
function renderSubcommands(name, command, lines) {
|
|
152
|
+
const subs = Object.entries(subCommandsOf(command)).filter(([, def]) => !def.meta.hidden);
|
|
153
|
+
if (subs.length === 0) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
lines.push('.SH SUBCOMMANDS');
|
|
157
|
+
for (const [subName, def] of subs) {
|
|
158
|
+
lines.push('.TP');
|
|
159
|
+
lines.push(esc(`${name}-${subName}(1)`));
|
|
160
|
+
lines.push(esc(def.meta.description ?? def.meta.about ?? ''));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// ---- Public API ----
|
|
164
|
+
/**
|
|
165
|
+
* Render a man page for one command as roff source.
|
|
166
|
+
*
|
|
167
|
+
* ```ts
|
|
168
|
+
* writeFileSync('my-tool.1', renderManPage(command));
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function renderManPage(command, opts) {
|
|
172
|
+
const { meta } = command;
|
|
173
|
+
const name = opts?.name ?? meta.binName ?? meta.displayName ?? meta.name;
|
|
174
|
+
const section = opts?.section ?? '1';
|
|
175
|
+
const manual = opts?.manual ?? '';
|
|
176
|
+
const title = meta.version ? `${name} ${meta.version}` : name;
|
|
177
|
+
const lines = [
|
|
178
|
+
'.ie \\n(.g .ds Aq \\(aq',
|
|
179
|
+
".el .ds Aq '",
|
|
180
|
+
`.TH ${esc(name)} ${esc(section)} "${esc(manual)}" "${esc(title)}"`,
|
|
181
|
+
];
|
|
182
|
+
lines.push('.SH NAME');
|
|
183
|
+
const summary = meta.description ?? meta.about ?? '';
|
|
184
|
+
lines.push(summary ? `${esc(name)} \\- ${esc(summary)}` : esc(name));
|
|
185
|
+
lines.push('.SH SYNOPSIS');
|
|
186
|
+
lines.push(renderSynopsis(name, command));
|
|
187
|
+
lines.push('.SH DESCRIPTION');
|
|
188
|
+
const description = meta.longAbout ?? meta.about ?? meta.description ?? '';
|
|
189
|
+
if (description) {
|
|
190
|
+
lines.push(esc(description));
|
|
191
|
+
}
|
|
192
|
+
renderOptions(command, lines);
|
|
193
|
+
renderSubcommands(name, command, lines);
|
|
194
|
+
const extra = meta.afterLongHelp ?? meta.afterHelp;
|
|
195
|
+
if (extra) {
|
|
196
|
+
lines.push('.SH EXTRA');
|
|
197
|
+
lines.push(esc(extra));
|
|
198
|
+
}
|
|
199
|
+
if (meta.version) {
|
|
200
|
+
lines.push('.SH VERSION');
|
|
201
|
+
lines.push(`v${esc(meta.longVersion ?? meta.version)}`);
|
|
202
|
+
}
|
|
203
|
+
if (meta.author) {
|
|
204
|
+
lines.push('.SH AUTHORS');
|
|
205
|
+
lines.push(esc(meta.author));
|
|
206
|
+
}
|
|
207
|
+
return `${lines.join('\n')}\n`;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Render a man page for the command and every subcommand beneath it, keyed by
|
|
211
|
+
* file name. Nested pages are named `parent-child.1`, as clap_mangen does.
|
|
212
|
+
*
|
|
213
|
+
* ```ts
|
|
214
|
+
* for (const [file, roff] of generateManPages(command)) {
|
|
215
|
+
* writeFileSync(path.join(outDir, file), roff);
|
|
216
|
+
* }
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
export function generateManPages(command, opts) {
|
|
220
|
+
const pages = new Map();
|
|
221
|
+
const section = opts?.section ?? '1';
|
|
222
|
+
const walk = (node, name) => {
|
|
223
|
+
pages.set(`${name}.${section}`, renderManPage(node, { ...opts, name }));
|
|
224
|
+
for (const [subName, sub] of Object.entries(subCommandsOf(node))) {
|
|
225
|
+
if (sub.meta.hidden) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
walk(sub, `${name}-${subName}`);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const rootName = opts?.name ?? command.meta.binName ?? command.meta.name;
|
|
232
|
+
walk(command, rootName);
|
|
233
|
+
return pages;
|
|
234
|
+
}
|