[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
+656
View File
@@ -0,0 +1,656 @@
# keyman audit
A review of `packages/keyman` for defects, unimplemented features, and drift
between the code and the documents that describe it.
Severity is about what it costs a user:
- **🔴 broken** — normal use produces a crash, data loss, or a silently wrong result.
- **🟠 misleading** — the code or a document states something that is not true.
- **🟡 gap** — something real that nothing mentions, or dead weight nobody uses.
Verified against `75983ab` with no uncommitted changes in the package. Line
numbers are from that state. Findings marked **verified** were reproduced by
running the code, not inferred from reading it; the reproduction is quoted.
Baseline: 162 tests pass, 98.9 % lines / 96.2 % branches. High coverage is
context for §1.2, not a defence of it.
---
## Contents
- [1. Defects](#1-defects)
- [2. Security](#2-security)
- [3. Unimplemented and dead](#3-unimplemented-and-dead)
- [4. Public API and packaging](#4-public-api-and-packaging)
- [5. Documentation drift](#5-documentation-drift)
- [6. Checked and accurate](#6-checked-and-accurate)
- [Suggested order of attack](#suggested-order-of-attack)
---
## 1. Defects
### 1.1 🔴 `keysDir` and `tmpDir` are honoured by half the tool
`resolveConfigPaths()` (`keyman.config.ts:249-259`) resolves all four paths from
the config, and `keyman.main.ts:18-21` prints them. But dispatch is inconsistent
about what it hands each operation:
| Operation | receives | uses |
| --- | --- | --- |
| `listKeys` (`main.ts:67`) | `paths.keysDir` | the configured directory |
| `generateKey` (`main.ts:73`) | `paths.keysDir`, `paths.tmpDir` | the configured directories |
| `copyKey` (`main.ts:70`) | `paths.tmpDir` | the configured directory |
| `encryptKeys` (`main.ts:76`) | `paths.vaultRoot` | **hardcoded** `<vaultRoot>/keys` (`encrypt.ts:38`) |
| `decryptKeys` (`main.ts:84`) | `paths.vaultRoot` | **hardcoded** `<vaultRoot>/keys` (`decrypt.ts:7`) and `<vaultRoot>/tmp` (`decrypt.ts:39,43`) |
With the defaults the two halves agree, which is why this is invisible. Set
either sub-directory and the vault splits in two.
**Verified.** With `{"vaultRoot":"./v","keysDir":"encrypted","tmpDir":"plain"}`,
`keyman --print-config` reports:
```json
{"...":"...","keysDir":"…/v/encrypted","tmpDir":"…/v/plain","keyPath":"…/v/age.key"}
```
so `generate` writes to `v/encrypted/<name>/` and `list` scans `v/encrypted`,
while `encrypt` writes to `v/keys`, and `decrypt` reads `v/keys` and writes
`v/tmp` — never touching either configured directory. Concretely:
- encrypt a key, then list it → the list shows nothing in `[Vault]`.
- generate a key, then decrypt it → "⚠️ No encrypted keys found."
- decrypt to local, then encrypt → the key is not offered, because `encrypt`
reads the configured `tmpDir` while `decrypt` wrote to the hardcoded one.
No error at any point. The user has two vaults and one of them is invisible to
whichever operation they try next.
The current behaviour is locked in by tests: `main.test.ts:164-187` asserts
`vaultRoot` is what encrypt and decrypt receive ("encrypts keys into the vault
root"), and `encrypt.test.ts:95` / `decrypt.test.ts:53` assert the literal
`keys` segment. Fixing this means changing those assertions.
**Fix.** Pass `paths.keysDir` and `paths.tmpDir` into `encryptKeys` and
`decryptKeys` and delete the three `path.join(vaultDir, 'keys' | 'tmp')` calls.
Neither function has a use for `vaultRoot` once that is done, so the parameter
goes away rather than becoming a second source of truth.
See also §5.1 — `DOCS-AUDIT.md` currently lists this layout under *checked and
accurate*.
### 1.2 🔴 Encrypt and decrypt crash with a raw stack trace on a first run
Three `readdirSync` calls have no `existsSync` guard:
- `encrypt.ts:13``~/.ssh`, which nothing creates.
- `encrypt.ts:16` — the tmp directory.
- `decrypt.ts:8``<vault>/keys`, which nothing creates either.
`keyman.main.ts:40-41` creates `vaultRoot` and `tmpDir`. It does **not** create
`keysDir`, so `decrypt` on a fresh vault throws instead of printing its
"⚠️ No encrypted keys found." message — the message is unreachable until the
directory exists for some other reason.
**Verified**, calling both functions directly against a vault laid out the way
`main.ts` lays it out:
```
--- A: decryptKeys with no vault/keys directory ---
THREW: Error ENOENT ENOENT: no such file or directory, scandir '…/vault/keys'
--- B: encryptKeys with no ~/.ssh directory ---
THREW: Error ENOENT ENOENT: no such file or directory, scandir '…/home/.ssh'
```
What the user sees is worse than the exception, because of `keyman.cli.ts:82`:
```ts
keyman();
```
Not awaited, no `.catch`. Any rejection anywhere in the menu loop becomes an
unhandled rejection: Node prints the stack and exits non-zero, and the menu loop
— whose whole point (`README.md:97`) is that you can run several operations in
one session — is gone.
`copyKey` guards (`copy.ts:8`) and `listKeys` guards all three of its
directories (`list.ts:22,50,78`). Encrypt and decrypt are the outliers, not the
rule.
Worth noting where the coverage numbers sit: `keyman.encrypt.ts` and
`keyman.decrypt.ts` are both at **100 % lines, 100 % branches**. Every test
creates the directories in `beforeEach` (`encrypt.test.ts:46-47`,
`decrypt.test.ts:54-55`), so the missing guard is not a branch that went
uncovered — it is a branch that was never written. Line coverage measures lines
executed, not inputs considered.
**Half closed (Phase 1).** `keyman()` is now awaited inside a `catch`, so a
rejection is one line rather than an unhandled-rejection stack trace. The missing
`existsSync` guards — and with them the menu loop surviving a failed operation —
are Phase 2.
### 1.3 🔴 A missing `age.key` becomes `age -r null`
`keyman.main.ts:73` and `:80` assert away a null:
```ts
extractAgePublicKey(paths.keyPath)!
```
`extractAgePublicKey` returns `string | null` (`utils.ts:8-22`) and returns null
in three cases: the file is missing, it is unreadable, or it parses but has no
`# public key:` line. In all three it prints an error and returns — and the
non-null assertion carries that null straight into an `execa` argv.
**Verified**, both halves:
```
❌ ERROR: Age key file not found at /nope/age.key
extractAgePublicKey(missing) = null
execa with null recipient THREW: ExecaError | Command failed with exit code 1: age -r null -o /tmp/x.age /etc/hosts
```
execa stringifies the null, so the recipient becomes the literal `"null"`.
The two call sites fail differently, and the generate path fails worse:
- `generateKey` runs `ssh-keygen` **first** (`generate.ts:59`) and `age` second
(`generate.ts:68`). Its `try/catch` swallows the failure into "❌ Error
generating/encrypting key", but by then the private key is on disk in `tmpDir`
in plaintext, and the user has been told the operation failed. Nothing tells
them a key was left behind.
- `encryptKeys` has no `try/catch` at all, so it takes the §1.2 path: unhandled
rejection, stack trace, session over.
**Fix.** Resolve the recipient once, before dispatch, and treat null as a
recoverable condition: print what to run (`age-keygen -o <keyPath>`) and return
to the menu. The type already says this is possible; the `!` is the only thing
claiming otherwise.
### 1.4 🔴 Decrypting into `~/.ssh` silently overwrites an existing key
`decrypt.ts:47-49` writes the decrypted key and copies the public key with no
existence check, no confirmation, and no backup.
**Verified** that `age -o` does not refuse an existing file:
```
before: PRECIOUS EXISTING KEY
age -o exit=0 (overwrote)
after: secret
```
So selecting `prod` with the `SSH (~/.ssh)` destination replaces
`~/.ssh/id_prod` outright. If the vault copy is stale, or the folder name
happens to collide with an unrelated local key, the local key is gone — and this
is the one operation in the tool that writes outside the vault, into the
directory the user's actual SSH access depends on.
The `Local (vault/tmp)` destination has the same behaviour but a much lower cost,
since `vault/tmp` is scratch space by design.
**Fix.** Check both output paths before decrypting anything and prompt per
collision, or refuse and name the file. A `--force` equivalent can come later;
the current default should not be "overwrite".
### 1.5 🟠 `age` or `ssh-keygen` missing is unhandled in encrypt and decrypt
Same missing `try/catch` as §1.3. `generateKey` (`generate.ts:51-76`) and
`copyKey` (`copy.ts:43-57`) both wrap their `execa` calls and report a failure;
`encryptKeys` and `decryptKeys` do not. On a machine without `age` on `PATH`
the one hard external requirement, per `CLAUDE.md` — choosing Encrypt from the
menu produces an `ENOENT` stack trace rather than "install age".
### 1.6 🟠 Encrypt copies `.pub` unconditionally and aborts the batch midway
`encrypt.ts:45`:
```ts
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
```
The selection list is built from *private* keys only (`encrypt.ts:14,17` filter
out `.pub`), so a private key with no `.pub` sibling is offered — and that is a
legal state, since `ssh-keygen -y` regenerates a public key on demand and people
do delete them.
When it happens, `age` has already written the `.age` file, so the throw leaves
the vault holding an encrypted key with no public key. Worse, the throw escapes
the `for` loop: every remaining selected key is skipped, with no output saying
so, and the process dies via §1.2.
`generate.ts:71` has the same shape but is far less likely to fire, since
`ssh-keygen` just wrote the file.
**Fix.** Derive the public key with `ssh-keygen -y -f <key>` when the sibling is
absent, and wrap the loop body so one bad key costs one key rather than the
batch.
### 1.7 🟠 `/home/<user>` is hardcoded
`keyman.main.ts:33`:
```ts
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
```
On macOS other users live under `/Users/`, and this tool is otherwise
macOS-specific (§1.9). Nothing checks the directory exists, so a wrong guess
feeds a nonexistent `sshDir` into §1.2 rather than into an error message.
`main.test.ts:198-204` locks in `/home/deploy/.ssh`.
**Fix.** `os.userInfo()` for the current user, and for a named user either look
the home directory up (`getent passwd` / `dscl`) or ask for the path outright.
Failing that, check `existsSync` and say so.
### 1.8 🟡 Keys not named `id_*` are invisible, silently
Every discovery filter requires the prefix: `copy.ts:9`, `encrypt.ts:14,17`,
`list.ts:23,51`, and `decrypt.ts:9` reconstructs `id_${dir}`. A key called
`deploy_ed25519` cannot be listed, copied, or encrypted, and nothing says why —
it simply is not in the menu.
`generateKey` enforces the prefix (`generate.ts:43`), so keys keyman creates are
always fine. The gap only bites pre-existing keys, which is exactly the
population a key manager is adopted to take over.
### 1.9 🟡 `pbcopy` is hardcoded
`copy.ts:49`, with the comment above it admitting the shortcut:
```ts
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness
```
On Linux or Windows, Copy public key always fails. It fails *cleanly* — the
`try/catch` reports "❌ Failed to copy to clipboard" — but the package declares
only `"node": ">=22"` in `engines` and the README says nothing, so nothing warns
before install. `xclip`/`wl-copy`/`clip.exe` by platform is a handful of lines;
alternatively print the key to stdout as a fallback so the operation is never a
dead end.
### 1.10 🟡 Smaller things
- **`listKeys` throws on a broken symlink.** `list.ts:80` calls `fs.statSync` on
every entry in the keys directory; a dangling symlink throws `ENOENT`, and
`listKeys` has no `try/catch`, so it exits via §1.2. `lstatSync`, or a
`withFileTypes` readdir, or a guard.
- **`key.replace('id_', '')` is unanchored** (`encrypt.ts:38`, `generate.ts:63`).
Every input is prefix-filtered today, so the first match *is* the prefix and
the behaviour is correct — it is a trap left for whoever loosens §1.8.
`replace(/^id_/, '')` costs nothing.
- **An `age` failure leaves an empty vault directory.** `generate.ts:65` creates
`<keysDir>/<name>/` before `generate.ts:68` runs `age`.
`generate.test.ts:150-163` asserts the `.pub` is absent afterwards but not the
directory, so this passes today. It makes the folder show up in
`decrypt`'s scan as a candidate that filters back out — harmless, untidy.
- **Debug output still in shipped code.** `encrypt.ts:18-19`
(`console.log(tmpKeys); console.log(sshKeys);`) is already tracked as
`DOCS-AUDIT.md` §6.4. `decrypt.ts:10` — a `console.log(keyfile)` *inside a
`filter` callback*, printing one line per vault directory — is not, and is the
more visible of the two.
---
## 2. Security
### 2.1 🔴 Decrypted private keys are world-readable before the chmod
`decrypt.ts:47-50` decrypts, then copies, then chmods — in three separate
processes:
```ts
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
await execa('cp', [publicKey, publicKeyOut]);
await execa('chmod', ['600', privateKeyOut]);
```
**Verified** what `age` creates, and what `mkdirSync` at `main.ts:41` creates:
```
-rw-r--r-- …/out ← the decrypted private key, as age leaves it
drwxr-xr-x …/tmpdir ← vault/tmp, as keyman creates it
```
So a plaintext private key exists at `0644` for the lifetime of two process
spawns, inside a `0755` directory any local user can traverse. If the `chmod`
fails or the process is killed in between, it stays `0644` — and because
`decryptKeys` has no `try/catch` (§1.5), a failing `chmod` also kills the
session before the next key is even attempted.
**Fix.** `fs.chmodSync` immediately after `age` returns rather than a third
spawn; create `tmpDir` with `{mode: 0o700}` and `~/.ssh` likewise if it is
missing. Replacing `cp` and `chmod` with `fs.copyFileSync` / `fs.chmodSync` also
removes two shell-outs that do not work on Windows and cuts three spawns per key
to one.
### 2.2 🟠 The passphrase is passed on the `ssh-keygen` command line
`generate.ts:53`:
```ts
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
```
argv is world-readable on both Linux (`/proc/<pid>/cmdline`) and macOS
(`ps -o command`) for the lifetime of the process. Any other user on the machine
can read the passphrase of a key being generated. `generate.test.ts:78-87`
asserts this exact argv.
**Fix.** Omit `-N` entirely and let `ssh-keygen` prompt on the tty — it already
asks twice and confirms, so keyman's own password prompt (`generate.ts:26-33`)
can go away rather than being replaced. That keeps the passphrase off argv
without keyman ever holding it.
### 2.3 🟠 The age recipient is trusted from a comment, never verified
`extractAgePublicKey` (`utils.ts:16`) regexes the recipient out of a comment
line in the identity file:
```ts
fileContents.match(/^# public key:\s*(age1[^\s]+)/m)
```
Nothing checks it corresponds to the private key in that same file. Edit the
comment — or concatenate two key files — and every subsequent encryption goes to
a recipient the local identity cannot decrypt. The failure surfaces only later,
at decrypt time, on keys that may no longer exist in plaintext anywhere.
**Fix.** `age-keygen -y <keyPath>` derives the public key *from the private key*
and is exactly the tool for this. Note that `age-keygen` is currently not
invoked anywhere in the source, despite `CLAUDE.md` listing it among the
binaries keyman shells out to (§5.5).
### 2.4 🟡 Nothing manages the plaintext left in `vault/tmp`
Decrypted keys accumulate in `vault/tmp` indefinitely. There is no shred
operation, no warning on exit, and keyman never writes the `.gitignore` its own
README (`README.md:25-26`, `:148`) tells the user to write by hand. The only
signal is the 🔓 marker in `listKeys`, which the user has to go looking for.
A "Clear decrypted keys" menu entry and a `.gitignore` written alongside the
vault on first run would cost little and close the most likely way a private key
reaches a public repository — which is the threat this tool exists to address.
---
## 3. Unimplemented and dead
### 3.1 🟠 There is no `--help`
`keyman.cli.ts` handles `--print-config`, `--version`/`-V`, and
`self-update`/`upgrade`, then falls through to the interactive session. `--help`
is not among them, and neither is any unknown-flag handling.
**Verified.** `keyman --help` with no tty:
```
📁 Vault Root: …
? Specify USER (default: @current): (@current)
…/@inquirer/core/dist/lib/create-prompt.js:67
reject(new ExitPromptError(`User force closed the prompt with ${code} ${signal}`));
```
Two problems in one output. `--help` starts a session instead of describing the
tool, and because of §1.2 the resulting `ExitPromptError` is an unhandled
rejection with a stack trace. That second half is what a user gets from **Ctrl-C
at any prompt** — the normal way to leave an interactive CLI produces a crash
dump.
`keyman --vault foo` is likewise accepted and ignored.
**Fix.** `--help` listing the flags, the two subcommands, and the `KEYMAN_*`
environment variables (§5.3); an unknown-flag error; and a `catch` in
`keyman.cli.ts` that treats `ExitPromptError` as "goodbye" and anything else as
a one-line error. nopy uses Commander for this; keyman need not, but it does
need the behaviour.
**Closed (Phase 1).** `src/keyman.args.ts` owns the parse and the help text; the
`catch` around `keyman()` in `keyman.cli.ts` turns `ExitPromptError` into
"👋 Goodbye!" and exit 0, and anything else into one line and exit 1.
### 3.2 🟡 `flagValue` accepts things that are not values
`keyman.cli.ts:24-27` is `args.indexOf(name)` and `args[index + 1]`:
- `--channel=main` is not recognised.
- `--channel` as the last argument yields `undefined`.
- `keyman self-update --channel --force` sets the channel to `"--force"`, which
is cast to `Channel` (`cli.ts:47`) and flows into the dist-tag lookup at
`update.ts:176` as a key that cannot exist. The registry answers, the tag is
absent, and the user is told "Could not reach <registry>" — which is false.
Validating against the three legal channels would turn all three into one clear
error.
**Closed (Phase 1).** `parseArgs` accepts both `--flag value` and `--flag=value`,
rejects a flag swallowed as another flag's value, and validates `--channel`
against `CHANNELS`.
### 3.3 🟡 The `resolution` merge machinery has no effect
`keyman.config.ts` carries `ResolutionStrategy`, `KeymanResolutionConfig`,
`mergeValue` and `mergeConfigs` — roughly 45 lines, imported from nopy's design.
Every property in `KeymanConfigSchema` is a `z.string()`. For two strings,
`mergeValue` returns `childValue` in the `override` branch (`:121-123`) and
returns `childValue` again from the primitive fallthrough (`:156`). The two
strategies are indistinguishable for every key the schema permits, and the
array-concat and deep-merge branches are unreachable through a valid config —
unknown keys pass through the merge but are then stripped by
`KeymanConfigSchema.parse` (§3.5).
So the documented knob does nothing. The doc comment at `:186-194` advertises it:
```json
{ "vaultRoot": "../vault", "resolution": { "vaultRoot": "override" } }
```
and `config.test.ts:212` — "honours an explicit override strategy" — passes for
a case where plain merge gives the same answer, so the test does not distinguish
them either.
This is a choice to make, not a bug to fix. Either drop the machinery and the
comment, or keep it deliberately as the shape a future object-valued or
array-valued option would need — and say so in a comment, since right now it
reads as functional.
### 3.4 🟡 `getConfigPaths()` is exported, tested, and called by nothing
`keyman.config.ts:265` is used only by `config.test.ts:122,131`. It is not
re-exported from `src/index.ts` and not called by the CLI. nopy's equivalent
feeds `nopy.main.ts:64`.
The absence is felt: `--print-config` prints the *resolved paths* only, so there
is no way to ask which config files were consulted. That information exists only
as a stderr side effect of `loadConfig` ("✅ Loaded configuration from …"), which
is not machine-readable and is interleaved with warnings. Folding
`getConfigPaths()` into the `--print-config` JSON makes the function earn its
keep and makes the escape hatch answer the question it is for.
### 3.5 🟡 A typo in `.keymanrc.json` is silent
`KeymanConfigSchema` is a plain `z.object`, which strips unknown keys.
**Verified.** With `{"vaultRoot":"./v","vaultroot":"typo", …}`, the lowercase key
is dropped without a word and `--print-config` reports the vault from the
correct key. Had only the typo been present, the user would get the `vault`
default and no clue.
`.strict()` — or keeping the strip and logging the leftover keys as a warning —
turns a silently wrong vault into one line of output. Since `loadConfig` already
degrades to defaults rather than throwing, a warning fits the module's existing
posture better than a hard failure.
### 3.6 🟠 "Support for key rotation" does not exist
`README.md:11`. `grep -rn "rotat" packages/keyman/src/` returns nothing. Already
tracked as `DOCS-AUDIT.md` §2.10, still open. Rotation is a genuinely useful
operation for this tool — generate a replacement, encrypt it, keep the old one
until the new one is deployed — so this is worth building rather than deleting.
### 3.7 🟡 "Copy public key and create README"
`encrypt.ts:44` says it; no README is written. Suggestively,
`decrypt.test.ts:75` places a `README.md` inside the keys directory as a
fixture, so a per-key README appears to have been the intent once. Either build
it or drop the half of the comment that lies.
---
## 4. Public API and packaging
### 4.1 🟡 A shebang on the library entry point
`src/index.ts:1` is `#!/usr/bin/env node`. The bin is `dist/keyman.cli.js`
(`package.json:28`); `index.ts` is the `exports["."]` target and is only ever
imported. nopy's `src/index.ts` has no shebang. Harmless, and a copy-paste
artefact.
### 4.2 🟡 The exported functions' types are not exported
`src/index.ts:2` exports `loadConfig` and `resolveConfigPaths`. It does not
export `KeymanConfig`, `KeymanConfigFile`, `ResolutionStrategy` or
`KeymanResolutionConfig`, so a TypeScript consumer cannot name what `loadConfig`
returns or what `resolveConfigPaths` takes. This is the same one-line omission
`CLAUDE.md` already records for nopy's `CubePackageRef`.
### 4.3 🟡 `export * from './keyman.main.js'` exports only `keyman()`
The five operation modules and `extractAgePublicKey` are not on the public
surface, so the package is consumable as a library only as "run the entire
interactive menu". That may well be intended — but then `loadConfig` and
`resolveConfigPaths` being exported is the odd part, since a consumer can obtain
the paths and do nothing with them.
### 4.4 🟡 Update-module constants are half re-exported
`keyman.update.ts` exports `SCOPE`, `UPDATE_CACHE_DIR`, `UPDATE_CACHE_FILE`,
`DEFAULT_FETCH_TIMEOUT_MS` and `DEFAULT_CONFIG_TIMEOUT_MS`; `src/index.ts:12-31`
re-exports neither, while re-exporting `DEFAULT_CHECK_INTERVAL_MS` and
`NPMJS_REGISTRY`. Pick one rule.
---
## 5. Documentation drift
### 5.1 🟠 `DOCS-AUDIT.md` lists §1.1 under *checked and accurate*
`DOCS-AUDIT.md:826-827`:
> **keyman config** — priority (`VAULT_ROOT` > file > defaults), the four default
> values, and the vault layout match `keyman.config.ts` and `keyman.encrypt.ts`.
The first two clauses are correct. The third holds only because
`keyman.encrypt.ts` hardcodes `keys` — checking the documented layout against
the file that ignores the config is what made §1.1 invisible. The entry should
move out of section 7 and point at §1.1.
### 5.2 🟠 `README.md` operations list — still open
`DOCS-AUDIT.md` §2.10, re-verified: `README.md:90-96` lists four menu entries;
`main.ts:54-61` has six. `Copy public key` and `Generate key` are undocumented —
the latter being the only in-tool way to create a key, which is why the Quick
Start at `README.md:33` tells the user to run `ssh-keygen` by hand.
`README.md:93` says encrypt takes keys "from `vault/tmp/`"; `encrypt.ts:12-20`
unions `~/.ssh` and tmp and offers both.
### 5.3 🟠 The README documents none of the CLI surface
`README.md` covers the interactive menu and the config file. It does not mention:
- `self-update` / `upgrade`, `--dry-run`, `--force`, `--channel`, `--registry`
- `--version` / `-V`, `--print-config`
- `KEYMAN_REGISTRY`, `KEYMAN_REGISTRY_TOKEN`, `KEYMAN_NO_UPDATE_CHECK`,
`KEYMAN_PACKAGE_MANAGER`
- the once-a-day update check, or that it is disabled when `CI` is set
`README.PUBLISH.md:552-578` documents all of it, but `package.json:37-41` ships
only `dist`, `README.md` and `LICENSE` — so a reader on the registry sees none of
it. This is the same shape as the nopy README problem closed as
`DOCS-AUDIT.md` §2.9, and keyman is now the worse of the two.
### 5.4 🟠 The README presents a configurable layout that is half-real
`README.md:46-70` documents `keysDir` and `tmpDir` as configuration, and
`:72-86` draws the default tree. Per §1.1 the first is only half true. Whichever
way §1.1 is resolved, this section needs an edit.
### 5.5 🟡 `CLAUDE.md` names a binary keyman never runs
> Encryption shells out to `age` / `age-keygen` / `ssh-keygen`, which must be on
> `PATH`.
`age-keygen` appears nowhere in `packages/keyman/src`. It appears in
`README.md:22` as a manual setup step, which is presumably where the claim came
from. Either note it as a prerequisite the user runs rather than something
keyman invokes, or make §2.3 true and turn the claim into fact.
`CLAUDE.md` also does not mention that `decryptKeys` shells out to `cp` and
`chmod` (`decrypt.ts:49-50`) — see §2.1, where the recommendation is to stop.
### 5.6 ✅ The update module has not drifted from nopy's
`keyman.update.ts` and `nopy.update.ts` are described in `CLAUDE.md` as "two
near-identical copies of one module", the duplication deliberate. Diffed with
package names normalised: **every difference is a doc comment.** No behavioural
drift at all. The stated risk of the duplication has not materialised; nopy's
copy simply carries fuller comments, and porting the better ones over would cost
nothing.
---
## 6. Checked and accurate
- **Config precedence.** `VAULT_ROOT` > config file > defaults
(`config.ts:249-259`), matching `README.md:59-70`. Verified via
`--print-config`.
- **Upward traversal and the home-directory config.** `findConfigFiles`
(`config.ts:85-110`) collects root-first and de-duplicates the home config
when it is also an ancestor (`:105`).
- **`loadConfig` never throws.** Invalid JSON is skipped per file (`:220-226`)
and a failed final validation degrades to defaults (`:232-241`) — which is the
documented difference from nopy's behaviour, and it holds.
- **`extractAgePublicKey` is honest about failure.** It returns `null` in every
failure mode and prints why; the defect in §1.3 is entirely in the caller's
`!`.
- **The menu loop.** Returns to the menu after every operation
(`main.ts:45-91`), as `README.md:97` says.
- **The four default values** and the `id_<name>.age` / `id_<name>.pub` layout
inside a per-key folder, as drawn at `README.md:72-86`.
- **`listKeys` status logic** (`list.ts:127-128`) matches its legend and the
README's, including the 🔓 state.
- **The update module**, in full — see §5.6.
---
## Suggested order of attack
**1 — the crashes, together.** §1.2, §1.3, §1.5 and §1.10's `statSync` are all
the same shape: an unguarded call in a function with no error boundary, reaching
a `keyman()` that is never awaited. One `catch` in `keyman.cli.ts` that
distinguishes `ExitPromptError` from a real failure, plus `existsSync` guards and
`try/catch` in encrypt and decrypt, closes all of them and most of §3.1's second
half. This is the smallest change with the largest effect on what a first run
feels like.
**2 — §1.4 and §2.1.** Both are in `decryptKeys`, both are about writing outside
the vault, and one of them destroys data. Replacing `cp`/`chmod` with the `fs`
equivalents is part of the same edit.
**3 — §1.1.** Mechanical, but it changes four test assertions, so it wants to be
its own commit. Fix `DOCS-AUDIT.md` §5.1 in the same one.
**4 — decide on §3.3 and §3.6.** Both are features the documentation claims and
the code does not have; both are decisions rather than fixes. Rotation is worth
building. The `resolution` machinery probably is not, and deleting it would take
`keyman.config.ts` from 267 lines to around 220.
**5 — §5.2, §5.3 and §5.4** are one rewrite of `README.md`. It is the only
document that ships, and it currently describes two thirds of the menu and none
of the command line.
**6 — the rest.** §2.2 (drop the passphrase prompt, let `ssh-keygen` ask), §2.3
(`age-keygen -y`), §2.4 (a shred operation), §1.6 through §1.9, §3.2, §3.4,
§3.5, and the §4 one-liners.
+433
View File
@@ -0,0 +1,433 @@
# keyman remediation plan
Turns [`AUDIT.md`](./AUDIT.md) into sequenced work. Each phase is one commit,
independently landable, gate-green on its own. Section references (§) are to
`AUDIT.md`.
Ordering is by *blast radius per unit of risk*, not by severity: the error
boundary comes first because it makes every later phase's failure mode legible,
and the config threading comes late because it is the only phase that rewrites
existing test assertions.
## Verified before planning
Four things the fixes depend on, checked by running them rather than assumed —
two of them changed the prescription:
| Check | Result | Consequence |
| --- | --- | --- |
| `ssh-keygen` with `-N` omitted | Prompts `Enter passphrase … (empty for no passphrase)` **and** confirms | §2.2 fix works: omit `-N`, inherit stdio, keyman never holds the passphrase |
| `ssh-keygen -y -f <encrypted key>` | **Prompts for the passphrase** | §1.6 fix cannot be a silent spawn — needs `stdio: 'inherit'` and a skip path |
| `@inquirer/core` from keyman | `ERR_MODULE_NOT_FOUND` — transitive via `inquirer`, not a direct dep | Detect `ExitPromptError` by `error.name`, never by import |
| `z.strictObject` in zod 4.4.3 | Available; reports `unrecognized_keys` with a `keys` array | §3.5 has a hard-failure option, though the plan prefers a warning |
## What is not a breaking change
Per §4.3, `src/index.ts` exports only `keyman`, `loadConfig`,
`resolveConfigPaths` and the update module. `encryptKeys`, `decryptKeys`,
`generateKey`, `listKeys`, `copyKey` and `extractAgePublicKey` are **not** on the
public surface, so every signature change below is internal. Phases 26 are not
semver-breaking.
The one user-visible behaviour change is Phase 5 — see [Migration](#migration).
## Gate discipline
`lint:ci``typecheck``test:coverage` runs on `pre-push` and in CI. Two
standing constraints:
- **Every phase lands its tests with its fix.** No phase may leave a red gate,
so there is no "write the failing tests first" commit.
- **`keyman.cli.ts` is excluded from coverage** (`vitest.config.ts:18`). Per
`CLAUDE.md`, *adding logic to those files means moving it somewhere covered*
which is why Phase 1 extracts argument parsing into a new module rather than
growing `cli.ts`.
Per-phase verification is `pnpm --filter @bitsquare/keyman run test`; the full
gate (`pnpm run lint:ci && pnpm run typecheck && pnpm run test:coverage`) before
each push.
---
## Phase 1 — Error boundary, `--help`, argument validation
Closes §3.1, §3.2, and the second half of §1.2 (the crash dump).
First because it is pure addition, touches no operation module, and converts
every latent throw in phases 26 from a stack dump into a line of text. The
`ExitPromptError` half is independently worth shipping: today **Ctrl-C at any
prompt** produces a crash dump.
**New file `src/keyman.args.ts`** (covered by the gate, unlike `cli.ts`):
- `parseArgs(argv: string[]): ParsedArgs` — supports `--flag value` *and*
`--flag=value`, rejects a flag consumed as another flag's value, rejects
unknown flags, and validates `--channel` against `'latest' | 'next' | 'main'`
so §3.2's false "Could not reach <registry>" cannot happen.
- `helpText(): string` — flags, both subcommands, and the four `KEYMAN_*`
variables. This is the text Phase 9 keeps in step with the README.
**`src/keyman.cli.ts`** stays wiring: dispatch on the parse result, and
```ts
try {
await keyman();
} catch (error) {
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);
}
```
`error.name`, not `instanceof``@inquirer/core` is not a direct dependency and
does not resolve from this package.
**Tests** — new `tests/args.test.ts`: each rejection, both flag forms, the
channel whitelist, and that `helpText()` names every flag `parseArgs` accepts
(so the two cannot drift).
**Done when** `keyman --help` prints usage and exits 0 without loading config or
prompting; `keyman --bogus` errors; Ctrl-C prints Goodbye and exits 0.
---
## Phase 2 — Guards and error handling in encrypt/decrypt
Closes §1.2 (first half), §1.5, and §1.10's `statSync`.
- `encrypt.ts:13,16` and `decrypt.ts:8``existsSync` guard, falling through to
the "⚠️ No …" message each function already has but cannot currently reach.
- `main.ts:40-41` — create `keysDir` alongside `vaultRoot` and `tmpDir`. Use
`{recursive: true, mode: 0o700}` now, so Phase 4 does not have to revisit it.
- Wrap the `age` spawns in both functions. An `ENOENT` on the binary gets its own
message ("`age` was not found on PATH") — it is the one hard external
requirement and currently the least legible failure.
- `list.ts:80``readdirSync(dir, {withFileTypes: true})` instead of
`statSync` per entry, which also drops N stat calls and fixes the broken-symlink
throw.
- Delete the debug logging while in these files: `encrypt.ts:18-19` and
`decrypt.ts:10` (§1.10). That also closes `DOCS-AUDIT.md` §6.4's open bullet.
**Tests** — the cases the current suite structurally cannot have, because every
`beforeEach` pre-creates the directories: encrypt with no `~/.ssh`, encrypt with
no tmp, decrypt with no `<vault>/keys`, each asserting the warning and no throw.
Plus `list` with a dangling symlink in the keys directory.
**Note on coverage.** `encrypt.ts` and `decrypt.ts` are at 100 % lines and
branches *today*. The number will not move; the tests are the point.
**Done when** a first run against an empty vault can reach every menu entry and
return to the menu.
---
## Phase 3 — Resolve the age recipient once, and derive it properly
Closes §1.3 and §2.3, and makes `CLAUDE.md`'s `age-keygen` claim true (§5.5).
Two changes that belong together because both are about the recipient:
1. **`utils.ts` — derive, don't scrape.** `extractAgePublicKey` currently regexes
`# public key:` out of a comment (`utils.ts:16`) and trusts it. Replace with
`age-keygen -y <keyPath>`, which derives the public key *from the private key*
and cannot disagree with it. Keep the comment parse as a fallback for when
`age-keygen` is absent, behind a warning that the recipient is unverified.
The function becomes `async`.
2. **`main.ts:73,80` — delete both `!`.** Resolve the recipient once before the
`switch`, and treat `null` as recoverable: print the remedy
(`age-keygen -o <keyPath>`) and `break` back to the menu. This is the whole of
§1.3 — the type already said null was possible.
Sequencing matters inside the phase: fix the call site first. Without it, a
missing key file still reaches `age -r null`, and the generate path still leaves
a **plaintext private key in `tmpDir`** after telling the user the operation
failed.
**Tests**`utils.test.ts` gains the `age-keygen -y` path with `execa` mocked,
the fallback-with-warning path, and the both-unavailable path. `main.test.ts`
gains: missing recipient → neither `generateKey` nor `encryptKeys` is called, a
remedy is printed, and the menu loop continues.
**Done when** `keyman` against a vault with no `age.key` reaches the menu,
refuses generate and encrypt with a remedy, and still offers list and decrypt.
---
## Phase 4 — Decrypt: stop overwriting, stop the 0644 window
Closes §1.4 and §2.1. The highest-value phase — §1.4 is the only finding that
destroys data the user did not ask to touch.
- **Collision check before any decryption.** Both output paths, both modes.
Prompt per collision, defaulting to skip; `~/.ssh` deserves the friction more
than `vault/tmp` does, but the check is the same code.
- **Replace the shell-outs** (`decrypt.ts:49-50`) with `fs.copyFileSync` and
`fs.chmodSync`. Three spawns per key become one, it works on Windows, and it
removes a `cp` that overwrites unconditionally.
- **Close the permission window.** Verified: `age -o` creates the file `0644`
and `mkdirSync` creates `vault/tmp` as `0755`, so a plaintext key is
world-readable for the duration of two process spawns — and stays `0644` if the
`chmod` fails. `fs.chmodSync` immediately after `age` resolves; `mode: 0o700`
on the directory (already done in Phase 2); create `~/.ssh` `0700` if absent.
**Test rework — the fiddliest in the plan.** `decrypt.test.ts` asserts on the
mocked spawns: `argsOf('cp')` (`:98,111`) and `argsOf('chmod')` (`:99,112`) both
disappear, and `execa` is mocked with a bare `mockResolvedValue` (`:57`) that
writes no output file. Once `copyFileSync` is real it needs a real file, so the
mock must write to its `-o` argument the way `encrypt.test.ts:51-54` already
does. Assert the on-disk result and mode instead of the argv — a better test
than the one it replaces, since it checks the outcome rather than the mechanism.
`execa` call counts also change (`:123`: six spawns → two).
**Done when** decrypting onto an existing key requires a confirmation, and the
decrypted key is never observable at anything but `0600`.
---
## Phase 5 — Thread `keysDir` and `tmpDir` through encrypt and decrypt
Closes §1.1 and the `DOCS-AUDIT.md` entry in §5.1.
Mechanical, but it is the one phase that rewrites assertions that pass today, so
it stays its own commit with nothing else in it.
- `encryptKeys(sshDir, keysDir, tmpDir, pubkey)` — drop `vaultDir`, delete the
hardcoded `path.join(vaultDir, 'keys')` (`encrypt.ts:38`).
- `decryptKeys(sshDir, keysDir, tmpDir, ageKey)` — drop `vaultDir`, delete the
hardcoded joins at `decrypt.ts:7,39,43`.
- `main.ts:76,84` — pass `paths.keysDir` and `paths.tmpDir`. Neither function has
any remaining use for `vaultRoot`, so the parameter goes rather than becoming a
second source of truth.
**Assertions to change** — all four, named so the diff is reviewable:
| Location | Today | After |
| --- | --- | --- |
| `main.test.ts:164-175` | "encrypts keys into the vault root", asserts `paths.vaultRoot` | asserts `paths.keysDir`, `paths.tmpDir` |
| `main.test.ts:177-187` | asserts `paths.vaultRoot` | asserts `paths.keysDir`, `paths.tmpDir` |
| `encrypt.test.ts:95,115,128-129` | `path.join(vaultDir, 'keys', …)` | `path.join(keysDir, …)` |
| `decrypt.test.ts:53,89` | `keyDir = path.join(vaultDir, 'keys')` | `keysDir` passed in directly |
**New test, the one that would have caught this:** a config with
`keysDir: 'encrypted'` and `tmpDir: 'plain'`, encrypt a key, then list it, and
assert the listing shows it in `[Vault]`. That round trip fails today and is the
regression worth owning.
**Also in this commit:** move the `DOCS-AUDIT.md:826-827` bullet out of *checked
and accurate* and point it at this finding. It was verified against
`keyman.encrypt.ts` — the file that ignores the config — which is precisely how
§1.1 stayed invisible.
---
## Phase 6 — Generate: passphrase off argv, and `.pub` recovery
Closes §2.2, §1.6, and §1.10's leftover-directory bullet.
**Passphrase (§2.2).** Verified: omitting `-N` makes `ssh-keygen` prompt *and*
confirm. So delete keyman's own password prompt (`generate.ts:26-33`), omit `-N`,
and spawn with `stdio: 'inherit'`. The passphrase never enters keyman's memory
and never reaches argv — strictly better than routing it more carefully, and it
deletes code. `generate.test.ts:78-87` loses `-N`/`'pw'` from the expected argv
and the password-prompt case goes away.
**Missing `.pub` (§1.6).** The selection list is built from private keys only, so
an orphan private key is offered and `copyFileSync` throws *after* `age` has
written the `.age` file — leaving a vault entry with no public key and killing
the rest of the batch. Fix in two parts:
- Derive it with `ssh-keygen -y -f <key>` when the sibling is absent. **Verified
that this prompts for a passphrase on an encrypted key**, so it needs
`stdio: 'inherit'` and a clean skip when the user cannot or will not supply it
— not a silent spawn whose stdout is captured.
- Wrap the loop body in `encrypt.ts:36-48` per key, so one bad key costs one key.
Report the failures at the end rather than dying at the first.
**Leftover directory.** `generate.ts:65` creates `<keysDir>/<name>/` before
`age` runs at `:68`. Move the `mkdirSync` after `age` succeeds.
---
## Phase 7 — Config: warn on typos, decide on the dead machinery
Closes §3.5, §3.4, and asks for a decision on §3.3.
**Typos (§3.5).** Verified: `{"vaultroot": "…"}` is silently stripped by
`z.object`. Warn per file rather than failing — diff `Object.keys(rawConfig)`
against the schema keys plus `resolution` inside the existing per-file loop
(`config.ts:210-227`), where the filename is in hand. That names the offending
file, which `z.strictObject` cannot do from the merged result, and it preserves
the module's documented posture of degrading to defaults rather than throwing.
(`z.strictObject` is available in zod 4.4.3 and reports `unrecognized_keys` with
a `keys` array, if a hard failure is preferred later.)
**`getConfigPaths` (§3.4).** Add it to the `--print-config` JSON as
`configFiles`. The function is currently exercised only by its own test, and
`--print-config` currently cannot answer *which files were read* — that exists
only as unstructured stderr from `loadConfig`. One change fixes both.
**Decision needed — the `resolution` machinery (§3.3).** Roughly 45 lines
(`config.ts:23-30,115-157`) that cannot affect a valid config, because every
schema property is a `string` and both strategies return `childValue` for
primitives. `config.test.ts:212` "honours an explicit override strategy" passes
either way.
- **Recommended: delete it**, along with the doc comment at `:186-194` that
advertises it. `keyman.config.ts` goes from 267 lines to roughly 220, and the
config file stops documenting a knob that does nothing.
- **Alternative: keep it** as the shape a future array- or object-valued option
would need — but then say so in a comment, because today it reads as
functional, and make `config.test.ts:212` assert something that distinguishes
the two strategies (which requires a non-string property to exist first).
Deleting is the smaller lie. It also diverges from nopy, where the machinery
*is* load-bearing — worth a line in `CLAUDE.md` so the divergence reads as
deliberate.
---
## Phase 8 — Portability and the gaps that make keys invisible
Closes §1.7, §1.8, §1.9, §2.4. Independent of each other; split if any grows.
- **Clipboard (§1.9).** `pbcopy` / `wl-copy` / `xclip` / `clip.exe` by platform,
falling back to printing the key to stdout so the operation is never a dead
end. Delete the comment at `copy.ts:46-48` that admits the shortcut.
- **Home directory (§1.7).** `os.userInfo()` for the current user; for a named
user, look the home directory up rather than assuming `/home/<user>` — wrong on
the one platform the tool currently supports. Check `existsSync` and say so,
instead of feeding a nonexistent path into a `readdir`.
`main.test.ts:198-204` changes.
- **Non-`id_*` keys (§1.8).** Relax the filters (`copy.ts:9`, `encrypt.ts:14,17`,
`list.ts:23,51`) to *any* private key with a recognisable header, or at minimum
print a count of the keys that were skipped and why. Today a key named
`deploy_ed25519` is simply absent from the menu — and pre-existing keys are the
population a key manager is adopted to take over. `decrypt.ts:9` reconstructs
`id_${dir}` from the folder name, so the vault layout has the assumption baked
in; relaxing discovery means storing the real filename per key, which is the
largest single item in this plan. **Size it before committing to it** — a
skipped-key count is a tenth of the work and closes most of the surprise.
- **Plaintext hygiene (§2.4).** A "🧹 Clear decrypted keys" menu entry, and write
a `.gitignore` next to the vault on first run covering `age.key` and `tmp/`
which `README.md:25-26` currently tells the user to do by hand. This is the
cheapest guard against the exact failure the tool exists to prevent.
---
## Phase 9 — Documentation
Closes §5.2, §5.3, §5.4, §5.5. Last, so it documents what the code now does.
- **`README.md` — the only shipped document** (`package.json:37-41` ships `dist`,
`README.md`, `LICENSE`). Currently describes four of six menu entries, invents
key rotation, and mentions none of `self-update`, `--print-config`,
`--version`, `--help`, or the four `KEYMAN_*` variables. `README.PUBLISH.md`
has all of it and never reaches a reader on the registry. Reuse Phase 1's
`helpText()` as the source for the CLI section so the two cannot drift.
- **`README.md:46-86`** — the configuration and vault-layout sections, now that
Phase 5 makes `keysDir`/`tmpDir` real, plus the migration note below.
- **`README.md:11`** — drop "Support for key rotation" unless Phase 10 lands
first.
- **`README.md:33`** — stop telling the user to shell out to `ssh-keygen`; the
Generate operation exists.
- **`CLAUDE.md`** — `age-keygen` becomes true in Phase 3; note that `cp`/`chmod`
are gone (Phase 4) and record the `resolution` divergence from nopy (Phase 7).
- **`AUDIT.md`** — mark findings closed, keeping their text as the record, the way
`DOCS-AUDIT.md` does.
---
## Phase 10 — Key rotation (decision required)
§3.6. `README.md:11` has advertised it since before this audit;
`grep -rn "rotat" packages/keyman/src/` returns nothing.
Unlike the rest of this plan it is a feature, not a repair, and it is the one
item that could reasonably be dropped instead. **Recommendation: build it**
rotation is the operation that makes a key vault worth having, and the pieces all
exist by Phase 6 (generate under a new name, encrypt, keep the old key until the
replacement is deployed, then shred). Sketch:
1. Pick an existing vault key.
2. Generate a replacement into `tmpDir` under a versioned name.
3. Encrypt it alongside the current one — never replacing it.
4. Report both public keys, so the new one can be deployed before the old one
goes.
5. A separate "retire" step that removes the superseded key once the user
confirms.
Steps 3 and 4 are the whole value: a rotation that atomically replaces the key is
a rotation that locks you out of the host you were rotating for. If this is
deferred, delete the README claim in Phase 9 instead.
---
## Migration
Phase 5 is the only user-visible change. Anyone with a custom `keysDir` or
`tmpDir` currently has a **split vault**`generate` and `list` on the
configured directory, `encrypt` and `decrypt` on `<vaultRoot>/keys` and
`<vaultRoot>/tmp`. After Phase 5 all five agree on the configured directory, so
anything written by `encrypt` before the upgrade needs moving:
```sh
mv <vaultRoot>/keys/* <vaultRoot>/<keysDir>/
```
Nobody on the defaults is affected, since the two halves coincide there. The
README gets this as a note, and it is worth a line in the release notes for
whichever version carries Phase 5.
## Rollout
Per `CLAUDE.md`: bump `packages/keyman/package.json`, land on `main`, then tag
`keyman-v<version>`.
- **Snapshots come free.** Every push to `main` publishes
`<version>-main.<run>.g<sha>` to Gitea under the `main` dist-tag, so each phase
is installable for testing without a release. `pnpm run try:snapshot` installs
one into a throwaway project.
- **Suggested cut points.** After Phase 4 as `0.6.0` — error boundary, guards,
recipient handling and the data-loss fix, which is the set worth getting to
users first. After Phase 9 as `0.7.0`, carrying the Phase 5 migration note.
- **keyman can reach npmjs.** It has no `workspace:*` dependencies (`execa`,
`inquirer`, `semver`, `zod` only), so `scripts/linked-deps.mjs` has nothing to
block on — unlike `nopy`, which `CLAUDE.md` records as gated behind
`nopy-cubes` shipping. keyman has never been published to npmjs; `0.6.0` could
be the first, and versions being `0.x.y` rather than `1.0.0-alphaN` means the
`latest` dist-tag will now actually move.
- **`pnpm publish`, never `npm publish`** — no `workspace:` ranges here, but the
rule is repo-wide and `scripts/verify-pack.mjs` enforces it in both workflows.
## Sequencing at a glance
```
1 cli boundary + --help + args §3.1 §3.2 §1.2(half) isolated, pure addition
2 guards in encrypt/decrypt/list §1.2 §1.5 §1.10 new tests only
3 age recipient, once and derived §1.3 §2.3 §5.5 signature → async
4 decrypt: no clobber, no 0644 §1.4 §2.1 reworks decrypt.test.ts
── cut 0.6.0 ──
5 thread keysDir/tmpDir §1.1 §5.1 rewrites 4 assertions
6 generate: -N gone, .pub recovery §2.2 §1.6 §1.10 reworks generate.test.ts
7 config: warn, prune, print §3.5 §3.4 §3.3* *decision
8 portability + hygiene §1.7 §1.8 §1.9 §2.4 §1.8 needs sizing
9 documentation §5.2 §5.3 §5.4 §5.5 README is the shipped one
── cut 0.7.0 ──
10 rotation §3.6* *decision: build or delete
```
Phases 16 are repairs and want to land in order. 7 and 8 are independent of each
other and of 56. 9 depends on everything before it. 10 is optional and gates
one line of Phase 9.
## Open decisions
Neither blocks Phase 1. Both change scope where they land:
1. **§3.3, at Phase 7** — delete the inert `resolution` machinery (recommended,
45 lines) or keep it as future shape with a comment saying so.
2. **§3.6, at Phase 10** — build rotation (recommended) or delete the README
claim in Phase 9.
+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 #!/usr/bin/env node
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { helpText, type ParsedArgs, parseArgs, UsageError } from './keyman.args.js';
import { loadConfig, resolveConfigPaths } from './keyman.config.js'; import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { keyman } from './keyman.main.js'; import { keyman } from './keyman.main.js';
import type { Channel } from './keyman.update.js';
import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js'; import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js';
const { version, buildInfo } = createRequire(import.meta.url)('../package.json') as { 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 versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
const args = process.argv.slice(2); let parsed: ParsedArgs;
try {
/** Reads `--flag value` out of argv, or undefined when the flag is absent */ parsed = parseArgs(process.argv.slice(2));
function flagValue(name: string): string | undefined { } catch (error) {
const index = args.indexOf(name); if (!(error instanceof UsageError)) throw error;
return index === -1 ? undefined : args[index + 1]; 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 config = loadConfig();
const paths = resolveConfigPaths(config); const paths = resolveConfigPaths(config);
console.log(JSON.stringify(paths)); console.log(JSON.stringify(paths));
process.exit(0); process.exit(0);
} }
if (args.includes('--version') || args.includes('-V')) { if (parsed.command === 'version') {
console.log(versionLabel); console.log(versionLabel);
process.exit(0); process.exit(0);
} }
if (args[0] === 'self-update' || args[0] === 'upgrade' || args.includes('--self-update')) { if (parsed.command === 'self-update') {
const dryRun = args.includes('--dry-run') || args.includes('-n'); const { dryRun } = parsed;
try { try {
const result = await selfUpdate({ const result = await selfUpdate({
currentVersion: version, currentVersion: version,
channel: flagValue('--channel') as Channel | undefined, channel: parsed.channel,
registry: flagValue('--registry'), registry: parsed.registry,
dryRun, dryRun,
force: args.includes('--force') || args.includes('-f'), force: parsed.force,
}); });
const { status } = result; const { status } = result;
@@ -79,4 +86,15 @@ if (notice) {
console.error(`\n${notice}\n`); 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);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 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);
}
});
});