# 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-cube` | `@bitsquare/nopy-cube` | — | the authoring surface a `manifest.mjs` imports | | `packages/cubes-core` | `@bitsquare/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 `cubes-core` both depend on `nopy-cube` (`workspace:*`), so publish order matters — see *Releasing*. `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-cube` 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-cube/**` from coverage — without it nopy's numbers absorb another package's files. `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**: `/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/`, 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 -y --data K=V ... --chdir