Files
ansiblings/CLAUDE.md
T
Benjamin Diedrichsen 7862fab809 [docs] keyman: rewrite the README, close the audit
Phase 9 of packages/keyman/docs/PLAN.md; closes AUDIT §5.2, §5.3, §5.4, §5.5,
and the §4 one-liners no phase had claimed (§4.1–§4.4, §3.7).

The README is the only document that ships (package.json files: dist,
README.md, LICENSE), and it described four of nine menu entries, invented key
rotation, told the user to run ssh-keygen by hand, asked them to write a
.gitignore keyman now writes, and mentioned none of the command line. It is
rewritten against the code: every operation, the rotate/retire sequence, the
id_ prefix and what happens to keys without it, installation with the scope
mapping (never a bare --registry, which would send 55 transitive dependencies
to a registry that has never heard of them), the configuration semantics
including which relative path resolves against what, and the Phase 5 migration
for a split vault.

The CLI section is helpText() verbatim, with tests/readme.test.ts asserting the
two are identical and that every menu label appears — so a flag or an operation
added later fails the gate instead of shipping undocumented. That is the part
that keeps this from drifting again.

Also: index.ts loses the bin's shebang (it is only ever imported), exports the
config types so a consumer can name what loadConfig returns, and re-exports the
update module wholesale rather than half of it by name — verified by importing
the built dist/index.js and reading its keys. The narrow surface is now a
comment stating the rule rather than an accident.

AUDIT.md marks all 30 findings closed except the second half of §1.8, keeping
each finding's text as the record with what closed it quoted underneath, the
way DOCS-AUDIT.md does. PLAN.md gains a status section naming the three
deviations. Root CLAUDE.md records the keyman architecture as it now is,
including the deliberate `resolution` divergence from nopy.

DOCS-AUDIT.md §2.10, §6.4 and the §7 keyman-config entry are amended in the
working tree but left unstaged, since that file carries unrelated WIP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:57:00 +02:00

394 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
A pnpm workspace holding two independently published CLIs, the authoring package
their deployment units are written against, and one bundle of those units:
| Path | Package | Binary | Role |
| -------------------------- | ----------------------------- | -------- | -------------------------------------------------------- |
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | interactive pyinfra script management and execution |
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | SSH key management, shelling out to `age` / `ssh-keygen` |
| `packages/nopy-cubes` | `@bitsquare/nopy-cubes` | — | the authoring surface a `manifest.mjs` imports |
| `packages/nopy-cubes-core` | `@bitsquare/nopy-cubes-core` | — | the core cube bundle (22 cubes), no TypeScript |
The root package is private; everything under `packages/` ships. `keyman` stands
alone, but `nopy` and `nopy-cubes-core` both depend on `nopy-cubes` (`workspace:*`), so
publish order matters — see *Releasing*.
`nopy-cubes-core` is consumed the way a third party would consume it: the root
`.nopyrc.json` names it in `cubePackages`, and the loader reads it out of
`node_modules`. There is no `cubes/` directory at the repo root any more.
## Documenting
Be modest. Size the write-up to the change: most work needs none, and a small
module never earns a section in `docs/API.md`. Where a reason is genuinely
non-obvious, one comment next to the code beats three paragraphs in a document
nobody re-reads. Document the surprising, not the obvious.
## Commands
```sh
pnpm install # also installs the git hooks via simple-git-hooks
pnpm run build # tsc --build across the TS packages (project references)
pnpm run typecheck # tsc --build (see below — it really does emit)
pnpm run lint # biome check . (lint:fix / lint:ci variants)
pnpm test # vitest run, every package with tests
pnpm run test:coverage # vitest with the coverage gate
pnpm run coverage:summary # renders the last coverage run as a Markdown table
pnpm run registry:status # what is on Gitea vs npmjs, and what is Gitea-only
pnpm run try:snapshot # install a published snapshot into a temp project and run it
```
Single package / single test:
```sh
pnpm --filter @bitsquare/nopy run test tests/config.test.ts # one file
pnpm --filter @bitsquare/nopy run test -t "merges configs" # by test name
pnpm --filter @bitsquare/nopy run test:watch
pnpm --filter @bitsquare/nopy run nopy # run the CLI from source via tsx
pnpm --filter @bitsquare/keyman run keyman
```
`typescript` is the 7.x native compiler, so `tsc` *is* the fast one — there is no
separate `tsgo` binary.
`typecheck` is plain `tsc --build`, not `--noEmit`. Once a project has
`references`, `--noEmit` is rejected outright (TS6310: *referenced project may
not disable emit*) — a composite project has to emit the declarations its
dependents read. So the typecheck writes `dist` as a side effect; it is
gitignored, and the upside is that the gate now also proves the build works.
## Verification gate
`lint:ci``typecheck``test:coverage` is one gate, run in three places: the
`pre-push` hook, `ci.yml` (non-`main`), and `publish-snapshot.yml` (`main`).
`pre-commit` runs Biome with fixes on staged files only. Bypass with
`SKIP_SIMPLE_GIT_HOOKS=1`; re-install after editing the hook config with
`pnpm exec simple-git-hooks`.
Coverage thresholds live in each package's `vitest.config.ts` (85 % branches and
functions, 80 % lines and statements), not in a CI flag — they fail identically
locally and on the runner. Barrel files (`src/index.ts`, `src/cubes/index.ts`,
`src/nopy.cubes.ts`) and the Commander argv wiring (`src/*.cli.ts`) are excluded;
adding logic to those files means moving it somewhere covered.
nopy's vitest config aliases `@bitsquare/nopy-cubes` to that package's **source**,
not to the workspace link (which points at a `dist` that only exists after a
build), so the gate does not depend on build ordering and can never run against a
stale artefact. The same config excludes `**/nopy-cubes/**` from coverage — without
it nopy's numbers absorb another package's files. `nopy-cubes-core` has no tests of its
own; the loader tests in nopy cover the contract it implements.
The three TS packages set `pool: 'forks'` because tests use `process.chdir()` — most
loader/config tests build a throwaway tree under `os.tmpdir()` and chdir into it,
since discovery is driven entirely by the working directory.
## nopy architecture
One pass per invocation, `nopy.main.ts` orchestrating:
1. **`nopy.config.ts`** — `loadConfig()` walks up from `process.cwd()` collecting
every `.nopyrc.json` plus `~/.nopyrc.json`, then merges them root-first.
Per-property strategy comes from the child's `resolution` block (`merge` is
the default: arrays concatenate and dedupe, objects deep-merge; `override`
replaces). Only properties listed in `PATH_PROPERTIES` (`cubeDirs`) get
relative paths resolved against their own config file's directory;
`cubePackages` needs the same origin for a different reason, so each entry is
normalised into a `CubePackageRef {spec, from}``from` is the directory of
the config that named it, which is where the package gets resolved from.
**Throws**
if no config file exists anywhere — which is why `nopy.cli.ts` calls it lazily
inside the action, so `--help`/`--version` work outside a project.
2. **`cubes/packages.ts`** — `resolveCubePackages()` turns each `CubePackageRef`
into a package root plus its cube directories. The location is a **convention**:
`<root>/cubes`, so a bundle needs no nopy-specific `package.json` field at all.
`nopy.cubes` survives only as an override, for the bundle whose cubes are
elsewhere (`dist/cubes` after a build, say) — absent means the default, but
present-and-malformed is an error rather than a fall back, since saying
something that does not parse is not the same as saying nothing.
Resolution goes through `createRequire(...).resolve.paths()` + `existsSync`,
deliberately bypassing the `exports` map: a bundle ships directories and has
no entry point to declare. `existsSync` also follows the symlink pnpm plants
at `node_modules/<name>`, which a `readdir` scan skips outright (it reports
`isSymbolicLink()`, not `isDirectory()`). A missing package, an unreadable
manifest, no cube directory found, and an entry pointing outside the package
root are all errors, never silent skips.
Duplicate refs are deduped here, last-wins, because `mergeValue` only dedupes
arrays of primitives and these are objects.
3. **`cubes/loader.ts`** — `findCubeRoots()` unions `config.cubeDirs`, the
directories from `cubePackages`, and every ancestor directory holding a
`.npcubes` marker, then scans each recursively (skipping dotted dirs and
`node_modules`). A directory is a cube
when it holds both a manifest (`manifest.mjs` or `*.manifest.mjs`) and a
deploy script (`deploy.py` or `*.deploy.py`); manifests are loaded by dynamic
`import()`. Cube id = `manifest.id` → a `[id]` prefix in `manifest.name`
the directory basename. Ids are flat and need not mirror the path
(`cubes/network/tailscale` declares `net:tailscale`), and they are claimed
**globally**, not per source: a duplicate is a hard error naming every
claimant, with no precedence rule and no shadowing. Each cube carries a
`source``{type: 'dir', dir}` or `{type: 'package', packageName, dir}`
which is what makes that error legible when the collision is between a local
tree and an installed bundle. Duplicate ids and bad manifests become entries
in `errors`, which aborts the run.
4. **`nopy.workflow.ts`** — picks interactive, file-replay, or history-replay and
normalises all three into a `WorkflowResult`. Replays never re-prompt except
for passwords (never persisted) and a missing host.
5. **`cubes/dependencies.ts``BuildContext.resolveCube()`** — the core.
Recursive, per (cube, host): assign params and schema defaults → collect
variables (prompt, or read them back from the session on replay) → run
`before` hooks → resolve `manifest.dependencies(vars)` (dynamic: it receives
the *collected* variables) → emit the deploy call → run `after` hooks. There
is no separate topological sort; ordering falls out of the recursion, and a
`${cubeId}:${host}` set makes emission idempotent. Hooks get a `HookContext`
whose `exec(id, vars)` re-enters `resolveCube`, so a hook can pull in a cube
that is not a declared dependency.
6. **`nopy.executor.ts`** — runs the built `pyinfra <host> -y --data K=V ... --chdir <cubeDir> <script>`
commands through execa with inherited stdio, sequentially, stopping at the
first failure unless `continueOnError`.
### Variables
`Variables` (`nopy.common.ts`) holds one `Variable` per (cube, key). A `Variable`
is a list of `Assignment {value, origin}`, and precedence is the `Origin` rank:
`default(0) < env(1) < session(2) < prompt(3) < param(4)`. There are no scope
bags — config `env` is seeded per cube as a real assignment, so the old
`get('global')` (a cube id that was never a cube) is gone, and a replay assigns at
`session` instead of being smuggled into the prompts bag.
`assignments` is the true history, newest first, never reordered. `ordered` is a
**stable** sort of it by rank; `value` / `origin` read its head. The stability is
load-bearing: it is what makes same-origin ties resolve to the newest while the
displaced value stays visible in the trace. The trace is never persisted.
`get(id)` returns the effective values (→ the pyinfra command line);
`persistable(id)` returns the same minus declared secrets (→ session and history).
Every schema key is guaranteed present on the pyinfra side; pyinfra parses
`--data` values itself, so `"true"` arrives as a bool and numeric strings as ints.
A manifest's `secrets: string[]` names schema keys holding sensitive values —
validated at load (an entry that is not a schema key aborts the run). Secrets are
excluded from `persistable()`, re-prompted on replay via `fillSessionGaps`
(`requiredKeys() secrets`), and masked by `maskCommand()` / `maskVariables()`
wherever a command is printed. Deliberately a plain array rather than zod
metadata: `.meta()` and `.describe()` live in the per-copy `z.globalRegistry`, so
a manifest built by a different zod copy would look up empty — fail-open is
tolerable for a prompt label and not for a secret marker. See `docs/REFACTORING.md`
items 6 and 7.
### Cube contract
A cube directory holds `manifest.mjs` + `deploy.py`; anything else in it is
ignored by the loader but reachable from the script, which runs with the cube
directory as its cwd. Manifests are ESM, import `Manifest` from
`@bitsquare/nopy-cubes`, and declare `id`, `name`, a Zod `schema` (each field
`.describe()`d — the description is the prompt label — and `.default()`ed), plus
optional `secrets`/`dependencies`/`before`/`after`.
Import from **`@bitsquare/nopy-cubes`**, not `@bitsquare/nopy`. The authoring
surface is types and a factory, with zod as its only peer — no CLI, no prompts,
no process spawning — so a bundle can depend on it without dragging the CLI in.
`@bitsquare/nopy` re-exports all of it (`cubes.Manifest`, `cubes.uniqid`, …), so
the older form still works; every cube in `packages/nopy-cubes-core` has been moved to
the new one.
Manifests are resolved by ordinary Node resolution **from the manifest's own
directory**, which used to mean a hand-written local cube failed with
`ERR_MODULE_NOT_FOUND` unless you linked the package. `cubes/resolve-hook.mjs`
retires that: `loadCubes()` registers a `module.register()` resolve hook that
tries normal resolution *first* and only on failure falls back to resolving
`@bitsquare/nopy-cubes`, `@bitsquare/nopy` and `zod` from the running CLI's own
`node_modules`. Ordinary-resolution-first is the load-bearing part — a cube that
ships its own zod keeps it. The hook is a convenience, never load-bearing:
registration is wrapped in a `try`, and a bundle installed properly never reaches
it. `dist/cubes/*.mjs` is copied by the build, not compiled — hence
`"build": "tsc && cp src/cubes/*.mjs dist/cubes/"`.
Its tests must spawn a real `node` child process. Written inside the vitest
worker they prove nothing: vite resolves the dynamic import itself, so they pass
whether or not the hook is installed — verified by commenting the registration
out and watching them stay green.
## keyman architecture
Much smaller: `keyman.cli.ts` (wiring only — argv parsing lives in
`keyman.args.ts`, which is covered, and the CLI is the error boundary that turns a
`UsageError` into one line instead of a stack trace) → `keyman.main.ts`, an
inquirer menu loop dispatching to one module per operation
(`list`/`copy`/`generate`/`encrypt`/`decrypt`/`rotate`/`retire`/`clear`).
`keyman.config.ts` mirrors nopy's upward traversal for `.keymanrc.json` but not
its `resolution` merge: every keyman property is a string, so a child simply wins
and the strategies could not change an outcome — see `docs/AUDIT.md` §3.3. It
validates with Zod, falls back to defaults instead of throwing, and warns about a
key it does not know rather than letting Zod strip it silently. `VAULT_ROOT` in
the environment beats the config file. It shells out to `age`, `age-keygen`
(`-y`, to derive the recipient from the identity rather than trusting the
`# public key:` comment) and `ssh-keygen` (`-y`, to recover a missing `.pub`),
which must be on `PATH`; `runTool` tells a missing binary apart from a refusing
one. Nothing shells out to `cp` or `chmod` any more — `decrypt` copies and
chmods in-process, because the old spawn left a private key at age's 0644 for the
length of two processes.
The write path is one function, `storeInVault` (`keyman.vault.ts`), shared by
`encrypt`, `generate` and `rotate`; `listVaultKeys` is the one reader of the
`<keysDir>/<name>/id_<name>.age` layout. Rotation is deliberately two operations
(`rotate` adds a replacement under the next name in the series, `retire` deletes
the superseded key), because a rotation that replaces the key in place locks you
out of the host it was for. keyman never handles a passphrase: `ssh-keygen`
prompts for it with stdio inherited, since `-N <value>` put it in argv where `ps`
could read it.
`docs/AUDIT.md` is a full audit of the package with each finding marked closed as
it landed, and `docs/PLAN.md` the ten phases that closed them. Both are records
now, not plans.
### Updating
`nopy.update.ts` and `keyman.update.ts` are two near-identical copies of one
module: derive the channel from the running version (`-main.``main`, any
other prerelease → `next`, clean → `latest`), resolve the registry from
`npm config get @bitsquare:registry`, read `dist-tags` off the packument with a
plain `fetch`, compare with semver. Nothing about the channel is stored — the
version you are running is the one piece of state that is always right, so an
upgrade cannot silently move you to a different channel.
They back a `self-update` subcommand and a once-a-day startup check whose hint
goes to **stderr**, so `--json` and `--print-only` stay machine-readable. The
cache is `~/.nopy/update-check.json` / `~/.keyman/update-check.json`; a
mismatched channel or registry in the cache is never treated as fresh. The
check is disabled whenever `CI` is set.
The install command uses `--@bitsquare:registry=<url>`, never `--registry`:
Gitea serves the `@bitsquare` scope and does **not** proxy npmjs, so a global
`--registry` would send every transitive dependency to a registry that has never
heard of them. Verified — `npm i -g @bitsquare/nopy@main --@bitsquare:registry=…`
pulls `nopy-cubes` from Gitea and the other 55 packages from npmjs. pnpm accepts
the same flag; the `npm_config_@bitsquare:registry` env var does not work with
pnpm and is not used.
The duplication between the two modules is deliberate: keyman shares no internal
library with nopy, and a fifth workspace package for ~250 lines would add
another edge to the publish order. Extract it if a third CLI appears.
## Releasing
Tag-driven, one package at a time; see `README.PUBLISH.md`.
### Registry resolution
The repo commits a root `.npmrc` mapping `@bitsquare:registry` to the Gitea
registry, so every npm/pnpm command run from the repo — global installs
included, since npm reads the project file for those too — resolves the scope
from Gitea. `.gitignore` ignores `.npmrc` generally (the workflows write
credentials to `.npmrc-gitea` / `.npmrc-release`) and carries a `!/.npmrc`
negation for the root file, which holds the mapping and no token.
Not a trade against npmjs: Gitea is a strict superset for this scope, since
`release.yml` publishes to both and `publish-snapshot.yml` adds a `main`
snapshot per push. It cannot affect `pnpm install` either — every `@bitsquare`
range in the workspace is `workspace:*` resolving to `link:`, so nothing in the
tree is fetched from that scope.
Two consequences: a bare `npm view @bitsquare/…` from the repo now answers for
**Gitea**, and an *untagged* install resolves to nothing, because Gitea
publishes no `latest` tag yet — always name `@main` or `@next`.
`pnpm run registry:status` prints both registries side by side and marks the
versions Gitea has that npmjs does not.
The sharp edge is in CI. `@scope:registry` is resolved *before* `registry` for a
scoped package, so the scoped key beats a `--registry` flag; and a project
`.npmrc` outranks the userconfig the workflows write. With the committed file in
place, `pnpm publish --registry <npmjs>` was measured uploading to **Gitea**, and
the `npm view --registry <npmjs>` guard answered from Gitea and skipped the npmjs
publish. Both workflows now `rm -f .npmrc` after checkout *and* pass
`--@bitsquare:registry=<url>` on every publish and lookup; either alone is
sufficient, and both were verified with `pnpm publish --dry-run`. This is the
same reason `self-update` never emits a bare `--registry`.
**Versions are `0.x.y`, not `1.0.0-alphaN`.** The dist-tag rule in `release.yml`
is mechanical — anything with a `-` goes out as `next` — so while every package
carried an `alphaN` suffix, `latest` never moved. `latest` on npmjs pointed at
`1.0.0-alpha5` only because npmjs sets it on a package's *first* publish
regardless of `--tag`; on Gitea it did not exist at all. Note that `npm view
<name>` against a registry with no `latest` tag prints nothing and exits **0**,
which is why this looked like a working lookup. (`npm view <name>@<version>`
does exit 1 for a missing version, so the workflows' idempotency guards are
fine.) All four packages were reset to `0.5.0`; `1.0.0-alpha5` stays the
numerically highest version on npmjs, so install with an explicit `@latest`.
- Push to `main``publish-snapshot.yml` publishes every package to the Gitea
registry as `<version>-main.<run>.g<sha>` under the `main` dist-tag. The
version is set on the runner with `npm pkg set` and never committed.
- `git tag <dir>-v<version>` (e.g. `nopy-v1.2.0` — the directory under
`packages/`, not the npm name) → `release.yml` publishes to Gitea *and* npmjs.
The tag chooses the package, `package.json` supplies the version, and the run
fails if they disagree. A prerelease version goes out as `next`, otherwise
`latest`.
So: bump `packages/<pkg>/package.json`, land it on `main`, then tag that commit.
Both workflows also stamp `buildInfo.commit` (the 7-char sha) into the manifest
with the same `npm pkg set`, never committed either — the snapshot loop stamps
every package, and the release step stamps whichever one the tag named. Both
CLIs append it to `--version` in parentheses — `0.5.0 (ab12cd7)` — and print the
bare version when the field is absent, which is every run from source. The
version string itself is untouched: `nopy.cli.ts` and `keyman.cli.ts` decorate
only the string they print, while `updateNotice()` and `selfUpdate()` keep
reading the raw `version`, so channel derivation never sees the annotation. An
unknown top-level key is ignored by npm and `package.json` is always packed, so
nothing in `files` had to change. The two CLIs are kept in step here for the
same reason their update modules are duplicated rather than shared.
Three things the `workspace:*` links added, all of them non-obvious:
- **`pnpm publish`, never `npm publish`.** `link-workspace-packages` is unset and
pnpm 10+ defaults it to `false`, so `workspace:*` is mandatory in the manifests
— and npm does not understand it. `npm pack` ships the literal string and the
install fails with `EUNSUPPORTEDPROTOCOL`; `pnpm pack`/`pnpm publish` substitute
the real version at pack time. Both directions were measured, not assumed.
- **`scripts/verify-pack.mjs`** packs every non-private package and fails if any
`workspace:` range survived into a tarball. It runs in both publish workflows.
Note `pnpm pack` has no `--ignore-scripts` flag, so `prepack` does rebuild —
which means the artefact under test is the one publish ships.
- **Order.** `packages/*/` sorts `nopy` before `nopy-cubes`, which is backwards.
`scripts/publish-order.mjs` topologically sorts over the `workspace:` edges;
the snapshot workflow stamps *every* version first and only then publishes in
that order, because `pnpm publish` reads the linked package's version at pack
time. `release.yml` additionally refuses to ship a package whose linked
dependency is not yet on npmjs (`scripts/linked-deps.mjs`) — npmjs is the
registry you cannot take a mistake back from.
## Known drift
`logConfigToFlags()` is exported and tested but nothing feeds its output into the
built pyinfra command, so `log.verbosity` / `log.debug` in `.nopyrc.json`
currently have no effect. Treat `docs/REFACTORING.md` as a plan, not a record.
The publish lane has now run against the Gitea registry: all four packages are
there under `@main`, and `pnpm run try:snapshot` installs them into a throwaway
project with npm and runs the binary. The npmjs lane has only ever published
`@bitsquare/nopy`; `keyman`, `nopy-cubes` and `nopy-cubes-core` have never been
released there, so the *check linked deps are released* guard in `release.yml`
will stop the first `nopy` release until `nopy-cubes` ships.
Nothing checks that a bundle and the CLI reading it are compatible versions;
`nopy.engines` was considered and deferred. `docs/CUBE-PACKAGES.md` is where all
of this came from and is now a record of what was built, including what differed
from the plan.
`docs/API.md` was regenerated against the source and now covers every export in
`src/index.ts` plus the authoring package; its *Known gaps* section is the short
list of behaviour that surprises a reader (`--json` printing nothing on success,
`DeployCall.dependencies` always empty, `ExecutionResult.stdout` never populated,
no cycle detection, and `self-update` reporting an empty dist-tag as an
unreachable registry). `CubePackageRef` is referenced by the exported
`NopyConfig` but is not itself re-exported, so a consumer cannot name the type —
one line, not yet fixed. `DOCS-AUDIT.md` tracks the drift in the remaining
documents; §2.9 (the nopy README shipping yarn-workspace instructions to npmjs)
and §2.10 (the keyman README describing four of nine operations and inventing a
tenth) are both closed. The keyman README now quotes `helpText()` verbatim and a
test fails if the two diverge, which is the shape worth copying for nopy.