[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,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.
|
||||
@@ -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 2–6 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 2–6 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 1–6 are repairs and want to land in order. 7 and 8 are independent of each
|
||||
other and of 5–6. 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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user