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.
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>
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>