diff --git a/src/cli.ts b/src/cli.ts index 369530d..165d4fc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,10 @@ import { createApp } from './app.ts'; +import { registerHelpCommand } from './commands/help.ts'; const program = createApp(); +registerHelpCommand(program); + program.parseAsync(process.argv).catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..7b472fc --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,212 @@ +import type { Command } from 'commander'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'node:path'; +import { getDefaultsDir } from '../config.ts'; +import * as output from '../utils/output.ts'; + +/** + * Prints the decorated banner and command list. + * + * @since TBD + * + * @param {string} contents - The raw markdown contents of commands.md. + * + * @returns {void} + */ +function printCommandList(contents: string): void { + const border = '*'.repeat(80); + + output.writeln(border); + output.writeln('*'); + output.writeln(`* ${chalk.blue(`${chalk.magenta('P')}roduct ${chalk.magenta('U')}tility & ${chalk.magenta('P')}ackager`)}`); + output.writeln('* ' + '-'.repeat(78)); + output.writeln('* A CLI utility by StellarWP'); + output.writeln('*'); + output.writeln(border); + + output.newline(); + output.writeln(`Run ${chalk.cyan('pup help ')} for more information on a specific command.`); + output.newline(); + + const lines = contents.split('\n'); + const commands: [string, string][] = []; + let currentCommand: string | null = null; + + for (const line of lines) { + const headerMatch = line.match(/##+\s+`pup ([^`]+)`/); + if (headerMatch) { + currentCommand = headerMatch[1]; + continue; + } + + if (currentCommand && !commands.find(([cmd]) => cmd === currentCommand)) { + const trimmed = line.trim(); + if (trimmed) { + const description = trimmed.replace(/`([^`]+)`/g, (_, code: string) => chalk.cyan(code)); + commands.push([currentCommand, description]); + currentCommand = null; + } + } + } + + commands.sort((a, b) => a[0].localeCompare(b[0])); + + output.table( + ['Command', 'Description'], + commands.map(([cmd, desc]) => [chalk.yellow(cmd), desc]), + ); + + output.newline(); + output.writeln(`For more documentation, head over to ${chalk.yellow('https://github.com/stellarwp/pup')}`); +} + +/** + * Prints styled help for a specific command topic. + * + * @since TBD + * + * @param {string} topic - The command topic to show help for. + * @param {string} contents - The raw markdown contents of commands.md. + * + * @returns {boolean} Whether the topic was found. + */ +function printCommandHelp(topic: string, contents: string): boolean { + const lines = contents.split('\n'); + let started = false; + let didFirstLine = false; + let inCodeBlock = false; + let inArgTable = false; + let argHeaders: string[] = []; + let argRows: string[][] = []; + + for (const line of lines) { + if (started) { + // Stop when we hit the next ## command section + if (/^##+\s+`pup /.test(line)) { + break; + } + + // Code block toggle + if (/^```/.test(line)) { + inCodeBlock = !inCodeBlock; + output.writeln(chalk.green('.'.repeat(50))); + continue; + } + + // Inside code block + if (inCodeBlock) { + if (/^#/.test(line)) { + output.writeln(chalk.green(line)); + } else { + output.writeln(chalk.cyan(line)); + } + continue; + } + + // Apply inline formatting (strip links first so ANSI codes from chalk + // don't contain `[` characters that confuse the link regex) + let formatted = line; + formatted = formatted.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + formatted = formatted.replace(/`([^`]+)`/g, (_, code: string) => chalk.cyan(code)); + formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, bold: string) => chalk.red(bold)); + + // Argument/option table handling + if (inArgTable) { + if (/^\| (Arg|Opt)/.test(formatted)) { + argHeaders = formatted.replace(/^\||\|$/g, '').split('|').map((s) => s.trim()); + continue; + } + + if (/^\|--/.test(line)) { + continue; + } + + if (/^\|/.test(line)) { + argRows.push(formatted.replace(/^\||\|$/g, '').split('|').map((s) => s.trim())); + continue; + } + + // End of table + if (argHeaders.length > 0) { + inArgTable = false; + output.table(argHeaders, argRows); + argHeaders = []; + argRows = []; + } + } + + // Sub-section headers (### or ####) + const sectionMatch = formatted.match(/^##(#+) (.+)/); + if (sectionMatch) { + const depth = sectionMatch[1].length; + const label = sectionMatch[2]; + output.section(`${'>'.repeat(depth)} ${label}:`); + + if (/^##(#+ )(Arguments|`\.puprc` options)/.test(line)) { + inArgTable = true; + argHeaders = []; + argRows = []; + } + continue; + } + + output.writeln(formatted); + + if (!didFirstLine) { + didFirstLine = true; + output.newline(); + } + } else { + const topicPattern = new RegExp(`^##+\\s+\`pup ${topic.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\``); + if (topicPattern.test(line)) { + output.title(`Help: ${chalk.cyan('pup ' + topic)}`); + started = true; + } + } + } + + // Flush any remaining argument table + if (inArgTable && argHeaders.length > 0) { + output.table(argHeaders, argRows); + } + + return started; +} + +/** + * Registers the `help` command with the CLI program. + * + * @since TBD + * + * @param {Command} program - The Commander.js program instance. + * + * @returns {void} + */ +export function registerHelpCommand(program: Command): void { + program + .command('help [topic]', { isDefault: true }) + .description('Shows help for pup.') + .action(async (topic?: string) => { + const docsDir = path.resolve(getDefaultsDir(), '..', 'docs'); + const commandsPath = path.join(docsDir, 'commands.md'); + + if (!await fs.pathExists(commandsPath)) { + output.log('Help documentation not found.'); + return; + } + + const contents = await fs.readFile(commandsPath, 'utf-8'); + + if (!topic) { + printCommandList(contents); + return; + } + + const normalizedTopic = topic.replace('pup ', '').replace('pup-', ''); + + if (!printCommandHelp(normalizedTopic, contents)) { + output.error(`Unknown topic: ${topic}`); + } + }); +} diff --git a/src/utils/output.ts b/src/utils/output.ts index 67c02bc..319083f 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -95,7 +95,7 @@ export function info(message: string): void { } /** - * Prints a bold title with an underline rule. + * Prints a yellow title with an underline rule. * * @since TBD * @@ -105,13 +105,13 @@ export function info(message: string): void { */ export function title(message: string): void { console.log(''); - console.log(formatMessage(chalk.bold(message))); - console.log(formatMessage(chalk.bold('='.repeat(message.length)))); + console.log(formatMessage(chalk.yellow(message))); + console.log(formatMessage(chalk.yellow('='.repeat(message.length)))); console.log(''); } /** - * Prints a bold yellow section header. + * Prints a yellow section header. * * @since TBD * @@ -121,7 +121,9 @@ export function title(message: string): void { */ export function section(message: string): void { console.log(''); - console.log(formatMessage(chalk.bold.yellow(message))); + console.log(formatMessage(chalk.yellow(message))); + console.log(formatMessage(chalk.yellow('-'.repeat(message.length)))); + console.log(''); } /** @@ -160,3 +162,53 @@ export function writeln(message: string): void { export function newline(): void { console.log(''); } + +/** + * Strips ANSI escape codes from a string for accurate length calculation. + * + * @since TBD + * + * @param {string} str - The string potentially containing ANSI codes. + * + * @returns {string} The string with ANSI codes removed. + */ +function stripAnsi(str: string): string { + return str.replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** + * Renders a formatted ASCII table to stdout. + * + * @since TBD + * + * @param {string[]} headers - Column header labels. + * @param {string[][]} rows - Array of row data, each row being an array of cell strings. + * + * @returns {void} + */ +export function table(headers: string[], rows: string[][]): void { + const colWidths: number[] = headers.map((h) => stripAnsi(h).length); + + for (const row of rows) { + for (let i = 0; i < row.length; i++) { + const len = stripAnsi(row[i] || '').length; + if (len > (colWidths[i] || 0)) { + colWidths[i] = len; + } + } + } + + const separator = '|' + colWidths.map((w) => '-'.repeat(w + 2)).join('|') + '|'; + const formatRow = (cells: string[]): string => { + return '| ' + cells.map((cell, i) => { + const padding = (colWidths[i] || 0) - stripAnsi(cell || '').length; + return (cell || '') + ' '.repeat(Math.max(0, padding)); + }).join(' | ') + ' |'; + }; + + console.log(formatRow(headers)); + console.log(separator); + for (const row of rows) { + console.log(formatRow(row)); + } +} diff --git a/tests/commands/help.test.ts b/tests/commands/help.test.ts new file mode 100644 index 0000000..1ed8e76 --- /dev/null +++ b/tests/commands/help.test.ts @@ -0,0 +1,216 @@ +import { + runPup, + writePuprc, + getPuprc, + createTempProject, + cleanupTempProjects, +} from '../helpers/setup.js'; + +describe('help command', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + writePuprc(getPuprc(), projectDir); + }); + + afterEach(() => { + cleanupTempProjects(); + }); + + describe('command list (no topic)', () => { + it('should exit with code 0', async () => { + const result = await runPup('help', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + }); + + it('should display the banner with title and border', async () => { + const result = await runPup('help', { cwd: projectDir }); + expect(result.stdout).toContain('*'.repeat(80)); + expect(result.stdout).toContain('Product Utility & Packager'); + expect(result.stdout).toContain('A CLI utility by StellarWP'); + }); + + it('should display the help hint', async () => { + const result = await runPup('help', { cwd: projectDir }); + expect(result.stdout).toContain('Run pup help for more information on a specific command.'); + }); + + it('should display a command table with headers', async () => { + const result = await runPup('help', { cwd: projectDir }); + expect(result.stdout).toContain('| Command'); + expect(result.stdout).toContain('| Description'); + expect(result.stdout).toMatch(/\|[-]+\|/); + }); + + it('should list all documented commands', async () => { + const result = await runPup('help', { cwd: projectDir }); + const expectedCommands = [ + 'build', + 'check', + 'check:tbd', + 'check:version-conflict', + 'clean', + 'do', + 'get-version', + 'help', + 'i18n', + 'info', + 'package', + 'workflow', + 'zip', + 'zip-name', + ]; + + for (const cmd of expectedCommands) { + expect(result.stdout).toContain(cmd); + } + }); + + it('should display commands in sorted order', async () => { + const result = await runPup('help', { cwd: projectDir }); + const lines = result.stdout.split('\n'); + const commandLines = lines.filter((line) => line.startsWith('|') && !line.startsWith('| Command') && !line.startsWith('|---')); + const commands = commandLines + .map((line) => line.split('|')[1]?.trim()) + .filter(Boolean); + + const sorted = [...commands].sort(); + expect(commands).toEqual(sorted); + }); + + it('should display the footer with GitHub URL', async () => { + const result = await runPup('help', { cwd: projectDir }); + expect(result.stdout).toContain('https://github.com/stellarwp/pup'); + }); + }); + + describe('topic detail', () => { + it('should display a title for the topic', async () => { + const result = await runPup('help build', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Help: pup build'); + expect(result.stdout).toContain('='.repeat('Help: pup build'.length)); + }); + + it('should display the command description', async () => { + const result = await runPup('help build', { cwd: projectDir }); + expect(result.stdout).toContain('Runs the build commands from the .puprc file.'); + }); + + it('should render section headers with > prefix', async () => { + const result = await runPup('help build', { cwd: projectDir }); + expect(result.stdout).toContain('> Usage:'); + expect(result.stdout).toContain('> Arguments:'); + }); + + it('should render section headers with dashed underlines', async () => { + const result = await runPup('help build', { cwd: projectDir }); + const lines = result.stdout.split('\n'); + const usageIdx = lines.findIndex((line) => line.includes('> Usage:')); + expect(usageIdx).toBeGreaterThan(-1); + expect(lines[usageIdx + 1]).toMatch(/^-+$/); + }); + + it('should render code blocks with dotted separators', async () => { + const result = await runPup('help build', { cwd: projectDir }); + expect(result.stdout).toContain('.'.repeat(50)); + }); + + it('should display code block content', async () => { + const result = await runPup('help build', { cwd: projectDir }); + expect(result.stdout).toContain('pup build [--dev]'); + }); + + it('should render argument tables', async () => { + const result = await runPup('help build', { cwd: projectDir }); + expect(result.stdout).toContain('| Argument'); + expect(result.stdout).toContain('| --dev'); + expect(result.stdout).toContain('| --root'); + }); + + it('should strip markdown bold formatting', async () => { + const result = await runPup('help build', { cwd: projectDir }); + // **Optional.** should render as Optional. without the ** + expect(result.stdout).toContain('Optional.'); + expect(result.stdout).not.toContain('**Optional.**'); + }); + + it('should strip markdown link syntax', async () => { + const result = await runPup('help check:tbd', { cwd: projectDir }); + expect(result.stdout).toContain('.puprc-defaults'); + // Should not contain raw markdown link URL + expect(result.stdout).not.toContain('](https://'); + }); + + it('should not show leftover markdown link brackets', async () => { + const result = await runPup('help check:tbd', { cwd: projectDir }); + // The link [`.puprc-defaults`](url) should not leave a stray [ in the output + expect(result.stdout).not.toMatch(/\[\.puprc-defaults/); + }); + }); + + describe('sub-commands', () => { + it('should display help for check:tbd', async () => { + const result = await runPup('help check:tbd', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Help: pup check:tbd'); + expect(result.stdout).toContain('Scans your files for tbd'); + }); + + it('should display help for check:version-conflict', async () => { + const result = await runPup('help check:version-conflict', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Help: pup check:version-conflict'); + expect(result.stdout).toContain('version numbers match'); + }); + + it('should render .puprc options tables for check:tbd', async () => { + const result = await runPup('help check:tbd', { cwd: projectDir }); + expect(result.stdout).toContain('>> .puprc options:'); + expect(result.stdout).toContain('| Option'); + expect(result.stdout).toContain('fail_method'); + expect(result.stdout).toContain('dirs'); + expect(result.stdout).toContain('skip_directories'); + expect(result.stdout).toContain('skip_files'); + }); + + it('should use >> depth prefix for #### headings', async () => { + const result = await runPup('help check:tbd', { cwd: projectDir }); + // check:tbd uses #### (4 hashes) for Usage and .puprc options + expect(result.stdout).toContain('>> Usage:'); + }); + }); + + describe('multiple topics', () => { + const topics = [ + 'build', + 'check', + 'check:tbd', + 'check:version-conflict', + 'clean', + 'do', + 'get-version', + 'help', + 'i18n', + 'info', + 'package', + 'workflow', + 'zip', + 'zip-name', + ]; + + it.each(topics)('should display help for topic: %s', async (topic) => { + const result = await runPup(`help ${topic}`, { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`Help: pup ${topic}`); + }); + }); + + describe('unknown topic', () => { + it('should show an error for unknown topics', async () => { + const result = await runPup('help nonexistent', { cwd: projectDir }); + expect(result.output).toContain('Unknown topic: nonexistent'); + }); + }); +}); diff --git a/tests/utils/output.test.ts b/tests/utils/output.test.ts new file mode 100644 index 0000000..0cc10cd --- /dev/null +++ b/tests/utils/output.test.ts @@ -0,0 +1,135 @@ +/** + * chalk v5 is ESM-only and uses Node subpath imports (#ansi-styles) that + * ts-jest cannot resolve, so importing output.ts directly would fail. + * + * We mock chalk as a passthrough proxy so all chained calls (e.g. + * chalk.bold.yellow()) simply return the input string unmodified. + */ +jest.mock('chalk', () => { + const identity = (str: string) => str; + const handler: ProxyHandler = { + get: () => new Proxy(identity, handler), + apply: (_target, _thisArg, args) => args[0], + }; + return { __esModule: true, default: new Proxy(identity, handler) }; +}); + +import * as output from '../../src/utils/output.js'; + +describe('output utilities', () => { + let consoleOutput: string[]; + const originalLog = console.log; + + beforeEach(() => { + consoleOutput = []; + console.log = (...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }; + output.setPrefix(''); + }); + + afterEach(() => { + console.log = originalLog; + }); + + describe('table()', () => { + it('should render a header row and separator', () => { + output.table(['Name', 'Value'], [['foo', 'bar']]); + + expect(consoleOutput[0]).toBe('| Name | Value |'); + expect(consoleOutput[1]).toBe('|------|-------|'); + expect(consoleOutput[2]).toBe('| foo | bar |'); + }); + + it('should pad columns based on widest content', () => { + output.table( + ['Col', 'Description'], + [ + ['a', 'short'], + ['longer-name', 'x'], + ], + ); + + // Header separator should match the widest column widths + expect(consoleOutput[1]).toContain('-'.repeat('longer-name'.length + 2)); + // All rows should have consistent pipe positions + const pipePositions = (line: string) => [...line].reduce((acc, ch, i) => { + if (ch === '|') acc.push(i); + return acc; + }, []); + + const headerPipes = pipePositions(consoleOutput[0]); + const row1Pipes = pipePositions(consoleOutput[2]); + const row2Pipes = pipePositions(consoleOutput[3]); + expect(headerPipes).toEqual(row1Pipes); + expect(headerPipes).toEqual(row2Pipes); + }); + + it('should handle empty rows array', () => { + output.table(['A', 'B'], []); + + expect(consoleOutput).toHaveLength(2); // header + separator only + expect(consoleOutput[0]).toBe('| A | B |'); + }); + + it('should handle cells with ANSI codes and pad correctly', () => { + // Construct ANSI-colored string manually: cyan "foo" + const colored = '\x1b[36mfoo\x1b[39m'; + output.table(['Name'], [[colored], ['longer']]); + + // The separator should be based on 'longer' (6 chars), not the ANSI-coded 'foo' + expect(consoleOutput[1]).toBe('|--------|'); + // The colored cell should be padded to match 'longer' width + expect(consoleOutput[2]).toBe('| \x1b[36mfoo\x1b[39m |'); + }); + + it('should handle missing cells gracefully', () => { + output.table(['A', 'B', 'C'], [['only-one']]); + + expect(consoleOutput[2]).toContain('only-one'); + // Should not throw + expect(consoleOutput).toHaveLength(3); + }); + }); + + describe('section()', () => { + it('should print the section header', () => { + output.section('Test Section'); + + const nonEmpty = consoleOutput.filter((line) => line !== ''); + expect(nonEmpty[0]).toContain('Test Section'); + }); + + it('should print a dashed underline matching header length', () => { + output.section('My Header'); + + const dashLine = consoleOutput.find((line) => /^-+$/.test(line)); + expect(dashLine).toBeDefined(); + expect(dashLine!.length).toBe('My Header'.length); + }); + + it('should have blank lines before and after', () => { + output.section('Title'); + + expect(consoleOutput[0]).toBe(''); + expect(consoleOutput[consoleOutput.length - 1]).toBe(''); + }); + }); + + describe('title()', () => { + it('should print the title with = underline', () => { + output.title('My Title'); + + const nonEmpty = consoleOutput.filter((line) => line !== ''); + expect(nonEmpty[0]).toContain('My Title'); + expect(nonEmpty[1]).toContain('='.repeat('My Title'.length)); + }); + + it('should have blank lines before and after', () => { + output.title('Hello'); + + expect(consoleOutput[0]).toBe(''); + expect(consoleOutput[consoleOutput.length - 1]).toBe(''); + }); + }); +});