Generate prompted for the passphrase itself and passed it as `-N <value>`, so it sat in this process's argv — readable by any user on the box through `ps` for the length of the spawn — and in keyman's memory before that. Verified that omitting `-N` makes ssh-keygen prompt *and* confirm, so the prompt and the flag are both gone and the spawn inherits stdio. keyman no longer learns the passphrase, which is strictly better than handling it more carefully, and it deletes code. The other half is the missing `.pub`. The selection list is built from private keys, so an orphan is offered like any other, and copyFileSync discovered the absent sibling only *after* age had written the encrypted key: a vault entry with no public key, and an exception that took the rest of the batch with it. It is now derived with `ssh-keygen -y -f`, before the vault directory is created. Verified against real binaries that the derived key matches the original byte for byte, that an encrypted key prompts (on stderr — hence stdout piped, stdin and stderr inherited), and that a refused derivation degrades to storing the private key alone rather than failing. encrypt's loop now isolates per key and reports which ones did not make it, except for ToolNotFoundError: age missing is not a per-key problem and nine more identical errors help nobody. storeInVault is the shared write path both callers had a copy of. It also undoes its own mess: age has to write into a directory that already exists, so a failure could leave an empty directory or a truncated .age — which list counts as a vault entry and decrypt offers. The .age is removed because we named it, the directory only while empty, since one holding an earlier key is not ours to delete.
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.