Files
Benjamin Diedrichsen 8fa0cfa271 [keyman] audit + remediation plan, and phase 1: CLI error boundary
docs/AUDIT.md and docs/PLAN.md record the review and the ten phases it
turns into. This commit is phase 1.

keyman.cli.ts fell through to an interactive session for --help, ignored
unknown flags, and called keyman() unawaited — so Ctrl-C at any prompt,
and any rejection inside the menu loop, became an unhandled-rejection
stack trace. flagValue() also read `--channel --force` as the channel
"--force", which reached the dist-tag lookup as a key that cannot exist
and reported an unreachable registry.

New keyman.args.ts owns the parse: both --flag value and --flag=value, a
UsageError for an unknown flag or command, --channel validated against
the three real channels, and self-update-only flags rejected rather than
silently ignored. It is a separate module because cli.ts is excluded from
coverage and these are rules, not wiring. --help short-circuits before
tokenising, so it answers a line the parser would otherwise reject.

Usage errors exit 2; ExitPromptError is caught by name (@inquirer/core is
transitive here and does not resolve) and prints Goodbye.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:17:52 +02:00

156 lines
5.4 KiB
TypeScript

/**
* Tests for keyman's argv parsing.
*
* The old inline reader in keyman.cli.ts turned three different mistakes into
* silence or into a wrong diagnosis, so the interesting cases here are the
* rejections rather than the happy paths.
*/
import { describe, expect, it } from 'vitest';
import { CHANNELS, helpText, KNOWN_FLAGS, parseArgs, UsageError } from '../src/keyman.args.js';
describe('parseArgs', () => {
it('defaults to the interactive session', () => {
expect(parseArgs([])).toEqual({ command: 'interactive' });
});
it.each([
[['--help'], 'help'],
[['-h'], 'help'],
[['--version'], 'version'],
[['-V'], 'version'],
[['--print-config'], 'print-config'],
] as const)('%s selects %s', (argv, command) => {
expect(parseArgs([...argv])).toEqual({ command });
});
it('answers --help even when the rest of the line is wrong', () => {
expect(parseArgs(['--bogus', '--help'])).toEqual({ command: 'help' });
expect(parseArgs(['--help', '--channel'])).toEqual({ command: 'help' });
});
describe('self-update', () => {
it.each(['self-update', 'upgrade'])('is selected by the %s subcommand', (subcommand) => {
expect(parseArgs([subcommand])).toEqual({
command: 'self-update',
dryRun: false,
force: false,
channel: undefined,
registry: undefined,
});
});
it('is selected by --self-update', () => {
expect(parseArgs(['--self-update'])).toMatchObject({ command: 'self-update' });
});
it('collects its flags, long and short', () => {
expect(parseArgs(['self-update', '--dry-run', '--force'])).toMatchObject({
dryRun: true,
force: true,
});
expect(parseArgs(['self-update', '-n', '-f'])).toMatchObject({
dryRun: true,
force: true,
});
});
it.each(['--channel main', '--channel=main'])('accepts %s', (form) => {
expect(parseArgs(['self-update', ...form.split(' ')])).toMatchObject({ channel: 'main' });
});
it('accepts every real channel', () => {
for (const channel of CHANNELS) {
expect(parseArgs(['self-update', '--channel', channel])).toMatchObject({ channel });
}
});
it('reads a registry in either form', () => {
expect(parseArgs(['self-update', '--registry', 'https://r.example'])).toMatchObject({
registry: 'https://r.example',
});
expect(parseArgs(['self-update', '--registry=https://r.example'])).toMatchObject({
registry: 'https://r.example',
});
});
it('keeps a value that starts with a dash when it was written inline', () => {
expect(parseArgs(['self-update', '--registry=-weird'])).toMatchObject({
registry: '-weird',
});
});
});
describe('rejections', () => {
const reject = (argv: string[]) => () => parseArgs(argv);
it('rejects a channel that is not a channel', () => {
expect(reject(['self-update', '--channel', 'stable'])).toThrow(UsageError);
expect(reject(['self-update', '--channel', 'stable'])).toThrow(
'Unknown channel: stable (expected latest, next, main)'
);
});
it('rejects the next flag being eaten as a value', () => {
// The bug this whole module exists for: --channel --force used to set the
// channel to "--force" and report an unreachable registry.
expect(reject(['self-update', '--channel', '--force'])).toThrow('--channel expects a value');
});
it('rejects a value flag with nothing after it', () => {
expect(reject(['self-update', '--channel'])).toThrow('--channel expects a value');
expect(reject(['self-update', '--registry='])).toThrow('--registry expects a value');
});
it('rejects a boolean flag given a value', () => {
expect(reject(['--dry-run=yes'])).toThrow('--dry-run does not take a value');
});
it('rejects unknown flags and commands', () => {
expect(reject(['--vault', 'foo'])).toThrow('Unknown flag: --vault');
expect(reject(['-x'])).toThrow('Unknown flag: -x');
expect(reject(['encrypt'])).toThrow('Unknown command: encrypt');
expect(reject(['self-update', 'upgrade'])).toThrow('Unexpected argument: upgrade');
});
it.each(['--channel', '--registry', '--dry-run', '-n', '--force', '-f'])(
'rejects %s without self-update rather than ignoring it',
(flag) => {
const argv = flag === '--channel' || flag === '--registry' ? [flag, 'main'] : [flag];
expect(reject(argv)).toThrow('is only valid with `keyman self-update`');
}
);
it('rejects a self-update flag alongside another command', () => {
expect(reject(['--print-config', '--force'])).toThrow('--force is only valid');
});
});
});
describe('helpText', () => {
it('documents every flag the parser accepts', () => {
const text = helpText();
for (const flag of KNOWN_FLAGS) {
expect(text, `${flag} is missing from --help`).toContain(flag);
}
});
it('names both subcommands, every channel, and the environment variables', () => {
const text = helpText();
expect(text).toContain('self-update');
expect(text).toContain('upgrade');
for (const channel of CHANNELS) {
expect(text).toContain(channel);
}
for (const variable of [
'VAULT_ROOT',
'KEYMAN_REGISTRY',
'KEYMAN_REGISTRY_TOKEN',
'KEYMAN_NO_UPDATE_CHECK',
'KEYMAN_PACKAGE_MANAGER',
]) {
expect(text).toContain(variable);
}
});
});