Files
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

24 KiB
Raw Permalink Blame History

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

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:

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:citypechecktest: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.tsloadConfig() 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.tsresolveCubePackages() 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.tsfindCubeRoots() 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.tsBuildContext.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 mainpublish-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.