From 853b66741b0fa329baaeb1b82a98d7caa46ff6ec Mon Sep 17 00:00:00 2001 From: DavidWells Date: Mon, 6 Jul 2026 10:48:29 -0700 Subject: [PATCH] fix(cli): piped --help no longer glues flag names to descriptions The help renderer separated terms from descriptions with only an ANSI color code, so piped output read '--dryDry run: ...'. Pad every row with real whitespace and type the noBaseOptions accessor properly. --- src/commands/base-command.ts | 12 +++-- tests/unit/commands/help-format.test.ts | 65 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/unit/commands/help-format.test.ts diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 11f10bf859b..26f8d45d8b0 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -304,6 +304,11 @@ export default class BaseCommand extends Command { } #noBaseOptions = false + + get noBaseOptions(): boolean { + return this.#noBaseOptions + } + /** don't show help options on command overview (mostly used on top commands like `addons` where options only apply on children) */ noHelpOptions() { this.#noBaseOptions = true @@ -353,7 +358,6 @@ export default class BaseCommand extends Command { /** override the longestOptionTermLength to react on hide options flag */ help.longestOptionTermLength = (command: BaseCommand, helper: Help): number => - // @ts-expect-error TS(2551) FIXME: Property 'noBaseOptions' does not exist on type 'C... Remove this comment to see the full error message (command.noBaseOptions === false && helper.visibleOptions(command).reduce((max, option) => Math.max(max, helper.optionTerm(option).length), 0)) || 0 @@ -367,9 +371,9 @@ export default class BaseCommand extends Command { const bang = isCommand ? `${HELP_$} ` : '' if (description) { - const pad = termWidth + HELP_SEPARATOR_WIDTH - const fullText = `${bang}${term.padEnd(pad - (isCommand ? 2 : 0))}${chalk.grey(description)}` - return helper.wrap(fullText, helpWidth - HELP_INDENT_WIDTH, pad) + const pad = Math.max(termWidth + HELP_SEPARATOR_WIDTH - (isCommand ? 2 : 0), term.length + 2) + const fullText = `${bang}${term.padEnd(pad)}${chalk.grey(description)}` + return helper.wrap(fullText, helpWidth - HELP_INDENT_WIDTH, pad + (isCommand ? 2 : 0)) } return `${bang}${term}` diff --git a/tests/unit/commands/help-format.test.ts b/tests/unit/commands/help-format.test.ts new file mode 100644 index 00000000000..d6c0fe89041 --- /dev/null +++ b/tests/unit/commands/help-format.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'vitest' + +import BaseCommand from '../../../src/commands/base-command.js' + +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string): string => text.replace(/\[[0-9;]*m/g, '') + +describe('help formatting without a TTY', () => { + test('separates a short flag from its description with at least two spaces', () => { + const program = new BaseCommand('netlify') + const build = program + .command('build') + .description('Build on your local machine') + .option('--dry', 'Dry run: show instructions without running them', false) + .option('--context ', 'Specify a deploy context') + + const helpText = stripAnsi(build.helpInformation()) + + expect(helpText).toMatch(/--dry {2,}Dry run: show instructions/) + expect(helpText).not.toMatch(/--dryDry/) + }) + + test('every OPTIONS row keeps at least two spaces between term and description', () => { + const program = new BaseCommand('netlify') + const api = program + .command('api') + .description('Run any Netlify API method') + .option('-d, --data ', 'Data to use') + .option('--list', 'List out available API methods', false) + + const helpText = stripAnsi(api.helpInformation()) + const optionsSection = helpText.split('OPTIONS')[1]?.split('\n\n')[0] ?? '' + const rows = optionsSection.split('\n').filter((line) => /^\s+-/.test(line)) + + expect(rows.length).toBeGreaterThan(0) + for (const row of rows) { + expect(row).toMatch(/^ {2}\S.* {2,}\S/) + } + + expect(helpText).not.toMatch(/Data/) + }) + + test('option terms contribute to the help column width', () => { + const program = new BaseCommand('netlify') + const cmd = program + .command('example') + .description('Example command') + .option('--a-really-long-option-name ', 'Long option') + .option('--dry', 'Short option') + + const helpText = stripAnsi(cmd.helpInformation()) + const longRow = helpText.split('\n').find((line) => line.includes('--a-really-long-option-name')) + + expect(longRow).toBeDefined() + expect(longRow).toMatch(/ {2,}Long option/) + }) + + test('noHelpOptions exposes noBaseOptions and hides the OPTIONS section', () => { + const program = new BaseCommand('netlify') + + expect(program.noBaseOptions).toBe(false) + program.noHelpOptions() + expect(program.noBaseOptions).toBe(true) + }) +})