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>
24 KiB
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: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:
nopy.config.ts—loadConfig()walks up fromprocess.cwd()collecting every.nopyrc.jsonplus~/.nopyrc.json, then merges them root-first. Per-property strategy comes from the child'sresolutionblock (mergeis the default: arrays concatenate and dedupe, objects deep-merge;overridereplaces). Only properties listed inPATH_PROPERTIES(cubeDirs) get relative paths resolved against their own config file's directory;cubePackagesneeds the same origin for a different reason, so each entry is normalised into aCubePackageRef {spec, from}—fromis 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 whynopy.cli.tscalls it lazily inside the action, so--help/--versionwork outside a project.cubes/packages.ts—resolveCubePackages()turns eachCubePackageRefinto a package root plus its cube directories. The location is a convention:<root>/cubes, so a bundle needs no nopy-specificpackage.jsonfield at all.nopy.cubessurvives only as an override, for the bundle whose cubes are elsewhere (dist/cubesafter 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 throughcreateRequire(...).resolve.paths()+existsSync, deliberately bypassing theexportsmap: a bundle ships directories and has no entry point to declare.existsSyncalso follows the symlink pnpm plants atnode_modules/<name>, which areaddirscan skips outright (it reportsisSymbolicLink(), notisDirectory()). 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, becausemergeValueonly dedupes arrays of primitives and these are objects.cubes/loader.ts—findCubeRoots()unionsconfig.cubeDirs, the directories fromcubePackages, and every ancestor directory holding a.npcubesmarker, then scans each recursively (skipping dotted dirs andnode_modules). A directory is a cube when it holds both a manifest (manifest.mjsor*.manifest.mjs) and a deploy script (deploy.pyor*.deploy.py); manifests are loaded by dynamicimport(). Cube id =manifest.id→ a[id]prefix inmanifest.name→ the directory basename. Ids are flat and need not mirror the path (cubes/network/tailscaledeclaresnet: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 asource—{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 inerrors, which aborts the run.nopy.workflow.ts— picks interactive, file-replay, or history-replay and normalises all three into aWorkflowResult. Replays never re-prompt except for passwords (never persisted) and a missing host.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) → runbeforehooks → resolvemanifest.dependencies(vars)(dynamic: it receives the collected variables) → emit the deploy call → runafterhooks. There is no separate topological sort; ordering falls out of the recursion, and a${cubeId}:${host}set makes emission idempotent. Hooks get aHookContextwhoseexec(id, vars)re-entersresolveCube, so a hook can pull in a cube that is not a declared dependency.nopy.executor.ts— runs the builtpyinfra <host> -y --data K=V ... --chdir <cubeDir> <script>commands through execa with inherited stdio, sequentially, stopping at the first failure unlesscontinueOnError.
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.ymlpublishes every package to the Gitea registry as<version>-main.<run>.g<sha>under themaindist-tag. The version is set on the runner withnpm pkg setand never committed. git tag <dir>-v<version>(e.g.nopy-v1.2.0— the directory underpackages/, not the npm name) →release.ymlpublishes to Gitea and npmjs. The tag chooses the package,package.jsonsupplies the version, and the run fails if they disagree. A prerelease version goes out asnext, otherwiselatest.
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, nevernpm publish.link-workspace-packagesis unset and pnpm 10+ defaults it tofalse, soworkspace:*is mandatory in the manifests — and npm does not understand it.npm packships the literal string and the install fails withEUNSUPPORTEDPROTOCOL;pnpm pack/pnpm publishsubstitute the real version at pack time. Both directions were measured, not assumed.scripts/verify-pack.mjspacks every non-private package and fails if anyworkspace:range survived into a tarball. It runs in both publish workflows. Notepnpm packhas no--ignore-scriptsflag, soprepackdoes rebuild — which means the artefact under test is the one publish ships.- Order.
packages/*/sortsnopybeforenopy-cubes, which is backwards.scripts/publish-order.mjstopologically sorts over theworkspace:edges; the snapshot workflow stamps every version first and only then publishes in that order, becausepnpm publishreads the linked package's version at pack time.release.ymladditionally 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.