Commit Graph

5 Commits

Author SHA1 Message Date
Benjamin Diedrichsen da9df57e11 [keyman] thread the configured keys and tmp directories through encrypt/decrypt
encryptKeys and decryptKeys each took `vaultDir` and rebuilt `<vault>/keys`
and `<vault>/tmp` from it, so `keysDir` and `tmpDir` in .keymanrc.json were
honoured by main and list and silently ignored by the two operations that
write. main was also passing vaultRoot where encrypt expected the keys
directory, which put encrypted keys one level above where list looks for
them: with any config at all, a key encrypted a second ago was invisible.

Both now take keysDir and tmpDir explicitly. The decrypt location prompt
names the real directories instead of the hardcoded `vault/tmp` and
`~/.ssh`, which meant its labels were also its values — hence LOCAL_MODE.

tests/vault-layout.test.ts is the regression: encrypt then list, driven
through keyman() with only age and the prompts mocked, against a config
using keysDir `encrypted` and tmpDir `plain`. Every unit suite passed
through this bug because each was told which directory to use; the seam
between them was untested. Verified it fails when main is reverted to pass
vaultRoot.
2026-07-30 14:45:27 +02:00
Benjamin Diedrichsen 77bd43818f [keyman] phase 3: derive the age recipient, and survive not having one
main.ts asserted the recipient non-null twice — extractAgePublicKey(...)!
— and the type already said null was possible. With no age.key the vault
encrypted to the string "null": execa stringifies it, age exits 1, and on
the generate path that happens *after* ssh-keygen has written a plaintext
private key into tmpDir, so the user is told the operation failed and left
with a key on disk. Now the recipient is resolved once, remembered on
success, and a null prints the remedy (age-keygen -o <path>) and returns
to the menu. list, copy and decrypt still work without one.

extractAgePublicKey now derives the public key with `age-keygen -y`
instead of scraping the `# public key:` comment. The comment is ordinary
text nothing re-checks; verified that rewriting it does not change what
-y reports, so a stale or forged comment silently encrypted the vault to
a recipient nobody holds the private half of.

The comment survives as a fallback for a machine with no age-keygen,
behind a warning that it is unverified — but not when age-keygen runs and
refuses the file. That means age cannot read the identity, and trusting
the comment there would encrypt to a recipient the vault could never
decrypt with.

runTool throws ToolNotFoundError for ENOENT so the two cases can be told
apart. Its own tests move to tool.test.ts, which keeps real processes;
utils.test.ts mocks execa, since the gate cannot require age installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:37:26 +02:00
Benjamin Diedrichsen 11c323b715 [keyman] phase 2: guard the directories nothing creates
encrypt read ~/.ssh and the tmp directory, and decrypt read <vault>/keys,
with no existsSync between them. main.ts created vaultRoot and tmpDir but
never keysDir, so decrypt on a fresh vault threw ENOENT instead of
printing the "no encrypted keys" message it already had — the message was
unreachable until something else created the directory.

Both functions now fall through to their warning. main.ts creates all
three directories, 0700: the vault holds the age identity and tmp holds
plaintext private keys.

age spawns go through runTool, which separates "not installed" (ENOENT,
whose message is `spawn age ENOENT`) from "age refused" (whose reason is
on stderr and nowhere in the thrown message). Tested against real
processes, not a mocked execa — the shape of the failure is the point.

list.ts kept statSync rather than switching to withFileTypes as planned:
withFileTypes reports a symlinked key directory as a link and would have
silently dropped it. `throwIfNoEntry: false` fixes the dangling-symlink
throw and keeps following the good ones. Both cases now have a test.

Also deletes the three debug logs (encrypt.ts printed both key arrays,
decrypt.ts printed every candidate path from inside a filter).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:33:26 +02:00
Benjamin Diedrichsen 587ff2cf47 Add release pipeline and upgrade toolchain to TypeScript 7
Publish snapshot / snapshot (push) Failing after 1m58s
Publishing infrastructure
- Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml
  (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs)
- Tag-driven releases as <package-dir>-v<version>; the manifest stays the
  source of truth and release.yml refuses to run if tag and manifest disagree
- Every publish is idempotent: each step checks the registry first, so a run
  that fails on the second registry can simply be re-run
- Hard coverage gate (85% branches) shared by CI, the pre-push hook and local
  runs, since the thresholds live in vitest.config.ts rather than a CI flag
- README.PUBLISH.md documents the whole mechanism

Toolchain
- TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs
- Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0
- Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9,
  with enquirer's AutoComplete; the CubeSelection contract is unchanged
- Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema

Repo hygiene
- Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json
- Drop package-lock.json in favour of pnpm-lock.yaml

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:17:14 +02:00
Benjamin Diedrichsen 736c01216a initial transfer 2026-07-27 13:09:00 +02:00