fix(cli): bound CLI help alias discovery and document plugin option placement
`collectCliHelp` stops propagating an alias when it reaches a page already on the current path, so a `help [command]` row that prints its parent's help no longer recurses until the stack overflows. `serve|s` command rows are split into separately collected names. The CLI reference and upgrade guide describe the forwarded-argument boundary that Commander applies, including a leading pnpm option, and args.spec.ts pins that `--profile` placed after a leading pnpm option is forwarded. Generated pages use the repository's two-line generated header, and docs/AGENTS.md lists the CLI help among the generated references. Refs #4114
This commit is contained in:
1 parent
75935af1d0
commit
231d5a311e
14 files changed
+71
-38
No files matched your search
@@ -28,6 +28,23 @@ describe('CLI help discovery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('visits each command alias and lists a help command that repeats its parent once', async () => {
|
||||
const visited: string[] = []
|
||||
const parent = 'Usage: web [command]\n\nCommands:\n serve|s [options] Serve the UI\n help [command] display help for command\n'
|
||||
const pages = await collectCliHelp(async (args) => {
|
||||
visited.push(args.join(' '))
|
||||
if (args.length === 0) return 'Usage: dsh\n'
|
||||
if (args[0] === 'plugin') return 'Usage: dsh plugin\n'
|
||||
const selected = args[0] === '--profile' ? args.slice(1) : args
|
||||
return selected[1] === 'serve' || selected[1] === 's' ? 'Usage: web serve\n' : parent
|
||||
}, ['web'])
|
||||
expect(visited).toEqual(['', 'plugin', 'web', 'web serve', 'web s', 'web help', '--profile web'])
|
||||
expect(pages.map(page => page.invocations)).toEqual([
|
||||
['dsh'], ['dsh plugin'], ['dsh web', 'dsh web help', 'dsh --profile web', 'dsh --profile web help'],
|
||||
['dsh web serve', 'dsh web s', 'dsh --profile web serve', 'dsh --profile web s'],
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['', ' \r\n\n'])('rejects blank help %j', (help) => {
|
||||
expect(() => parseCliHelp(help)).toThrow('CLI help returned empty output')
|
||||
})
|
||||
|
||||
+21
-13
@@ -21,7 +21,7 @@ const execFileAsync = promisify(execFile)
|
||||
export interface CliHelp {
|
||||
/** Complete help text with LF line endings. */
|
||||
output: string
|
||||
/** Immediate command names listed in the `Commands:` section, in their displayed order. */
|
||||
/** Immediate command names and aliases listed in the `Commands:` section, in their displayed order. */
|
||||
commands: string[]
|
||||
}
|
||||
|
||||
@@ -34,21 +34,26 @@ interface HelpPage {
|
||||
/**
|
||||
* Discover commands from actual text help while preserving every displayed line.
|
||||
* @param stdout - Successful CLI help output.
|
||||
* @returns Help text with LF line endings and the command names listed in its `Commands:` section.
|
||||
* @returns Help text with LF line endings and the command names and aliases (`serve|s`) listed in its `Commands:` section.
|
||||
* @throws When the output is empty.
|
||||
*/
|
||||
export function parseCliHelp(stdout: string): CliHelp {
|
||||
const output = stdout.replaceAll('\r\n', '\n')
|
||||
if (output.trim() === '') throw new Error('CLI help returned empty output')
|
||||
const section = /^Commands:\n((?:[ \t][^\n]*\n|\n)*)/mu.exec(output)?.[1] ?? ''
|
||||
const commands = [...section.matchAll(/^ {2}\S+/gmu)].map(match => match[0].slice(2))
|
||||
const commands = [...section.matchAll(/^ {2}\S+/gmu)].flatMap(match => match[0].slice(2).split('|'))
|
||||
return { output, commands }
|
||||
}
|
||||
|
||||
function addAlias(page: HelpPage, invocation: string): void {
|
||||
// A `help [command]` row prints its parent's help, so a page can list itself as a child.
|
||||
// An invocation that reaches a page already on the current path joins that page without
|
||||
// extending its children, which keeps alias propagation finite.
|
||||
function addAlias(page: HelpPage, invocation: string, path: ReadonlySet<HelpPage>): void {
|
||||
if (page.invocations.includes(invocation)) return
|
||||
page.invocations.push(invocation)
|
||||
for (const [name, child] of page.children) addAlias(child, `${invocation} ${name}`)
|
||||
if (path.has(page)) return
|
||||
const childPath = new Set([...path, page])
|
||||
for (const [name, child] of page.children) addAlias(child, `${invocation} ${name}`, childPath)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,24 +66,26 @@ export async function collectCliHelp(
|
||||
readHelp: (args: readonly string[]) => Promise<string>, profiles: readonly string[],
|
||||
): Promise<HelpPage[]> {
|
||||
const pages = new Map<string, HelpPage>()
|
||||
const visit = async (args: string[]): Promise<HelpPage> => {
|
||||
const visit = async (args: string[], path: ReadonlySet<HelpPage>): Promise<HelpPage> => {
|
||||
const invocation = ['dsh', ...args].join(' ')
|
||||
const help = parseCliHelp(await readHelp(args))
|
||||
const existing = pages.get(help.output)
|
||||
if (existing !== undefined) {
|
||||
addAlias(existing, invocation)
|
||||
addAlias(existing, invocation, path)
|
||||
return existing
|
||||
}
|
||||
const page: HelpPage = { help, invocations: [invocation], children: new Map() }
|
||||
pages.set(help.output, page)
|
||||
for (const name of help.commands) page.children.set(name, await visit([...args, name]))
|
||||
const childPath = new Set([...path, page])
|
||||
for (const name of help.commands) page.children.set(name, await visit([...args, name], childPath))
|
||||
return page
|
||||
}
|
||||
await visit([])
|
||||
await visit(['plugin'])
|
||||
const top = new Set<HelpPage>()
|
||||
await visit([], top)
|
||||
await visit(['plugin'], top)
|
||||
for (const profile of [...profiles].sort()) {
|
||||
await visit([profile])
|
||||
await visit(['--profile', profile])
|
||||
await visit([profile], top)
|
||||
await visit(['--profile', profile], top)
|
||||
}
|
||||
return [...pages.values()]
|
||||
}
|
||||
@@ -115,7 +122,8 @@ function anchor(page: HelpPage): string {
|
||||
|
||||
function renderHelp(pages: readonly HelpPage[], chinese: boolean): string {
|
||||
const lines = [
|
||||
'<!-- Generated by scripts/gen-cli-help.ts; run pnpm run gen-cli-help to regenerate. -->',
|
||||
'<!-- Generated by scripts/gen-cli-help.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-cli-help` to regenerate. -->',
|
||||
'',
|
||||
chinese ? '# dsh 命令行帮助' : '# dsh CLI help',
|
||||
'',
|
||||
|
||||
Reference in new issue
Block a user