From 9f7e1799c389b8d43252be4149c7b21a787e7ae8 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 11:08:30 -0500 Subject: [PATCH 1/6] ENG-219: Add help command Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 3 ++ src/commands/help.ts | 80 +++++++++++++++++++++++++++++++++++++ tests/commands/help.test.ts | 17 ++++++++ 3 files changed, 100 insertions(+) create mode 100644 src/commands/help.ts create mode 100644 tests/commands/help.test.ts diff --git a/src/cli.ts b/src/cli.ts index 50ff300..4a5c634 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,10 @@ import { createApp } from './app.js'; +import { registerHelpCommand } from './commands/help.js'; 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..d649bb5 --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,80 @@ +import type { Command } from 'commander'; +import fs from 'fs-extra'; +import path from 'node:path'; +import { getDefaultsDir } from '../config.js'; +import * as output from '../utils/output.js'; + +/** + * 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]') + .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) { + // Show command list + output.title('PUP - Project Utilities & Packager'); + output.log('Available commands:\n'); + + const lines = contents.split('\n'); + for (const line of lines) { + const match = line.match(/^\* \[`(.+?)`\]/); + if (match) { + output.log(` ${match[1]}`); + } + } + + output.newline(); + output.log('Run "pup help " for more information on a specific command.'); + return; + } + + // Show specific topic + const sections = contents.split(/^## /m); + const normalizedTopic = topic.replace('pup ', '').replace('pup-', ''); + + for (const section of sections) { + const headerMatch = section.match(/^`pup (.+?)`/); + if (!headerMatch) continue; + + const sectionName = headerMatch[1]; + if ( + sectionName === normalizedTopic || + sectionName === `pup ${normalizedTopic}` || + sectionName.includes(normalizedTopic) + ) { + output.title(`pup ${sectionName}`); + // Strip markdown formatting for console + const body = section + .replace(/^`pup .+?`\n/, '') + .replace(/```[a-z]*\n/g, '') + .replace(/```\n?/g, '') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .trim(); + + output.log(body); + return; + } + } + + output.error(`Unknown topic: ${topic}`); + }); +} diff --git a/tests/commands/help.test.ts b/tests/commands/help.test.ts new file mode 100644 index 0000000..9bc2cc0 --- /dev/null +++ b/tests/commands/help.test.ts @@ -0,0 +1,17 @@ +import { runPup, resetFixtures, writeDefaultPuprc } from '../helpers/setup.js'; + +describe('help command', () => { + beforeEach(() => { + writeDefaultPuprc(); + }); + + afterEach(() => { + resetFixtures(); + }); + + it('should show help output', async () => { + const result = await runPup('help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('pup'); + }); +}); From a43bcfa0267239f9d1e634405e766e4692586525 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:43:47 -0500 Subject: [PATCH 2/6] ENG-219: Use createTempProject in help tests for isolation Co-Authored-By: Claude Opus 4.6 --- tests/commands/help.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/commands/help.test.ts b/tests/commands/help.test.ts index 9bc2cc0..0a1845e 100644 --- a/tests/commands/help.test.ts +++ b/tests/commands/help.test.ts @@ -1,16 +1,25 @@ -import { runPup, resetFixtures, writeDefaultPuprc } from '../helpers/setup.js'; +import { + runPup, + writePuprc, + getPuprc, + createTempProject, + cleanupTempProjects, +} from '../helpers/setup.js'; describe('help command', () => { + let projectDir: string; + beforeEach(() => { - writeDefaultPuprc(); + projectDir = createTempProject(); + writePuprc(getPuprc(), projectDir); }); afterEach(() => { - resetFixtures(); + cleanupTempProjects(); }); it('should show help output', async () => { - const result = await runPup('help'); + const result = await runPup('help', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('pup'); }); From 49c74b0462c2f11e7f02bdbe66f9d1f5023ce3eb Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 17:05:29 -0500 Subject: [PATCH 3/6] ENG-219: Add styled output to help command matching PHP version Port the rich console styling from the original PHP help command: - Decorative banner with colored P/U/P title - Command list displayed in a formatted table with yellow names - Topic detail view with colored sections, code blocks, and tables - Add table() and section underline helpers to output utility - Fix link regex ordering to prevent ANSI escape code corruption Co-Authored-By: Claude Opus 4.6 --- src/commands/help.ts | 214 ++++++++++++++++++++++++++++++++++--------- src/utils/output.ts | 52 +++++++++++ 2 files changed, 225 insertions(+), 41 deletions(-) diff --git a/src/commands/help.ts b/src/commands/help.ts index d649bb5..b808a70 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,9 +1,179 @@ import type { Command } from 'commander'; +import chalk from 'chalk'; import fs from 'fs-extra'; import path from 'node:path'; import { getDefaultsDir } from '../config.js'; import * as output from '../utils/output.js'; +/** + * 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. * @@ -29,52 +199,14 @@ export function registerHelpCommand(program: Command): void { const contents = await fs.readFile(commandsPath, 'utf-8'); if (!topic) { - // Show command list - output.title('PUP - Project Utilities & Packager'); - output.log('Available commands:\n'); - - const lines = contents.split('\n'); - for (const line of lines) { - const match = line.match(/^\* \[`(.+?)`\]/); - if (match) { - output.log(` ${match[1]}`); - } - } - - output.newline(); - output.log('Run "pup help " for more information on a specific command.'); + printCommandList(contents); return; } - // Show specific topic - const sections = contents.split(/^## /m); const normalizedTopic = topic.replace('pup ', '').replace('pup-', ''); - for (const section of sections) { - const headerMatch = section.match(/^`pup (.+?)`/); - if (!headerMatch) continue; - - const sectionName = headerMatch[1]; - if ( - sectionName === normalizedTopic || - sectionName === `pup ${normalizedTopic}` || - sectionName.includes(normalizedTopic) - ) { - output.title(`pup ${sectionName}`); - // Strip markdown formatting for console - const body = section - .replace(/^`pup .+?`\n/, '') - .replace(/```[a-z]*\n/g, '') - .replace(/```\n?/g, '') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/`(.+?)`/g, '$1') - .trim(); - - output.log(body); - return; - } + if (!printCommandHelp(normalizedTopic, contents)) { + output.error(`Unknown topic: ${topic}`); } - - output.error(`Unknown topic: ${topic}`); }); } diff --git a/src/utils/output.ts b/src/utils/output.ts index 67c02bc..8b0f82b 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -122,6 +122,8 @@ 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.bold.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)); + } +} From db4a0ff8e5d5bc7d2c72d77b1ba79e6bdd3a3597 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Thu, 12 Feb 2026 09:35:24 -0500 Subject: [PATCH 4/6] ENG-219: Add tests for help command styling and output utilities Integration tests for the help command covering banner, command table, topic detail rendering, section headers, code blocks, argument tables, markdown stripping, sub-commands, and unknown topic error handling. Unit tests for output.table(), output.section(), and output.title(). Co-Authored-By: Claude Opus 4.6 --- tests/commands/help.test.ts | 198 +++++++++++++++++++++++++++++++++++- tests/utils/output.test.ts | 135 ++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 tests/utils/output.test.ts diff --git a/tests/commands/help.test.ts b/tests/commands/help.test.ts index 0a1845e..1ed8e76 100644 --- a/tests/commands/help.test.ts +++ b/tests/commands/help.test.ts @@ -18,9 +18,199 @@ describe('help command', () => { cleanupTempProjects(); }); - it('should show help output', async () => { - const result = await runPup('help', { cwd: projectDir }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('pup'); + 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(''); + }); + }); +}); From 5c88d4d591ea168b5fb05b06e8c049f725fc1037 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Thu, 12 Feb 2026 14:29:15 -0500 Subject: [PATCH 5/6] ENG-219: Brought over output changes from ENG-219/command-info --- src/utils/output.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utils/output.ts b/src/utils/output.ts index 8b0f82b..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,8 +121,8 @@ 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.bold.yellow('-'.repeat(message.length)))); + console.log(formatMessage(chalk.yellow(message))); + console.log(formatMessage(chalk.yellow('-'.repeat(message.length)))); console.log(''); } From aed44563ab6f0cccf57d8f63f30472a01c12fea5 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 09:31:36 -0500 Subject: [PATCH 6/6] ENG-219: Make help the default command when pup is run without arguments --- src/commands/help.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/help.ts b/src/commands/help.ts index b808a70..08c8530 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -185,7 +185,7 @@ function printCommandHelp(topic: string, contents: string): boolean { */ export function registerHelpCommand(program: Command): void { program - .command('help [topic]') + .command('help [topic]', { isDefault: true }) .description('Shows help for pup.') .action(async (topic?: string) => { const docsDir = path.resolve(getDefaultsDir(), '..', 'docs');