-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathcommand-help.ts
More file actions
306 lines (280 loc) · 10.4 KB
/
Copy pathcommand-help.ts
File metadata and controls
306 lines (280 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
/**
* Generic `--help` / `-h` support for **every** lt command.
*
* Problem this solves: gluegun's built-in `.help()` only handles the top-level
* `lt --help` (the command list). For a subcommand, `lt fullstack convert-mode
* --help` simply *runs* the command — so a user who only wanted to read about a
* command accidentally triggers it. {@link installHelpInterceptor} wraps every
* loaded command so that, when help is requested, the command prints rich help
* and returns **without executing**.
*
* Two levels of detail:
* 1. Generic (always available) — usage, aliases and description from the
* command metadata gluegun already has.
* 2. Rich (opt-in) — a command module may `export const help: CommandHelp`
* describing options, features, examples and configuration. The interceptor
* loads it from `command.file` without running the command.
*/
/** Rich help definition a command may export as `export const help`. */
export interface CommandHelp {
aliases?: string[];
/** lt.config keys / configuration notes for this command. */
configuration?: string;
description?: string;
/** Concrete example invocations (without leading `lt`). */
examples?: string[];
/** Short bullet points describing what the command can do. */
features?: string[];
name?: string;
options?: CommandHelpOption[];
}
/** A single documented option/flag of a command. */
export interface CommandHelpOption {
default?: unknown;
description: string;
/** e.g. `--to`, `--noConfirm`, `--name` */
flag: string;
required?: boolean;
/** e.g. `string`, `boolean`, `number` */
type?: string;
/** allowed values, e.g. `['vendor', 'npm']` */
values?: string[];
}
/** Minimal shape of a gluegun command needed for help rendering. */
export interface HelpableCommand {
alias?: string[];
aliases?: string[];
commandPath?: string[];
description?: string;
file?: null | string;
name?: string;
}
export interface HelpJsonGlobalFlag {
description: string;
flag: string;
type: string;
}
/**
* JSON shape returned by `--help-json` on every command. Stable contract:
* tools and AI agents may rely on the field names. `richHelp: true` means the
* command exported a typed `CommandHelp` (so `options`, `features`, `examples`
* and `configuration` are authoritative). `richHelp: false` means the
* description came from gluegun metadata only — `options` is the empty
* array and only the global flags are guaranteed.
*/
export interface HelpJsonShape {
aliases: string[];
command: string;
configuration?: string;
description: string;
examples?: string[];
features?: string[];
globalFlags: HelpJsonGlobalFlag[];
name: string;
options: CommandHelpOption[];
richHelp: boolean;
}
/** Parameters surface — compatible with gluegun's `toolbox.parameters`. */
export interface HelpParameters {
array?: string[];
first?: string;
options?: Record<string, unknown>;
}
/** Minimal print surface — compatible with gluegun's `toolbox.print`. */
export interface HelpPrint {
colors: {
bold: (s: string) => string;
cyan: (s: string) => string;
dim: (s: string) => string;
yellow: (s: string) => string;
};
info: (s: string) => void;
}
/** A loaded gluegun command whose `run` can be wrapped. */
export interface InterceptableCommand extends HelpableCommand {
__helpWrapped?: boolean;
run?: (toolbox: { parameters?: HelpParameters; print?: HelpPrint }) => unknown;
}
/**
* Wrap every command's `run` so that `--help` / `-h` and `--help-json` print
* help and return without executing. Call once after `build().create()`,
* before `cli.run()`.
*
* Idempotent per command (guarded by `__helpWrapped`), so a command is never
* double-wrapped if this runs more than once in the same process (e.g. tests).
*/
export function installHelpInterceptor(
commands: InterceptableCommand[] | undefined,
defaultCommand?: InterceptableCommand,
): void {
for (const command of commands || []) {
if (typeof command.run !== 'function' || command.__helpWrapped) {
continue;
}
// Leave the top-level/default command and gluegun's preloaded builtins
// (`help`, `version` — they have `file === null`) to gluegun's own
// `.help()`, so that `lt --help` keeps printing the brand banner + full
// command list. Real file-backed subcommands (incl. `lt config help`) are
// still wrapped.
if (command === defaultCommand || !command.file || !(command.commandPath && command.commandPath.length)) {
continue;
}
const originalRun = command.run;
command.run = (toolbox) => {
if (isHelpJsonRequested(toolbox?.parameters)) {
emitHelpJson(buildHelpJson(command, loadCommandHelp(command.file)));
return undefined;
}
if (toolbox?.print && isHelpRequested(toolbox.parameters)) {
renderCommandHelp(toolbox.print, command, loadCommandHelp(command.file));
return undefined;
}
return originalRun(toolbox);
};
command.__helpWrapped = true;
}
}
/** True when the invocation asks for machine-readable help (`--help-json`). */
export function isHelpJsonRequested(parameters: HelpParameters | undefined): boolean {
const options = parameters?.options || {};
return options['help-json'] === true || options.helpJson === true;
}
/** True when the invocation asks for human-readable help (`--help` or `-h`). */
export function isHelpRequested(parameters: HelpParameters | undefined): boolean {
const options = parameters?.options || {};
if (options['help-json'] === true || options.helpJson === true) {
return false;
}
return options.help === true || options.h === true;
}
/** Best-effort load of a command's rich `help` export from its file. */
export function loadCommandHelp(file: null | string | undefined): CommandHelp | undefined {
if (!file) {
return undefined;
}
try {
const mod = require(file);
const help = (mod && (mod.help || (mod.default && mod.default.help))) as CommandHelp | undefined;
return help && typeof help === 'object' ? help : undefined;
} catch {
return undefined;
}
}
/**
* Render human-readable help for a command to `print`. Uses the rich `help`
* definition when available, otherwise a useful generic fallback. Never runs
* the command.
*/
export function renderCommandHelp(print: HelpPrint, command: HelpableCommand, help?: CommandHelp): void {
const { bold, cyan, dim, yellow } = print.colors;
const usage = usagePath(command);
const description = help?.description || command.description || '(no description)';
print.info('');
print.info(`${bold(usage)} — ${description}`);
const aliases = aliasList(command, help);
if (aliases.length) {
print.info(dim(`Aliases: ${aliases.join(', ')}`));
}
if (help?.features?.length) {
print.info('');
print.info(bold('What it does:'));
for (const feature of help.features) {
print.info(` • ${feature}`);
}
}
print.info('');
print.info(bold('Usage:'));
print.info(` ${usage} [options]`);
if (help?.examples?.length) {
print.info('');
print.info(bold('Examples:'));
for (const example of help.examples) {
print.info(` ${cyan(example.startsWith('lt ') ? example : `lt ${example}`)}`);
}
}
print.info('');
print.info(bold('Options:'));
const options = [...(help?.options || [])];
for (const option of options) {
const meta: string[] = [];
if (option.required) {
meta.push('required');
}
if (option.values?.length) {
meta.push(option.values.join('|'));
} else if (option.type) {
meta.push(option.type);
}
if (option.default !== undefined) {
meta.push(`default: ${String(option.default)}`);
}
const metaText = meta.length ? dim(` (${meta.join(', ')})`) : '';
print.info(` ${option.flag.padEnd(28)} ${option.description}${metaText}`);
}
// Always-present global flags
print.info(` ${'--help, -h'.padEnd(28)} Show this help and exit (does not run the command)`);
print.info(` ${'--help-json'.padEnd(28)} Machine-readable help as JSON (does not run the command)`);
if (!options.some((o) => o.flag === '--noConfirm')) {
print.info(` ${'--noConfirm'.padEnd(28)} Skip confirmation prompts (where supported)`);
}
if (help?.configuration) {
print.info('');
print.info(bold('Configuration (lt.config.json / lt.config.yaml):'));
for (const line of help.configuration.split('\n')) {
print.info(` ${line}`);
}
}
if (!help) {
print.info('');
print.info(dim('Tip: see docs/commands.md and docs/lt.config.md for full reference.'));
}
print.info('');
print.info(yellow('This is help output — the command was NOT executed.'));
print.info('');
}
function aliasList(command: HelpableCommand, help?: CommandHelp): string[] {
return help?.aliases || command.aliases || command.alias || [];
}
/** Resolve the user-facing invocation, e.g. `lt fullstack convert-mode`. */
function usagePath(command: HelpableCommand): string {
const path = command.commandPath && command.commandPath.length ? command.commandPath : [command.name || ''];
return `lt ${path.filter(Boolean).join(' ')}`.trim();
}
const GLOBAL_HELP_FLAGS: HelpJsonGlobalFlag[] = [
{ description: 'Show human-readable help; the command is NOT executed.', flag: '--help', type: 'boolean' },
{ description: 'Alias for --help.', flag: '-h', type: 'boolean' },
{
description: 'Print this JSON description on stdout; the command is NOT executed.',
flag: '--help-json',
type: 'boolean',
},
];
/**
* Build the JSON shape returned by `--help-json` for a command, merging the
* gluegun-known metadata (name, commandPath, description, aliases) with the
* command's optional rich `CommandHelp` export.
*/
export function buildHelpJson(command: HelpableCommand, help?: CommandHelp): HelpJsonShape {
const aliases = aliasList(command, help);
return {
aliases,
command: usagePath(command),
configuration: help?.configuration,
description: help?.description || command.description || '',
examples: help?.examples,
features: help?.features,
globalFlags: GLOBAL_HELP_FLAGS,
name: help?.name || command.name || '',
options: help?.options || [],
richHelp: Boolean(help),
};
}
/**
* Emit a help-json payload to stdout as a single pretty-printed JSON document.
* Kept tiny + side-effect-only so it can be stubbed in tests via a captured
* `console.log`.
*/
export function emitHelpJson(payload: HelpJsonShape): void {
// eslint-disable-next-line no-console
console.log(JSON.stringify(payload, null, 2));
}