[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>
This commit is contained in:
Benjamin Diedrichsen
2026-07-30 14:17:52 +02:00
parent 75983ab3b1
commit 8fa0cfa271
5 changed files with 1480 additions and 15 deletions
+203
View File
@@ -0,0 +1,203 @@
/**
* Argv parsing for the keyman CLI.
*
* Separate from `keyman.cli.ts` because that file is excluded from coverage: it
* is meant to be wiring, and *which flag takes a value* and *which channel names
* are legal* are behaviour. The old inline `indexOf` reader accepted
* `--channel --force`, which reached the registry as a dist-tag that cannot
* exist and reported an unreachable registry instead of a bad flag.
*/
import type { Channel } from './keyman.update.js';
/** The channels `--channel` accepts, in the order the error message lists them */
export const CHANNELS: readonly Channel[] = ['latest', 'next', 'main'];
/** Flags that consume the next token, or the suffix of a `--flag=value` */
const VALUE_FLAGS: readonly string[] = ['--channel', '--registry'];
/** Flags that stand alone, short aliases included */
const BOOLEAN_FLAGS: readonly string[] = [
'--help',
'-h',
'--version',
'-V',
'--print-config',
'--self-update',
'--dry-run',
'-n',
'--force',
'-f',
];
/**
* Flags that only mean anything to `self-update`. Named so that using one on its
* own is an error rather than a silent no-op.
*/
const SELF_UPDATE_ONLY: readonly string[] = [
'--channel',
'--registry',
'--dry-run',
'-n',
'--force',
'-f',
];
/** Every flag the parser accepts — the list `helpText()` is checked against */
export const KNOWN_FLAGS: readonly string[] = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
const SUBCOMMANDS: readonly string[] = ['self-update', 'upgrade'];
export type ParsedArgs =
| { command: 'help' }
| { command: 'version' }
| { command: 'print-config' }
| { command: 'interactive' }
| {
command: 'self-update';
dryRun: boolean;
force: boolean;
channel?: Channel;
registry?: string;
};
/**
* A mistake in the invocation. Carries a message meant for the user, so the CLI
* can print one line instead of a stack trace.
*/
export class UsageError extends Error {
constructor(message: string) {
super(message);
this.name = 'UsageError';
}
}
/**
* Turns argv (already sliced past `node` and the script) into one command.
*
* @throws {UsageError} on an unknown flag or command, a value flag with no
* value, a boolean flag given one, or a channel that is not a real channel
*/
export function parseArgs(argv: string[]): ParsedArgs {
// Before tokenising, so that help answers a line it could not otherwise parse.
// Exact tokens only: `--registry=--help` is a (bad) registry, not a request.
if (argv.some((token) => token === '--help' || token === '-h')) {
return { command: 'help' };
}
const flags = new Set<string>();
const values = new Map<string, string>();
let subcommand: string | undefined;
for (let index = 0; index < argv.length; index++) {
const token = argv[index];
if (!token.startsWith('-')) {
if (!SUBCOMMANDS.includes(token)) {
throw new UsageError(`Unknown command: ${token}`);
}
if (subcommand) {
throw new UsageError(`Unexpected argument: ${token}`);
}
subcommand = token;
continue;
}
const equals = token.indexOf('=');
const name = equals === -1 ? token : token.slice(0, equals);
if (VALUE_FLAGS.includes(name)) {
// A value that looks like a flag is a forgotten value, not a value —
// unless it was written as --flag=-value and therefore meant.
const inline = equals === -1 ? undefined : token.slice(equals + 1);
const value = inline ?? argv[++index];
if (!value || (inline === undefined && value.startsWith('-'))) {
throw new UsageError(`${name} expects a value`);
}
values.set(name, value);
continue;
}
if (!BOOLEAN_FLAGS.includes(name)) {
throw new UsageError(`Unknown flag: ${name}`);
}
if (equals !== -1) {
throw new UsageError(`${name} does not take a value`);
}
flags.add(name);
}
const given = (...names: string[]) => names.some((name) => flags.has(name));
const isSelfUpdate = subcommand !== undefined || flags.has('--self-update');
if (!isSelfUpdate) {
const stray = [...values.keys(), ...flags].find((name) => SELF_UPDATE_ONLY.includes(name));
if (stray) {
throw new UsageError(`${stray} is only valid with \`keyman self-update\``);
}
}
if (flags.has('--print-config')) {
return { command: 'print-config' };
}
if (given('--version', '-V')) {
return { command: 'version' };
}
if (isSelfUpdate) {
const channel = values.get('--channel');
if (channel !== undefined && !CHANNELS.includes(channel as Channel)) {
throw new UsageError(`Unknown channel: ${channel} (expected ${CHANNELS.join(', ')})`);
}
return {
command: 'self-update',
dryRun: given('--dry-run', '-n'),
force: given('--force', '-f'),
channel: channel as Channel | undefined,
registry: values.get('--registry'),
};
}
return { command: 'interactive' };
}
/**
* What `--help` prints.
*
* Hand-written rather than generated from the flag tables, so that adding a flag
* to the parser without documenting it fails a test instead of shipping.
*/
export function helpText(): string {
return `keyman — SSH key management and an age-encrypted key vault
Usage
keyman start the interactive menu
keyman self-update update keyman itself (alias: upgrade)
Flags
-h, --help print this help and exit
-V, --version print the version and exit
--print-config print the resolved vault paths as JSON and exit
--self-update same as the self-update subcommand
Flags for self-update
--channel <${CHANNELS.join('|')}> channel to update from
(default: derived from the running version)
--registry <url> registry to query instead of the configured one
-n, --dry-run print the install command without running it
-f, --force reinstall even when already up to date
Environment
VAULT_ROOT overrides vaultRoot from .keymanrc.json
KEYMAN_REGISTRY registry for the update check and self-update
KEYMAN_REGISTRY_TOKEN bearer token for a private registry
KEYMAN_NO_UPDATE_CHECK set to 1 to skip the once-a-day update check
(also skipped whenever CI is set)
KEYMAN_PACKAGE_MANAGER npm | pnpm | yarn | bun for the install command
Configuration is read from .keymanrc.json, merged from the current directory
upwards and then from ~/.keymanrc.json.
`;
}
+33 -15
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
import { createRequire } from 'node:module';
import { helpText, type ParsedArgs, parseArgs, UsageError } from './keyman.args.js';
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { keyman } from './keyman.main.js';
import type { Channel } from './keyman.update.js';
import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js';
const { version, buildInfo } = createRequire(import.meta.url)('../package.json') as {
@@ -18,35 +18,42 @@ const { version, buildInfo } = createRequire(import.meta.url)('../package.json')
*/
const versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
const args = process.argv.slice(2);
/** Reads `--flag value` out of argv, or undefined when the flag is absent */
function flagValue(name: string): string | undefined {
const index = args.indexOf(name);
return index === -1 ? undefined : args[index + 1];
let parsed: ParsedArgs;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
if (!(error instanceof UsageError)) throw error;
console.error(`${error.message}`);
console.error('Run `keyman --help` for usage.');
process.exit(2);
}
if (args.includes('--print-config')) {
if (parsed.command === 'help') {
console.log(helpText());
process.exit(0);
}
if (parsed.command === 'print-config') {
const config = loadConfig();
const paths = resolveConfigPaths(config);
console.log(JSON.stringify(paths));
process.exit(0);
}
if (args.includes('--version') || args.includes('-V')) {
if (parsed.command === 'version') {
console.log(versionLabel);
process.exit(0);
}
if (args[0] === 'self-update' || args[0] === 'upgrade' || args.includes('--self-update')) {
const dryRun = args.includes('--dry-run') || args.includes('-n');
if (parsed.command === 'self-update') {
const { dryRun } = parsed;
try {
const result = await selfUpdate({
currentVersion: version,
channel: flagValue('--channel') as Channel | undefined,
registry: flagValue('--registry'),
channel: parsed.channel,
registry: parsed.registry,
dryRun,
force: args.includes('--force') || args.includes('-f'),
force: parsed.force,
});
const { status } = result;
@@ -79,4 +86,15 @@ if (notice) {
console.error(`\n${notice}\n`);
}
keyman();
try {
await keyman();
} catch (error) {
// Ctrl-C at any inquirer prompt lands here. `name`, not `instanceof`:
// @inquirer/core is transitive and does not resolve from this package.
if ((error as { name?: string }).name === 'ExitPromptError') {
console.log('\n👋 Goodbye!\n');
process.exit(0);
}
console.error(`${error instanceof Error ? error.message : error}`);
process.exit(1);
}