Phase 10 of docs/PLAN.md; closes AUDIT §3.6, the README's oldest lie
("Support for key rotation", with no occurrence of "rotat" in src/).
Rotation only ever adds. `rotateKey` generates a replacement under the next
name in the series — prod → prod-2 → prod-3 — and encrypts it *alongside*
the key it replaces, so both are in the vault at once. `retireKey` is a
separate operation, and the only one in keyman that destroys an encrypted
key. The gap between the two is where the new public key gets deployed and
tested: a rotation that replaces the key in one step locks you out of the
host you were rotating for, because the replacement is not on it yet and
the only copy of the one that is has gone.
The name has to change — the vault layout derives the directory from it, so
a replacement also called `prod` *is* the `prod` entry. `nextRotationName`
skips any version already taken in the vault, in tmp or in .ssh, so it
never asks ssh-keygen to overwrite a private key in use. Retirement warns
when nothing in the vault supersedes the key and then makes the user type
its name, since that deletion is unrecoverable.
Three things extracted rather than copied: `listVaultKeys` (vault.ts) now
backs decrypt, rotate and retire; `createKeyPair` and `promptKeyOptions`
(generate.ts) are shared with rotation, which also carries the old key's
comment over as the default. Verified against the real binaries that a
hyphen-suffixed name survives ssh-keygen and age, that the vault entry
round-trips byte-identically, and that ssh-keygen writes the replacement
0600 without help.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ansiblings
Infrastructure tooling monorepo: two published CLIs plus the pyinfra "cubes" they deploy.
| Path | Package | Binary | What it is |
|---|---|---|---|
packages/nopy |
@bitsquare/nopy |
nopy |
interactive pyinfra script management and execution |
packages/keyman |
@bitsquare/keyman |
keyman |
SSH key management with age encryption |
cubes/ |
— | — | the deployment units nopy runs |
npm install -g @bitsquare/nopy @bitsquare/keyman
See each package's README for usage, and README.PUBLISH.md for how they get published.
Development
Requires Node ≥ 22 (the repo pins 24 in .nvmrc) and pnpm — the version is
pinned by packageManager, so corepack enable is enough.
pnpm install
| Command | Does |
|---|---|
pnpm run build |
compiles both packages with tsc |
pnpm run typecheck |
tsc --build --noEmit across the workspace |
pnpm run lint |
Biome check |
pnpm run lint:fix |
Biome check with fixes applied |
pnpm test |
vitest, both packages |
pnpm run test:coverage |
vitest with the coverage gate |
pnpm run coverage:summary |
renders the last coverage run as a Markdown table |
typescript is on the 7.x native compiler, so tsc is the fast one — there is
no separate tsgo binary to keep in sync. Each package also has a dev-run script
(pnpm --filter @bitsquare/nopy run nopy) that executes the TypeScript sources
directly through tsx.
Git hooks
Installed by simple-git-hooks on pnpm install, configured in the root
package.json:
- pre-commit — Biome check with fixes, on staged files only, re-staging what it fixed. Fast; blocks only on problems it cannot fix itself.
- pre-push —
lint:ci→typecheck→test:coverage. This is the same gate CI runs, so a push that survives it will not surprise you on the runner.
Set SKIP_SIMPLE_GIT_HOOKS=1 to bypass either one; re-install them after
changing the config with pnpm exec simple-git-hooks.
Coverage
Both packages hold a hard 85 % branch floor, enforced by
coverage.thresholds in their vitest.config.ts rather than by a CI-only flag —
pnpm run test:coverage fails the same way locally, in the pre-push hook, and
on the runner. Barrel files and CLI argv wiring are excluded; everything with
behaviour in it is not.