import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { getCommand } from '../core/commands.ts'; import type { HelpArgumentInfo, HelpInfo, HelpPositionalInfo, HelpSubcommandInfo } from '../output/formatter.ts'; import { getHelpInfo } from '../output/help.ts'; import type { AnyPadroneCommand } from '../types/index.ts'; // ============================================================================ // Types // ============================================================================ export type DocsFormat = 'markdown' | 'html' | 'man' | 'json'; export type DocsOptions = { /** Output format. Defaults to 'markdown'. */ format?: DocsFormat; /** Output directory. If not set, docs are returned but not written. */ output?: string; /** Include hidden commands and options. Defaults to false. */ includeHidden?: boolean; /** Frontmatter generator for markdown files (VitePress, Starlight, etc.). */ frontmatter?: (info: HelpInfo, depth: number) => Record; /** Whether to overwrite existing files. Defaults to true. */ overwrite?: boolean; /** Print what would be written without writing. */ dryRun?: boolean; }; export type DocsPage = { /** File path relative to output directory (e.g., "deploy.md", "index.md"). */ path: string; /** Generated content for this page. */ content: string; /** The command name this page documents. */ command: string; }; export type DocsResult = { /** All generated pages. */ pages: DocsPage[]; /** Files that were written (empty if no output dir). */ written: string[]; /** Files that were skipped (already exist, no overwrite). */ skipped: string[]; /** Files that failed to write. */ errors: { file: string; error: Error }[]; }; // ============================================================================ // Help Info Collection // ============================================================================ function collectAllHelpInfo(cmd: AnyPadroneCommand, includeHidden: boolean): HelpInfo[] { const info = getHelpInfo(cmd, 'standard'); const result: HelpInfo[] = [info]; if (cmd.commands) { for (const sub of cmd.commands) { if (!includeHidden && sub.hidden) continue; result.push(...collectAllHelpInfo(sub, includeHidden)); } } return result; } // ============================================================================ // Markdown Generator // ============================================================================ function generateFrontmatter(data: Record): string { const lines: string[] = ['---']; for (const [key, value] of Object.entries(data)) { if (typeof value === 'string') { lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`); } else if (typeof value === 'number' || typeof value === 'boolean') { lines.push(`${key}: ${value}`); } else if (Array.isArray(value)) { lines.push(`${key}:`); for (const item of value) { lines.push(` - "${String(item).replace(/"/g, '\\"')}"`); } } } lines.push('---'); return lines.join('\n'); } function formatMarkdownPositional(arg: HelpPositionalInfo): string { const parts: string[] = []; parts.push(`- \`${arg.name}\``); if (arg.type) parts.push(`*(${arg.type})*`); if (arg.optional) parts.push('*(optional)*'); if (arg.default !== undefined) parts.push(`— default: \`${String(arg.default)}\``); if (arg.description) parts.push(`— ${arg.description}`); return parts.join(' '); } function formatMarkdownArgument(arg: HelpArgumentInfo): string[] { const lines: string[] = []; const flagName = `--${arg.name}`; const flagStr = arg.flags?.length ? `${arg.flags.map((f) => `-${f}`).join(', ')}, ` : ''; const aliasStr = arg.aliases?.length ? `${arg.aliases.map((a) => `--${a}`).join(', ')}, ` : ''; const header = `#### \`${flagStr}${aliasStr}${flagName}\``; lines.push(header); lines.push(''); if (arg.description) { lines.push(arg.description); lines.push(''); } const meta: string[] = []; if (arg.type && arg.type !== 'boolean') meta.push(`**Type:** \`${arg.type}\``); if (!arg.optional) meta.push('**Required**'); if (arg.default !== undefined) meta.push(`**Default:** \`${String(arg.default)}\``); if (arg.enum) meta.push(`**Choices:** ${arg.enum.map((v) => `\`${v}\``).join(', ')}`); if (arg.variadic) meta.push('**Repeatable**'); if (arg.deprecated) { const msg = typeof arg.deprecated === 'string' ? arg.deprecated : ''; meta.push(`**Deprecated**${msg ? `: ${msg}` : ''}`); } if (meta.length > 0) { lines.push(meta.join(' | ')); lines.push(''); } if (arg.env) { const envVars = typeof arg.env === 'string' ? [arg.env] : arg.env; lines.push(`**Environment:** ${envVars.map((v) => `\`${v}\``).join(', ')}`); lines.push(''); } if (arg.configKey) { lines.push(`**Config key:** \`${arg.configKey}\``); lines.push(''); } if (arg.examples?.length) { lines.push(`**Examples:** ${arg.examples.map((e) => `\`${typeof e === 'string' ? e : JSON.stringify(e)}\``).join(', ')}`); lines.push(''); } return lines; } function formatMarkdownSubcommand(sub: HelpSubcommandInfo): string { const parts: string[] = []; const suffix = sub.hasSubcommands ? ' ...' : ''; parts.push(`| \`${sub.name}${suffix}\``); const aliases = sub.aliases?.filter((a) => a !== '[default]'); parts.push(`| ${aliases?.length ? aliases.map((a) => `\`${a}\``).join(', ') : ''}`); const desc = sub.title ?? sub.description ?? ''; parts.push(`| ${desc}`); parts.push('|'); return parts.join(' '); } function generateMarkdownPage(info: HelpInfo, depth: number, frontmatterFn?: DocsOptions['frontmatter']): string { const lines: string[] = []; if (frontmatterFn) { const fm = frontmatterFn(info, depth); if (Object.keys(fm).length > 0) { lines.push(generateFrontmatter(fm)); lines.push(''); } } // Title const displayName = info.name === '' || !info.name ? 'CLI Reference' : info.name; lines.push(`# ${displayName}`); lines.push(''); // Deprecation warning if (info.deprecated) { const msg = typeof info.deprecated === 'string' ? info.deprecated : 'This command is deprecated.'; lines.push(`> **Deprecated:** ${msg}`); lines.push(''); } // Description if (info.title) { lines.push(`> ${info.title}`); lines.push(''); } if (info.description) { lines.push(info.description); lines.push(''); } // Aliases if (info.aliases?.length) { const realAliases = info.aliases.filter((a) => a !== '[default]'); if (realAliases.length > 0) { lines.push(`**Aliases:** ${realAliases.map((a) => `\`${a}\``).join(', ')}`); lines.push(''); } } // Usage const usageParts: string[] = [info.usage.command]; if (info.usage.hasSubcommands) usageParts.push('[command]'); if (info.positionals?.length) { for (const arg of info.positionals) { usageParts.push(arg.optional ? `[${arg.name}]` : `<${arg.name}>`); } } if (info.usage.hasArguments) usageParts.push('[options]'); lines.push('## Usage'); lines.push(''); lines.push('```'); lines.push(usageParts.join(' ')); lines.push('```'); lines.push(''); // Examples if (info.examples?.length) { lines.push('## Examples'); lines.push(''); lines.push('```'); for (const ex of info.examples) { lines.push(`$ ${ex}`); } lines.push('```'); lines.push(''); } // Subcommands if (info.subcommands?.length) { const visibleSubs = info.subcommands.filter((s) => !s.hidden); if (visibleSubs.length > 0) { lines.push('## Commands'); lines.push(''); lines.push('| Command | Aliases | Description |'); lines.push('| --- | --- | --- |'); for (const sub of visibleSubs) { lines.push(formatMarkdownSubcommand(sub)); } lines.push(''); } } // Positional arguments if (info.positionals?.length) { lines.push('## Arguments'); lines.push(''); for (const arg of info.positionals) { lines.push(formatMarkdownPositional(arg)); } lines.push(''); } // Options if (info.arguments?.length) { lines.push('## Options'); lines.push(''); for (const arg of info.arguments) { lines.push(...formatMarkdownArgument(arg)); } } return `${lines.join('\n').trimEnd()}\n`; } // ============================================================================ // HTML Generator // ============================================================================ function escapeHtml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function generateHtmlPage(info: HelpInfo, depth: number): string { const displayName = info.name === '' || !info.name ? 'CLI Reference' : escapeHtml(info.name); const sections: string[] = []; // Header sections.push(`
`); sections.push(`

${displayName}

`); if (info.deprecated) { const msg = typeof info.deprecated === 'string' ? escapeHtml(info.deprecated) : 'This command is deprecated.'; sections.push(`
Deprecated: ${msg}
`); } if (info.title) { sections.push(`

${escapeHtml(info.title)}

`); } if (info.description) { sections.push(`

${escapeHtml(info.description)}

`); } // Aliases if (info.aliases?.length) { const realAliases = info.aliases.filter((a) => a !== '[default]'); if (realAliases.length > 0) { sections.push(`

Aliases: ${realAliases.map((a) => `${escapeHtml(a)}`).join(', ')}

`); } } // Usage const usageParts: string[] = [info.usage.command]; if (info.usage.hasSubcommands) usageParts.push('[command]'); if (info.positionals?.length) { for (const arg of info.positionals) { usageParts.push(arg.optional ? `[${arg.name}]` : `<${arg.name}>`); } } if (info.usage.hasArguments) usageParts.push('[options]'); sections.push('

Usage

'); sections.push(`
${escapeHtml(usageParts.join(' '))}
`); // Examples if (info.examples?.length) { sections.push('

Examples

'); sections.push(`
${info.examples.map((ex) => `$ ${escapeHtml(ex)}`).join('\n')}
`); } // Subcommands if (info.subcommands?.length) { const visibleSubs = info.subcommands.filter((s) => !s.hidden); if (visibleSubs.length > 0) { sections.push('

Commands

'); sections.push(' '); sections.push(' '); sections.push(' '); for (const sub of visibleSubs) { const aliases = sub.aliases?.filter((a) => a !== '[default]'); const desc = sub.title ?? sub.description ?? ''; const suffix = sub.hasSubcommands ? ' ...' : ''; sections.push( ` `, ); } sections.push(' '); sections.push('
CommandAliasesDescription
${escapeHtml(sub.name + suffix)}${aliases?.length ? aliases.map((a) => `${escapeHtml(a)}`).join(', ') : ''}${escapeHtml(desc)}
'); } } // Positional arguments if (info.positionals?.length) { sections.push('

Arguments

'); sections.push('
'); for (const arg of info.positionals) { sections.push( `
${escapeHtml(arg.name)}${arg.type ? ` ${escapeHtml(arg.type)}` : ''}${arg.optional ? ' (optional)' : ''}
`, ); if (arg.description) sections.push(`
${escapeHtml(arg.description)}
`); if (arg.default !== undefined) sections.push(`
Default: ${escapeHtml(String(arg.default))}
`); } sections.push('
'); } // Options if (info.arguments?.length) { sections.push('

Options

'); sections.push('
'); for (const arg of info.arguments) { const flagName = `--${arg.name}`; const flagStr = arg.flags?.length ? `${arg.flags.map((f) => `-${f}`).join(', ')}, ` : ''; const aliasStr = arg.aliases?.length ? `${arg.aliases.map((a) => `--${a}`).join(', ')}, ` : ''; const typeSpan = arg.type && arg.type !== 'boolean' ? ` ${escapeHtml(arg.type)}` : ''; sections.push(`
${escapeHtml(flagStr + aliasStr + flagName)}${typeSpan}
`); if (arg.description) sections.push(`
${escapeHtml(arg.description)}
`); const meta: string[] = []; if (!arg.optional) meta.push('Required'); if (arg.default !== undefined) meta.push(`Default: ${escapeHtml(String(arg.default))}`); if (arg.enum) meta.push(`Choices: ${arg.enum.map((v) => `${escapeHtml(v)}`).join(', ')}`); if (arg.variadic) meta.push('Repeatable'); if (arg.deprecated) { const msg = typeof arg.deprecated === 'string' ? escapeHtml(arg.deprecated) : ''; meta.push(`Deprecated${msg ? `: ${msg}` : ''}`); } if (meta.length > 0) sections.push(`
${meta.join(' · ')}
`); if (arg.env) { const envVars = typeof arg.env === 'string' ? [arg.env] : arg.env; sections.push(`
Environment: ${envVars.map((v) => `${escapeHtml(v)}`).join(', ')}
`); } if (arg.configKey) { sections.push(`
Config key: ${escapeHtml(arg.configKey)}
`); } } sections.push('
'); } sections.push('
'); return `${sections.join('\n')}\n`; } // ============================================================================ // Man Page Generator (experimental) // ============================================================================ function escapeMan(text: string): string { return text.replace(/\\/g, '\\\\').replace(/-/g, '\\-').replace(/'/g, '\\(aq'); } function generateManPage(info: HelpInfo, _depth: number, programName: string): string { const commandName = info.name === '' || !info.name ? programName : info.name; const manName = commandName.replace(/\s+/g, '-'); const lines: string[] = []; lines.push(`.TH "${escapeMan(manName.toUpperCase())}" "1" "" "" ""`); // NAME lines.push('.SH NAME'); const desc = info.title ?? info.description ?? ''; lines.push(`${escapeMan(manName)}${desc ? ` \\- ${escapeMan(desc)}` : ''}`); // SYNOPSIS lines.push('.SH SYNOPSIS'); const usageParts: string[] = [`\\fB${escapeMan(commandName)}\\fR`]; if (info.usage.hasSubcommands) usageParts.push('[\\fIcommand\\fR]'); if (info.positionals?.length) { for (const arg of info.positionals) { usageParts.push(arg.optional ? `[\\fI${escapeMan(arg.name)}\\fR]` : `\\fI${escapeMan(arg.name)}\\fR`); } } if (info.usage.hasArguments) usageParts.push('[\\fIoptions\\fR]'); lines.push(usageParts.join(' ')); // DESCRIPTION if (info.description) { lines.push('.SH DESCRIPTION'); lines.push(escapeMan(info.description)); } // EXAMPLES if (info.examples?.length) { lines.push('.SH EXAMPLES'); for (const ex of info.examples) { lines.push('.PP'); lines.push(`.nf\n$ ${escapeMan(ex)}\n.fi`); } } // COMMANDS if (info.subcommands?.length) { const visibleSubs = info.subcommands.filter((s) => !s.hidden); if (visibleSubs.length > 0) { lines.push('.SH COMMANDS'); for (const sub of visibleSubs) { const suffix = sub.hasSubcommands ? ' ...' : ''; lines.push(`.TP`); lines.push(`\\fB${escapeMan(sub.name + suffix)}\\fR`); const subDesc = sub.title ?? sub.description; if (subDesc) lines.push(escapeMan(subDesc)); } } } // ARGUMENTS if (info.positionals?.length) { lines.push('.SH ARGUMENTS'); for (const arg of info.positionals) { lines.push('.TP'); lines.push(`\\fI${escapeMan(arg.name)}\\fR`); const parts: string[] = []; if (arg.description) parts.push(escapeMan(arg.description)); if (arg.optional) parts.push('(optional)'); if (arg.default !== undefined) parts.push(`Default: ${escapeMan(String(arg.default))}`); if (parts.length > 0) lines.push(parts.join('. ')); } } // OPTIONS if (info.arguments?.length) { lines.push('.SH OPTIONS'); for (const arg of info.arguments) { const flagName = `\\-\\-${escapeMan(arg.name)}`; const flagStr = arg.flags?.length ? `${arg.flags.map((f) => `\\-${escapeMan(f)}`).join(', ')}, ` : ''; const aliasStr = arg.aliases?.length ? `${arg.aliases.map((a) => `\\-\\-${escapeMan(a)}`).join(', ')}, ` : ''; lines.push('.TP'); lines.push(`\\fB${flagStr}${aliasStr}${flagName}\\fR${arg.type ? ` \\fI${escapeMan(arg.type)}\\fR` : ''}`); const parts: string[] = []; if (arg.description) parts.push(escapeMan(arg.description)); if (arg.default !== undefined) parts.push(`Default: ${escapeMan(String(arg.default))}`); if (arg.enum) parts.push(`Choices: ${arg.enum.map((v) => escapeMan(v)).join(', ')}`); if (parts.length > 0) lines.push(parts.join('. ')); if (arg.env) { const envVars = typeof arg.env === 'string' ? [arg.env] : arg.env; lines.push(`.br`); lines.push(`Environment: ${envVars.map((v) => escapeMan(v)).join(', ')}`); } } } return `${lines.join('\n')}\n`; } // ============================================================================ // Page Path Helpers // ============================================================================ function commandToPath(info: HelpInfo, ext: string, isRoot: boolean): string { if (isRoot) return `index${ext}`; // Split on whitespace and replace empty segments (from empty-name default commands) with "_default" const segments = info.name.split(/\s+/).map((s) => s || '_default'); return segments.join('/') + ext; } // ============================================================================ // Index Page Generators // ============================================================================ function generateMarkdownIndex(rootInfo: HelpInfo, allInfos: HelpInfo[]): string { const lines: string[] = []; lines.push(`# ${rootInfo.title ?? rootInfo.name ?? 'CLI'} Reference`); lines.push(''); if (rootInfo.description) { lines.push(rootInfo.description); lines.push(''); } if (allInfos.length > 1) { lines.push('## Commands'); lines.push(''); for (const info of allInfos) { const path = commandToPath(info, '.md', info === rootInfo); const name = info === rootInfo ? info.name || 'root' : info.name; const desc = info.title ?? info.description ?? ''; lines.push(`- [${name}](${path})${desc ? ` — ${desc}` : ''}`); } lines.push(''); } return `${lines.join('\n').trimEnd()}\n`; } // ============================================================================ // Main Entry Point // ============================================================================ /** * Generate documentation for a Padrone CLI program or command tree. * Accepts either a PadroneProgram (from createPadrone()) or a raw AnyPadroneCommand. */ export function generateDocs(program: object, options: DocsOptions = {}): DocsResult { const { format = 'markdown', output, includeHidden = false, frontmatter, overwrite = true, dryRun = false } = options; const cmd = getCommand(program); const allInfos = collectAllHelpInfo(cmd, includeHidden); const rootInfo = allInfos[0]!; const programName = cmd.name || 'program'; const pages: DocsPage[] = []; const ext = format === 'markdown' ? '.md' : format === 'html' ? '.html' : format === 'man' ? '.1' : '.json'; for (let i = 0; i < allInfos.length; i++) { const info = allInfos[i]!; const isRoot = i === 0; const depth = isRoot ? 0 : info.name.split(/\s+/).length; const path = commandToPath(info, ext, isRoot); let content: string; switch (format) { case 'markdown': content = generateMarkdownPage(info, depth, frontmatter); break; case 'html': content = generateHtmlPage(info, depth); break; case 'man': content = generateManPage(info, depth, programName); break; case 'json': content = `${JSON.stringify(info, null, 2)}\n`; break; } pages.push({ path, content, command: info.name }); } // Generate index page for markdown (when there are subcommands) if (format === 'markdown' && allInfos.length > 1) { // Replace the root page with a combined index const rootPage = pages[0]!; rootPage.content = generateMarkdownIndex(rootInfo, allInfos); } const result: DocsResult = { pages, written: [], skipped: [], errors: [] }; // Write to disk if output dir specified if (output) { const outDir = resolve(output); for (const page of pages) { const fullPath = join(outDir, page.path); try { if (existsSync(fullPath) && !overwrite) { result.skipped.push(page.path); continue; } if (dryRun) { result.written.push(page.path); continue; } const dir = dirname(fullPath); mkdirSync(dir, { recursive: true }); writeFileSync(fullPath, page.content, 'utf-8'); result.written.push(page.path); } catch (err) { result.errors.push({ file: page.path, error: err instanceof Error ? err : new Error(String(err)), }); } } } return result; } // ============================================================================ // Man Page Installation // ============================================================================ export type SetupManPagesResult = { /** Directory where man pages were written. */ dir: string; /** Man page files that were written. */ written: string[]; /** Whether existing pages were overwritten (true) or newly created (false). */ updated: boolean; }; /** * Returns the local man page directory for the given section. * Uses `~/.local/share/man/man
` (XDG convention). */ async function getManPageDir(section = 1): Promise { const { homedir } = await import('node:os'); return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'man', `man${section}`); } /** * Converts a command name to a man page filename. * "myapp" → "myapp.1", "myapp deploy" → "myapp-deploy.1" */ function manPageFilename(commandName: string, section = 1): string { return `${commandName.replace(/\s+/g, '-')}.${section}`; } /** * Installs man pages for a Padrone CLI program into the local man directory. * Generates man pages for all commands and writes them to `~/.local/share/man/man1/`. * * After installation, `man ` and `man -` should work * (assuming `~/.local/share/man` is in `MANPATH` or `manpath` picks it up). */ export async function setupManPages(program: object): Promise { const cmd = getCommand(program); const allInfos = collectAllHelpInfo(cmd, false); const programName = cmd.name || 'program'; const manDir = await getManPageDir(1); mkdirSync(manDir, { recursive: true }); const written: string[] = []; let updated = false; for (let i = 0; i < allInfos.length; i++) { const info = allInfos[i]!; const depth = i === 0 ? 0 : info.name.split(/\s+/).length; const commandName = info.name === '' || !info.name ? programName : info.name; const filename = manPageFilename(commandName); const fullPath = join(manDir, filename); if (existsSync(fullPath)) updated = true; const content = generateManPage(info, depth, programName); writeFileSync(fullPath, content, 'utf-8'); written.push(filename); } return { dir: manDir, written, updated }; } /** * Removes installed man pages for a Padrone CLI program. */ export async function removeManPages(program: object): Promise<{ dir: string; removed: string[] }> { const { unlinkSync } = await import('node:fs'); const cmd = getCommand(program); const allInfos = collectAllHelpInfo(cmd, false); const programName = cmd.name || 'program'; const manDir = await getManPageDir(1); const removed: string[] = []; for (let i = 0; i < allInfos.length; i++) { const info = allInfos[i]!; const commandName = info.name === '' || !info.name ? programName : info.name; const filename = manPageFilename(commandName); const fullPath = join(manDir, filename); if (existsSync(fullPath)) { unlinkSync(fullPath); removed.push(filename); } } return { dir: manDir, removed }; }