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
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clap-ts - A type-safe CLI argument parser for TypeScript, inspired by Rust's clap.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all public API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
export type {
|
|
9
|
+
ArgType,
|
|
10
|
+
ArgAction,
|
|
11
|
+
NumArgs,
|
|
12
|
+
ValueParserFn,
|
|
13
|
+
PossibleValue,
|
|
14
|
+
ValueHint,
|
|
15
|
+
ColorChoice,
|
|
16
|
+
ValueSource,
|
|
17
|
+
Shell,
|
|
18
|
+
OutputSink,
|
|
19
|
+
MissingArg,
|
|
20
|
+
ArgDef,
|
|
21
|
+
ArgsDef,
|
|
22
|
+
StyleFn,
|
|
23
|
+
StylesDef,
|
|
24
|
+
CommandMeta,
|
|
25
|
+
ArgGroup,
|
|
26
|
+
ParsedArgs,
|
|
27
|
+
CommandContext,
|
|
28
|
+
CommandDef,
|
|
29
|
+
RunOptions,
|
|
30
|
+
ParseResult,
|
|
31
|
+
InferArgValue,
|
|
32
|
+
InferArgOptional,
|
|
33
|
+
} from './types.js';
|
|
34
|
+
|
|
35
|
+
// Parser
|
|
36
|
+
export {
|
|
37
|
+
parseArgs,
|
|
38
|
+
getRawArgs,
|
|
39
|
+
collectGlobalArgs,
|
|
40
|
+
mergeGlobalArgs,
|
|
41
|
+
subCommandsOf,
|
|
42
|
+
hasSubCommands,
|
|
43
|
+
possibleValues,
|
|
44
|
+
matchesPossibleValue,
|
|
45
|
+
CliParseError,
|
|
46
|
+
} from './parser.js';
|
|
47
|
+
|
|
48
|
+
// Validation
|
|
49
|
+
export { validate } from './validation.js';
|
|
50
|
+
|
|
51
|
+
// Help renderer
|
|
52
|
+
export { renderHelp, renderUsage, showHelp, showVersion, showError } from './help.js';
|
|
53
|
+
|
|
54
|
+
// Runner (main API)
|
|
55
|
+
export { defineCommand, defineArgs, defineArg, runCommand, runMain } from './runner.js';
|
|
56
|
+
|
|
57
|
+
// Generators live behind subpaths so a running CLI never loads them:
|
|
58
|
+
// clap-ts/completions, clap-ts/man, clap-ts/markdown
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
|
|
16
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import type { CommandDef, Shell } from './types.js';
|
|
20
|
+
import { generateCompletions } from './completions.js';
|
|
21
|
+
import { generateManPages } from './man.js';
|
|
22
|
+
|
|
23
|
+
const XDG_DATA = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share');
|
|
24
|
+
const XDG_CONFIG = process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
|
|
25
|
+
|
|
26
|
+
/** Where a shell looks for user completions, and what the file must be called. */
|
|
27
|
+
export interface CompletionTarget {
|
|
28
|
+
readonly dir: string;
|
|
29
|
+
readonly file: string;
|
|
30
|
+
/** A line the user must add themselves, when sourcing is not automatic. */
|
|
31
|
+
readonly manualStep?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The per-user completion path for a shell.
|
|
36
|
+
*
|
|
37
|
+
* bash, zsh and fish load these directories on their own. powershell, elvish
|
|
38
|
+
* and nushell have no drop-in directory, so those report a line to add to the
|
|
39
|
+
* profile instead.
|
|
40
|
+
*/
|
|
41
|
+
export function completionTarget(shell: Shell, binaryName: string): CompletionTarget {
|
|
42
|
+
switch (shell) {
|
|
43
|
+
case 'bash':
|
|
44
|
+
return { dir: join(XDG_DATA, 'bash-completion', 'completions'), file: binaryName };
|
|
45
|
+
case 'zsh':
|
|
46
|
+
return {
|
|
47
|
+
dir: join(XDG_DATA, 'zsh', 'site-functions'),
|
|
48
|
+
file: `_${binaryName}`,
|
|
49
|
+
manualStep: `add ${join(XDG_DATA, 'zsh', 'site-functions')} to $fpath before compinit`,
|
|
50
|
+
};
|
|
51
|
+
case 'fish':
|
|
52
|
+
return { dir: join(XDG_CONFIG, 'fish', 'completions'), file: `${binaryName}.fish` };
|
|
53
|
+
case 'powershell':
|
|
54
|
+
return {
|
|
55
|
+
dir: join(XDG_CONFIG, 'powershell', 'completions'),
|
|
56
|
+
file: `${binaryName}.ps1`,
|
|
57
|
+
manualStep: `add \`. ${join(XDG_CONFIG, 'powershell', 'completions', `${binaryName}.ps1`)}\` to $PROFILE`,
|
|
58
|
+
};
|
|
59
|
+
case 'elvish':
|
|
60
|
+
return {
|
|
61
|
+
dir: join(XDG_CONFIG, 'elvish', 'lib'),
|
|
62
|
+
file: `${binaryName}.elv`,
|
|
63
|
+
manualStep: `add \`use ${binaryName}\` to ~/.config/elvish/rc.elv`,
|
|
64
|
+
};
|
|
65
|
+
case 'nushell':
|
|
66
|
+
return {
|
|
67
|
+
dir: join(XDG_CONFIG, 'nushell', 'completions'),
|
|
68
|
+
file: `${binaryName}.nu`,
|
|
69
|
+
manualStep: `add \`source ${join(XDG_CONFIG, 'nushell', 'completions', `${binaryName}.nu`)}\` to your config`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What an install did, or would have done. */
|
|
75
|
+
export interface InstallResult {
|
|
76
|
+
readonly paths: readonly string[];
|
|
77
|
+
readonly manualStep?: string;
|
|
78
|
+
readonly dryRun: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface InstallOptions {
|
|
82
|
+
/** Report the paths without writing anything. */
|
|
83
|
+
readonly dryRun?: boolean;
|
|
84
|
+
/** Write here instead of the per-user location. */
|
|
85
|
+
readonly dir?: string;
|
|
86
|
+
/** Binary name; defaults to the command's binName or name. */
|
|
87
|
+
readonly binaryName?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function write(dir: string, file: string, contents: string, dryRun: boolean): string {
|
|
91
|
+
const path = join(dir, file);
|
|
92
|
+
if (!dryRun) {
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
writeFileSync(path, contents);
|
|
95
|
+
}
|
|
96
|
+
return path;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Write the completion script for one shell to its per-user location. */
|
|
100
|
+
export function installCompletions(
|
|
101
|
+
command: CommandDef,
|
|
102
|
+
shell: Shell,
|
|
103
|
+
opts?: InstallOptions,
|
|
104
|
+
): InstallResult {
|
|
105
|
+
const binaryName = opts?.binaryName ?? command.meta.binName ?? command.meta.name;
|
|
106
|
+
const target = completionTarget(shell, binaryName);
|
|
107
|
+
const dir = opts?.dir ?? target.dir;
|
|
108
|
+
const dryRun = opts?.dryRun === true;
|
|
109
|
+
|
|
110
|
+
const path = write(dir, target.file, generateCompletions(command, shell, binaryName), dryRun);
|
|
111
|
+
return {
|
|
112
|
+
paths: [path],
|
|
113
|
+
...(target.manualStep === undefined ? {} : { manualStep: target.manualStep }),
|
|
114
|
+
dryRun,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Write a man page per command into the per-user man directory. */
|
|
119
|
+
export function installManPages(command: CommandDef, opts?: InstallOptions): InstallResult {
|
|
120
|
+
const binaryName = opts?.binaryName ?? command.meta.binName ?? command.meta.name;
|
|
121
|
+
const dryRun = opts?.dryRun === true;
|
|
122
|
+
const pages = generateManPages(command, { name: binaryName });
|
|
123
|
+
const dir = opts?.dir ?? join(XDG_DATA, 'man', 'man1');
|
|
124
|
+
|
|
125
|
+
const paths: string[] = [];
|
|
126
|
+
for (const [file, roff] of pages) {
|
|
127
|
+
paths.push(write(dir, file, roff, dryRun));
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
paths,
|
|
131
|
+
manualStep: `add ${join(XDG_DATA, 'man')} to $MANPATH if your system does not read it already`,
|
|
132
|
+
dryRun,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const SHELLS: readonly Shell[] = ['bash', 'zsh', 'fish', 'powershell', 'elvish', 'nushell'];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Add `completions` and `man` subcommands that both print and install.
|
|
140
|
+
*
|
|
141
|
+
* `tool completions bash` writes the script to stdout as before;
|
|
142
|
+
* `tool completions install bash` puts it where the shell will find it.
|
|
143
|
+
*/
|
|
144
|
+
export function withInstallers(rootCommand: CommandDef<any>): CommandDef<any> {
|
|
145
|
+
const completions: CommandDef = {
|
|
146
|
+
meta: {
|
|
147
|
+
name: 'completions',
|
|
148
|
+
description: 'Print or install a shell completion script',
|
|
149
|
+
aliases: ['completion'],
|
|
150
|
+
// `completions install zsh` names the shell on the subcommand, so the
|
|
151
|
+
// shell positional here is only required when printing.
|
|
152
|
+
subcommandNegatesReqs: true,
|
|
153
|
+
},
|
|
154
|
+
args: {
|
|
155
|
+
shell: {
|
|
156
|
+
type: 'positional',
|
|
157
|
+
valueName: 'SHELL',
|
|
158
|
+
required: true,
|
|
159
|
+
valueParser: [...SHELLS],
|
|
160
|
+
description: `Target shell: ${SHELLS.join(', ')}`,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
subCommands: {
|
|
164
|
+
install: {
|
|
165
|
+
meta: { name: 'install', description: 'Write the script where the shell will find it' },
|
|
166
|
+
args: {
|
|
167
|
+
shell: {
|
|
168
|
+
type: 'positional',
|
|
169
|
+
valueName: 'SHELL',
|
|
170
|
+
required: true,
|
|
171
|
+
valueParser: [...SHELLS],
|
|
172
|
+
description: 'Shell to install for',
|
|
173
|
+
},
|
|
174
|
+
dryRun: { type: 'boolean', description: 'Report the path without writing' },
|
|
175
|
+
},
|
|
176
|
+
run({ args, stdout }) {
|
|
177
|
+
const result = installCompletions(rootCommand, args['shell'] as Shell, {
|
|
178
|
+
dryRun: args['dryRun'] === true,
|
|
179
|
+
});
|
|
180
|
+
const verb = result.dryRun ? 'would write' : 'wrote';
|
|
181
|
+
stdout.write(`${verb} ${result.paths[0]!}\n`);
|
|
182
|
+
if (result.manualStep !== undefined) {
|
|
183
|
+
stdout.write(`note: ${result.manualStep}\n`);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
run({ args, stdout }) {
|
|
189
|
+
stdout.write(generateCompletions(rootCommand, args['shell'] as Shell));
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const man: CommandDef = {
|
|
194
|
+
meta: { name: 'man', description: 'Print or install man pages' },
|
|
195
|
+
args: {
|
|
196
|
+
dryRun: { type: 'boolean', description: 'Report the paths without writing' },
|
|
197
|
+
},
|
|
198
|
+
subCommands: {
|
|
199
|
+
install: {
|
|
200
|
+
meta: { name: 'install', description: 'Write man pages where man will find them' },
|
|
201
|
+
args: { dryRun: { type: 'boolean', description: 'Report the paths without writing' } },
|
|
202
|
+
run({ args, stdout }) {
|
|
203
|
+
const result = installManPages(rootCommand, { dryRun: args['dryRun'] === true });
|
|
204
|
+
const verb = result.dryRun ? 'would write' : 'wrote';
|
|
205
|
+
for (const path of result.paths) {
|
|
206
|
+
stdout.write(`${verb} ${path}\n`);
|
|
207
|
+
}
|
|
208
|
+
if (result.manualStep !== undefined) {
|
|
209
|
+
stdout.write(`note: ${result.manualStep}\n`);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
run({ stdout }) {
|
|
215
|
+
for (const roff of generateManPages(rootCommand).values()) {
|
|
216
|
+
stdout.write(roff);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
...rootCommand,
|
|
223
|
+
subCommands: { ...rootCommand.subCommands, completions, man },
|
|
224
|
+
lazySubCommands: rootCommand.lazySubCommands,
|
|
225
|
+
};
|
|
226
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
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
|
+
|
|
25
|
+
import { styleText } from 'node:util';
|
|
26
|
+
import type { OutputSink } from './types.js';
|
|
27
|
+
|
|
28
|
+
/** Ordered from quietest to loudest. */
|
|
29
|
+
export const LEVELS = ['silent', 'error', 'warn', 'info', 'debug', 'trace'] as const;
|
|
30
|
+
|
|
31
|
+
export type LogLevel = (typeof LEVELS)[number];
|
|
32
|
+
|
|
33
|
+
const RANK: Record<LogLevel, number> = {
|
|
34
|
+
silent: 0,
|
|
35
|
+
error: 1,
|
|
36
|
+
warn: 2,
|
|
37
|
+
info: 3,
|
|
38
|
+
debug: 4,
|
|
39
|
+
trace: 5,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface Logger {
|
|
43
|
+
readonly level: LogLevel;
|
|
44
|
+
/** Whether a message at this level would be shown. */
|
|
45
|
+
enabled(level: LogLevel): boolean;
|
|
46
|
+
error(message: string, ...rest: unknown[]): void;
|
|
47
|
+
warn(message: string, ...rest: unknown[]): void;
|
|
48
|
+
info(message: string, ...rest: unknown[]): void;
|
|
49
|
+
debug(message: string, ...rest: unknown[]): void;
|
|
50
|
+
trace(message: string, ...rest: unknown[]): void;
|
|
51
|
+
/** A logger writing the same place at a different level. */
|
|
52
|
+
withLevel(level: LogLevel): Logger;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LoggerOptions {
|
|
56
|
+
/** Level to log at (default 'info'). */
|
|
57
|
+
readonly level?: LogLevel;
|
|
58
|
+
/** Where messages go (default process.stderr). */
|
|
59
|
+
readonly sink?: OutputSink;
|
|
60
|
+
/** Prefix each line, for instance with the tool name. */
|
|
61
|
+
readonly prefix?: string;
|
|
62
|
+
/** Force colour on or off; defaults to whatever the stream supports. */
|
|
63
|
+
readonly color?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Paint = (text: string) => string;
|
|
67
|
+
|
|
68
|
+
const PLAIN: Paint = (text) => text;
|
|
69
|
+
|
|
70
|
+
function labels(color: boolean): Record<Exclude<LogLevel, 'silent'>, string> {
|
|
71
|
+
const paint = (codes: Parameters<typeof styleText>[0], text: string): string =>
|
|
72
|
+
color ? styleText(codes, text, { validateStream: false }) : text;
|
|
73
|
+
return {
|
|
74
|
+
error: paint('red', 'error'),
|
|
75
|
+
warn: paint('yellow', 'warning'),
|
|
76
|
+
info: paint('cyan', 'info'),
|
|
77
|
+
debug: paint('magenta', 'debug'),
|
|
78
|
+
trace: paint('gray', 'trace'),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render one extra value.
|
|
84
|
+
*
|
|
85
|
+
* An Error is the most common thing to log and `JSON.stringify` turns it into
|
|
86
|
+
* `{}`, losing the message. A cyclic object throws outright, which would take
|
|
87
|
+
* the CLI down for the sake of a log line, so serialisation falls back to
|
|
88
|
+
* String rather than propagating.
|
|
89
|
+
*/
|
|
90
|
+
function renderValue(value: unknown): string {
|
|
91
|
+
if (typeof value === 'string') {
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
if (value instanceof Error) {
|
|
95
|
+
return value.stack ?? `${value.name}: ${value.message}`;
|
|
96
|
+
}
|
|
97
|
+
if (value === undefined) {
|
|
98
|
+
return 'undefined';
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === 'bigint' || typeof value === 'symbol' || typeof value === 'function') {
|
|
101
|
+
return String(value);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return JSON.stringify(value, safeReplacer()) ?? String(value);
|
|
105
|
+
} catch {
|
|
106
|
+
return String(value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A replacer that names a cycle instead of throwing on it. */
|
|
111
|
+
function safeReplacer(): (key: string, value: unknown) => unknown {
|
|
112
|
+
const seen = new WeakSet<object>();
|
|
113
|
+
return (_key, value) => {
|
|
114
|
+
if (typeof value !== 'object' || value === null) {
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
if (seen.has(value)) {
|
|
118
|
+
return '[Circular]';
|
|
119
|
+
}
|
|
120
|
+
seen.add(value);
|
|
121
|
+
return value;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function format(rest: readonly unknown[]): string {
|
|
126
|
+
if (rest.length === 0) {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
return ` ${rest.map(renderValue).join(' ')}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build a logger writing to a sink. */
|
|
133
|
+
export function createLogger(opts?: LoggerOptions): Logger {
|
|
134
|
+
const level = opts?.level ?? 'info';
|
|
135
|
+
const sink = opts?.sink ?? process.stderr;
|
|
136
|
+
const color = opts?.color ?? styleText('red', 'x') !== 'x';
|
|
137
|
+
const tag = labels(color);
|
|
138
|
+
const prefix = opts?.prefix === undefined ? '' : `${opts.prefix} `;
|
|
139
|
+
const threshold = RANK[level];
|
|
140
|
+
|
|
141
|
+
const write = (at: Exclude<LogLevel, 'silent'>, message: string, rest: unknown[]): void => {
|
|
142
|
+
if (RANK[at] > threshold) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
sink.write(`${prefix}${tag[at]}: ${message}${format(rest)}\n`);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const logger: Logger = {
|
|
149
|
+
level,
|
|
150
|
+
enabled: (at) => RANK[at] <= threshold,
|
|
151
|
+
error: (message, ...rest) => {
|
|
152
|
+
write('error', message, rest);
|
|
153
|
+
},
|
|
154
|
+
warn: (message, ...rest) => {
|
|
155
|
+
write('warn', message, rest);
|
|
156
|
+
},
|
|
157
|
+
info: (message, ...rest) => {
|
|
158
|
+
write('info', message, rest);
|
|
159
|
+
},
|
|
160
|
+
debug: (message, ...rest) => {
|
|
161
|
+
write('debug', message, rest);
|
|
162
|
+
},
|
|
163
|
+
trace: (message, ...rest) => {
|
|
164
|
+
write('trace', message, rest);
|
|
165
|
+
},
|
|
166
|
+
withLevel: (next) => createLogger({ ...opts, level: next }),
|
|
167
|
+
};
|
|
168
|
+
return logger;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface LevelFromArgsOptions {
|
|
172
|
+
/** Name of the count-action verbosity arg (default 'verbose'). */
|
|
173
|
+
readonly verboseKey?: string;
|
|
174
|
+
/** Name of the quiet flag (default 'quiet'). */
|
|
175
|
+
readonly quietKey?: string;
|
|
176
|
+
/** Name of an explicit level arg, which overrides the other two. */
|
|
177
|
+
readonly levelKey?: string;
|
|
178
|
+
/** Level with no flags given (default 'info'). */
|
|
179
|
+
readonly base?: LogLevel;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Work out a level from parsed arguments.
|
|
184
|
+
*
|
|
185
|
+
* An explicit level wins. Otherwise `--quiet` drops to errors only, and each
|
|
186
|
+
* `-v` climbs one step, so `-vv` reaches trace from the default of info.
|
|
187
|
+
*/
|
|
188
|
+
export function levelFromArgs(
|
|
189
|
+
args: Record<string, unknown>,
|
|
190
|
+
opts?: LevelFromArgsOptions,
|
|
191
|
+
): LogLevel {
|
|
192
|
+
const explicit = args[opts?.levelKey ?? 'logLevel'];
|
|
193
|
+
if (typeof explicit === 'string' && (LEVELS as readonly string[]).includes(explicit)) {
|
|
194
|
+
return explicit as LogLevel;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (args[opts?.quietKey ?? 'quiet'] === true) {
|
|
198
|
+
return 'error';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const base = RANK[opts?.base ?? 'info'];
|
|
202
|
+
const verbose = args[opts?.verboseKey ?? 'verbose'];
|
|
203
|
+
const steps = typeof verbose === 'number' ? verbose : verbose === true ? 1 : 0;
|
|
204
|
+
return LEVELS[Math.min(base + steps, LEVELS.length - 1)]!;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A logger for a command context, taking its level from the parsed arguments
|
|
209
|
+
* and writing to the context's stderr so the testing helpers capture it.
|
|
210
|
+
*/
|
|
211
|
+
export function loggerFrom(
|
|
212
|
+
// Structural rather than CommandContext<T>, so any command's context fits
|
|
213
|
+
// without the generic having to be named at the call site.
|
|
214
|
+
ctx: { readonly args: unknown; readonly stderr: OutputSink },
|
|
215
|
+
opts?: LoggerOptions & LevelFromArgsOptions,
|
|
216
|
+
): Logger {
|
|
217
|
+
return createLogger({
|
|
218
|
+
...opts,
|
|
219
|
+
sink: opts?.sink ?? ctx.stderr,
|
|
220
|
+
level: opts?.level ?? levelFromArgs(ctx.args as Record<string, unknown>, opts),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A logger that discards everything, for tests and dry runs. */
|
|
225
|
+
export const silentLogger: Logger = createLogger({ level: 'silent', sink: { write: PLAIN } });
|