[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:
@@ -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.
|
||||
`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user