# Documentation audit Every claim in the repository's Markdown was checked against the source it describes. Findings are grouped by *kind of divergence*, because the fix differs: a phantom feature needs a decision (build it or delete the docs), a wrong claim needs an edit, a gap needs prose. Severity is about what it costs a reader: - **๐Ÿ”ด broken** โ€” following the documentation produces a wrong result or a crash. - **๐ŸŸ  misleading** โ€” the documentation states something the code does not do. - **๐ŸŸก gap** โ€” the code does something real that no document mentions. Verified against the working tree at commit `fcc1817`. Line numbers are from that state. Findings closed since are marked **โœ… โ€ฆ fixed** and keep their original text as the record of what was wrong. So far: ยง1.1 (`--use-defaults`), ยง2.2 (`getDefaults()`), ยง2.1 (precedence โ€” the second half closed differently than proposed), ยง3 in full (`docs/API.md`, regenerated), ยง4.2 (password on stdout โ€” points 1 and 2 of 3), ยง4.3 (what a session records), ยง2.9 (the nopy README's yarn install instructions), and one bullet of ยง6.4. Closing ยง3 also settled the documentation half of several findings elsewhere without touching their underlying cause: ยง1.2, ยง1.3, ยง1.5, ยง2.3, ยง2.7, ยง4.4 and ยง6.5 are each now stated accurately in `docs/API.md`, but the code still behaves as those findings describe and they stay open. --- ## Contents - [1. Documented features that do not exist](#1-documented-features-that-do-not-exist) - [2. Documented behaviour that differs from the code](#2-documented-behaviour-that-differs-from-the-code) - [3. โœ… `docs/API.md` โ€” systematic drift โ€” fixed](#3--docsapimd--systematic-drift--fixed) - [4. Undocumented behaviour](#4-undocumented-behaviour) - [5. Cube documentation](#5-cube-documentation) - [6. Defects found while verifying](#6-defects-found-while-verifying) - [7. Checked and accurate](#7-checked-and-accurate) - [Suggested order of attack](#suggested-order-of-attack) --- ## 1. Documented features that do not exist These are the same class of problem as the `--parallel` flag that was removed earlier: documented in detail, absent from the source. ### 1.1 โœ… `-D, --use-defaults` does nothing โ€” **fixed** > **Resolved.** The flag is now implemented; see `docs/REFACTORING.md` item 5. > `BuildContext.resolveCube` skips the prompts, `env` in `.nopyrc.json` outranks > the schema default so a non-interactive run can be configured, and a cube with > a variable nothing can fill aborts the run by name instead of deploying it > blank. The finding below is kept as the record of what was wrong. | | | |---|---| | **Docs say** | `README.md:291` "Install with defaults (no prompts for customization)"; `docs/API.md:39` "Skip variable prompts, use defaults"; `nopy.cli.ts:54` "Run cubes with default values without prompts" | | **Code does** | Nothing. | The option is threaded through four layers and then dropped. `nopy.cli.ts:95` โ†’ `nopy.main.ts:125` โ†’ `nopy.main.ts:174` โ†’ `BuildContext.options.useDefaults` (`cubes/dependencies.ts:35`), where it is **never read**. The only branch that skips prompting is `isSessionReplay` (`cubes/dependencies.ts:62`). `runInteractiveWorkflow` destructures only `useAuthKey` and ignores its `useDefaults` too (`nopy.workflow.ts:50`). Every documented `-D` invocation โ€” including `nopy install -D --save-session automated-deployment.nopysession.json` (`README.md:226`), which is presented as the way to do an unattended run โ€” prompts for every variable of every cube. ``` $ grep -rn "useDefaults" packages/nopy/src/ nopy.workflow.ts:19 useDefaults?: boolean; # declared nopy.main.ts:94 useDefaults?: boolean; # declared nopy.main.ts:125 useDefaults = false, # defaulted nopy.main.ts:158 { useDefaults, useAuthKey },# passed nopy.main.ts:174 useDefaults, # passed nopy.cli.ts:95 useDefaults: options... # passed cubes/dependencies.ts:35 useDefaults?: boolean; # declared โ€” and that is all ``` ### 1.2 ๐Ÿ”ด `-j, --json` produces no output on success | | | |---|---| | **Docs say** | `README.md:18` "**JSON output** for CI/CD integration"; `README.md:360-367` "Machine-readable JSON output for scripting and CI/CD integration"; `docs/API.md:573` | | **Code does** | Emits JSON **only** on failure. | `jsonOutput` reaches three places in `nopy.main.ts` (140, 150, 219) and none of them prints a result. It suppresses the config banner, prints `{success: false, errors}` when cube *loading* fails, and suppresses progress lines. The success path at `nopy.main.ts:226-236` returns the `NopyResult` object to the caller, and `nopy.cli.ts:107-110` inspects `result.success` without printing it. A CI job running `nopy install --json` gets pyinfra's inherited stdio and nothing machine-readable. The exit code is the only usable signal. Related: `--dry-run --json` prints the **text** plan, not JSON. `executeDeployCalls` calls `outputExecutionPlan(calls)` without the `asJson` argument (`nopy.executor.ts:172`), even though the function supports it (`nopy.executor.ts:110`). ### 1.3 ๐ŸŸ  `log.verbosity` and `log.debug` have no effect Pre-existing known drift, recorded in `CLAUDE.md`, but the README still presents it as a working feature โ€” two tables, a recommendation paragraph, and a slot in the config example (`README.md:127-130`, `143-163`). `logConfigToFlags()` (`nopy.config.ts:352`) is exported and has 8 unit tests, but nothing calls it. `buildDeployCall` (`cubes/dependencies.ts:105-124`) constructs the pyinfra argv without consulting `config.log` at all. This is live in the repo's own config: `packages/nopy/.nopyrc.json` sets `"verbosity": "trace", "debug": true` and gets neither. ### 1.4 ๐ŸŸ  Manifest `env` property `README.md:14` lists "**Default values** with optional customization via manifest `env`". `env` was removed from `Manifest` โ€” see `docs/REFACTORING.md` item 3, and the current interface at `cubes/types.ts:43-56`, which has `id`, `name`, `schema`, `dependencies`, `before`, `after` and nothing else. ### 1.5 ๐ŸŸ  Topological sorting `README.md:11` ("Dependency resolution with **topological sorting**"), `README.md:25` ("Topologically sorts cubes based on dependencies") and `README.md:381` ("because cubes are topologically sorted") describe an algorithm that does not exist. There is no sort. `BuildContext.resolveCube` recurses depth-first and pushes each cube after its dependencies, with a `${cubeId}:${host}` set for idempotence (`cubes/dependencies.ts:43-100`, `105-143`). The ordering is a side effect of the recursion order. This matters beyond vocabulary: a topological sort detects cycles, and this does not. Two cubes that depend on each other recurse until the stack overflows โ€” `resolvedCubes` is only consulted in `buildDeployCall`, which runs *after* the recursive call. `docs/API.md:160` still promises `Error` "if ... circular dependency detected". > The `API.md` promise is gone (ยง3): the regenerated file states that ordering > falls out of the recursion and that there is no cycle detection. The README > claims and the missing detection itself both stand. --- ## 2. Documented behaviour that differs from the code ### 2.1 โœ… Variable precedence is wrong in both directions โ€” **fixed** > **Resolved, though the second pair closed the opposite way to the README's > original claim.** `env` now outranks the Zod defaults, as documented โ€” that was > a prerequisite for `--use-defaults` being configurable at all. > > Dependency/hook params still outrank prompts, deliberately. They do not compete > in practice: `VariableAssignment` leaves out any key a dependency supplied, so > the operator is never asked about it and there is no typed value to override. > The README documents the real order rather than the old promise. > > The underlying complaint โ€” that precedence was the field order of an object > literal and so could be neither named nor questioned โ€” is what > `docs/REFACTORING.md` item 6 addresses. `Origin` now ranks > `default < env < session < prompt < param` as data, and every value carries the > origin it came from. `README.md:107-113` stated: > **Priority order (lowest to highest):** > 1. Zod schema `.default()` values > 2. Global `env` from `.nopyrc.json` > 3. Accumulated variables from dependencies > 4. User prompts / session replay `Variables.get()` (`nopy.common.ts:33-39`) does: ```typescript return { ...this.global, // 1. config env (lowest) ...this.defaults[id], // 2. Zod defaults ...this.prompts[id], // 3. what the user typed ...this.params[id], // 4. dependency / hook (highest) }; ``` Two pairs are inverted, and both have consequences: - **Zod defaults beat `env`, not the other way round.** So `README.md:114` โ€” "allowing users to override them globally via `.nopyrc.json`" โ€” is backwards. Setting `"env": {"UPDATE": false}` cannot override a cube declaring `.default(true)`; the `env` value is only visible for keys the schema does not define. This is why `KEY_DIR` works in `packages/nopy/.nopyrc.json` (no cube declares it) and why anything else would not. - **Dependency/hook parameters beat user prompts.** A value passed as `[['user:add', {USER: 'deploy'}]]` silently overrides what the operator just typed at the form. The docs promise the opposite. ### 2.2 โœ… "Every key ... is guaranteed to be present on `host.data`" โ€” not when a field lacks `.default()` โ€” **fixed** > **Resolved.** `getDefaults()` falls back to a per-field read, so one required > field no longer wipes out the rest; `VariableAssignment` prompts for every > schema key rather than only the defaulted ones; and `--use-defaults` refuses > to deploy a cube whose required key nothing supplied. Verified against all 22 > cubes in `cubes/`: 19 build a complete `-D` run, the 3 below abort by name. > > Re-measured against the 25 cubes now in `packages/cubes-core/cubes`: 20 build a > complete `-D` run and 5 abort by name. Two of the additions are deliberate โ€” > `user:add` lost its `PUBKEY` default (it was a specific personal key), and > `ssh:keygen` inherits that failure because it declares `dependencies: () => > ['user:add']` and passes no parameters. See ยง6.6. `README.md:99` states it outright; `README.md:105` claims defaults ensure "every cube has a predictable starting state". `Cube.getDefaults()` (`cubes/types.ts:106-112`) is: ```typescript try { return this.manifest.schema.parse({}); } catch { return {} as z.infer; } ``` One field without a `.default()` makes `parse({})` throw, and the `catch` discards the defaults of **every other field in the cube**. `VariableAssignment` then iterates over that empty object and returns before prompting (`nopy.prompts.ts:175-181`), so the user is never asked. The cube deploys with no `--data` flags at all and every `host.data.X` is `None`. Three cubes in this repo are in that state today: | Cube | Field(s) without `.default()` | Result | |---|---|---| | `net:wifi:connection` | `SSID`, `PASSWORD` | no prompt, no `--data`, all 4 vars lost | | `service:autostart` | `APP` | no prompt, no `--data`, all 3 vars lost | | `user:edit` | `USER` | no prompt, no `--data`, all 4 vars lost | The failure is silent โ€” no error, no warning, just a pyinfra run with an empty data set. ### 2.3 ๐Ÿ”ด `.describe()` before `.default()` loses the prompt label `CLAUDE.md` and the cube contract state that each schema field is `.describe()`d and "the description is the prompt label". `nopy.prompts.ts:184-185` reads it as: ```typescript const zodType = schema[key]; const description = zodType?.description || key; ``` In zod 4, `.default()` returns a `ZodDefault` **wrapper** that does not inherit `.description` from the type it wraps. Ordering therefore decides whether the label survives: ``` z.boolean().describe('Update package cache').default(false) โ†’ description undefined z.boolean().default(false).describe('Update package cache') โ†’ description preserved ``` The README's own manifest example (`README.md:67-68`) uses the losing order, so anyone copying it gets bare `UPDATE` / `PACKAGES` keys as prompt labels instead of the sentences they wrote. `docs/API.md:610` happens to use the working order โ€” the two documents disagree, and neither mentions that it matters. > `docs/API.md` now says so explicitly, next to its manifest example, with the > zod 4.4.3 measurement (ยง3). The README example and the 15 affected manifests > are untouched, and the one-line fix in `nopy.prompts.ts` โ€” read through the > `ZodDefault` wrapper โ€” is still the better answer. 15 of the 22 cubes in `cubes/` are affected; among them `net:tailscale` (all 4 fields), `runtime:nodevm` (all 4), `user:add` (all 4), `ssh:keygen` (all 4) and `admin:locale` (all 4). ### 2.4 ๐ŸŸ  Session files claim `version` and `timestamp` fields `README.md:179-181` shows a session with `"version": "1.0.0"` and `"timestamp": "2025-10-13T10:30:00Z"`, and `docs/SESSION_FORMAT.md:305-306` declares both **required** in the `NopySession` interface. Every MJS example in that file sets them. `NopySession` (`nopy.session.ts:44-55`) has neither. `createSession` (`nopy.session.ts:183-197`) does not add them, `saveSession` writes the object verbatim (`nopy.session.ts:68-79`), and `loadSession`'s validation (`nopy.session.ts:146-155`) checks only `cubes`, `hosts` and `auth`. The repository's own `packages/nopy/example.nopysession.json` omits both โ€” it does not match the format its own documentation prescribes. Consequence: a `version` field implies a compatibility check that does not exist. Nothing reads it, so an incompatible old session fails later and more obscurely than a version check would. ### 2.5 ๐ŸŸ  Session filename convention does not match `listSessions()` The READMEs consistently use `*.nopysession.json` (`README.md:223`, `330`, `338`; `docs/DOCKER.md:54`; the shipped `example.nopysession.json`). `docs/SESSION_FORMAT.md` consistently uses `*.session.json` / `*.session.mjs`. `listSessions()` (`nopy.session.ts:173`) matches only the second form: ```typescript .filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs')) ``` `"my-deployment.nopysession.json"` does not end in `".session.json"`, so the file naming the README recommends is invisible to the function documented at `docs/API.md:430`. `loadSession` is unaffected (it switches on `.json`/`.mjs`), so this only bites the listing API. ### 2.6 ๐ŸŸ  `docs/DOCKER.md` container name contradicts the file it points at `docs/DOCKER.md:35` and `:45`: > We explicitly name it `nopy-test-container` because the > `example.nopysession.json` is configured to target this specific container name. `packages/nopy/example.nopysession.json` targets `@docker/nopy-test-ubuntu` โ€” the *image* tag from the build step, not the container name. Following the guide exactly produces a pyinfra run against a container that does not exist. (`packages/nopy/.nopyrc.json` does list `@docker/nopy-test-container` in `hosts`, so the guide was probably written against the config rather than the session.) ### 2.7 ๐ŸŸ  `docs/HOOKS.md` โ€” hook parameters are not validated `docs/HOOKS.md:48` describes the hook's second argument as "The final, **validated** variables for the current cube". `cubes/dependencies.ts:71` passes `this.variables.get(cubeId)` โ€” a plain merge of the four scopes. `schema.parse()` is called in exactly one place, `Cube.getDefaults()` on an empty object, to extract defaults. Values from prompts, `env`, dependencies and hooks are never validated against the schema at any point in the pipeline. `coerceValue` (`nopy.prompts.ts:145-159`) type-coerces prompt input, which is not the same as validation and does not apply to the other three scopes. ### 2.8 ๐ŸŸ  `docs/HOOKS.md` โ€” dependencies can pass variables too The comparison table at `docs/HOOKS.md:83` says variable passing is "Inherited from env" for dependencies versus "Explicitly passed via `exec()`" for hooks, presenting explicit parameters as a hook-only capability. `DependencySpec` is `string | [id, variables?]` (`cubes/types.ts:23`) and `cubes/dependencies.ts:86-88` unpacks the tuple and forwards it into the same `params` scope that `exec()` writes to. The two mechanisms are identical in this respect; per ยง2.1 both outrank user prompts. ### 2.9 โœ… nopy README installation section describes the wrong package manager โ€” **fixed** > **Resolved.** The yarn-workspace block is gone. The section now opens with > `npm install -g @bitsquare/nopy` (and the pnpm equivalent), documents the > `latest` / `next` / `main` channels, shows the `@bitsquare` scope mapping > needed to install from Gitea, and gains an *Upgrading* section covering > `nopy self-update` and the `NOPY_*` env vars. The finding below is kept as the > record of what was wrong. `README.md:248-279` says "This package is part of a **yarn** workspace monorepo", then gives `yarn install`, `yarn workspace @bitsquare/nopy build`, `yarn workspace @bitsquare/nopy nopy`, and `yarn nopy`. The repo is a **pnpm** workspace: `packageManager: "pnpm@11.17.0"` in the root manifest, `pnpm-workspace.yaml`, a `pnpm-lock.yaml`, and every other document (root `README.md`, `README.PUBLISH.md`, `CLAUDE.md`) uses pnpm. There is no `yarn.lock`. The section is also the wrong content for the file. `README.md` is one of three files shipped in the npm tarball (`files: ["dist", "README.md", "LICENSE"]`), so this is what a reader sees on npmjs.com โ€” build-from-monorepo instructions instead of `npm install -g @bitsquare/nopy`, which is what the root README and `README.PUBLISH.md:314` correctly tell people to run. ### 2.10 ๐ŸŸ  keyman README: two operations missing, one operation invented `packages/keyman/README.md:90-96` lists four menu entries: List, Encrypt, Decrypt, Quit. The menu (`keyman.main.ts:54-61`) has six: ``` ๐Ÿ“‹ List keys ๐Ÿ“ Copy public key ๐Ÿ†• Generate key ๐Ÿ”’ Encrypt keys ๐Ÿ”“ Decrypt keys โŒ Quit ``` `Copy public key` and `Generate key` are undocumented โ€” the latter being the only way to create a key inside the tool, which is why the Quick Start (`packages/keyman/README.md:33`) tells the user to shell out to `ssh-keygen` manually. Conversely `packages/keyman/README.md:11` advertises "๐Ÿ”„ Support for key rotation". There is no rotation anywhere: `grep -rn "rotat" packages/keyman/src/` returns nothing. `packages/keyman/README.md:93` also says encrypt takes keys "from `vault/tmp/`". `encryptKeys` (`keyman.encrypt.ts:12-22`) unions `~/.ssh` and `vault/tmp`, and offers both in the checkbox. ### 2.11 ๐ŸŸก Root README understates the coverage gate Root `README.md:57-58` describes "a hard **85 % branch** floor". Both `vitest.config.ts` files set four thresholds: branches 85, functions 85, lines 80, statements 80. `README.PUBLISH.md:135` and `CLAUDE.md` both state all four โ€” the root README is the odd one out, and it is the file a new contributor reads first. ### 2.12 ๐ŸŸก `docs/DOCKER.md` relative link is broken `docs/DOCKER.md:8` links `[README.md](./README.md)`, which resolves to `packages/nopy/docs/README.md` โ€” nonexistent. It should be `../README.md`. --- ## 3. โœ… `docs/API.md` โ€” systematic drift โ€” **fixed** > **Resolved by regenerating the file**, which is what ยง3's own recommendation > asked for โ€” the drift was structural rather than a set of stale lines, so > patching would have left the shape wrong. Every export in `src/index.ts` was > re-read against its source and the file now covers all of them: the authoring > package as its own section, `BuildContext` in place of the phantom Builder > Module, and the variables, history and prompts modules that had no entry at > all. The findings below are kept as the record of what was wrong. > > Three things were deliberately added rather than merely corrected. A > **Known gaps** section states the behaviour a reader would otherwise take on > trust โ€” `logConfigToFlags` being unconsumed (ยง1.3), `--json` printing nothing > on success (ยง1.2), the absent cycle detection (ยง1.5, ยง6.5), `DeployCall.dependencies` > always being `[]`, `ExecutionResult.stdout`/`stderr` never being populated, and > hook variables not being schema-validated (ยง2.7). The `.describe()`/`.default()` > ordering hazard (ยง2.3) is called out where the manifest example lives, with the > zod 4.4.3 measurement. And `-P` is documented alongside the rest of the CLI > (ยง4.4 โ€” the README half of that finding stands). > > One thing surfaced while writing it and is **not** fixed: `CubePackageRef` is > referenced by the exported `NopyConfig` but is not itself re-exported from > `src/index.ts`, so a consumer cannot name the type. Recorded in the file as a > note. `docs/API.md` documents an earlier architecture. It is not a matter of individual stale lines: the two central type definitions, one whole module, and two of the documented functions describe code that no longer exists. Anyone building against this file writes code that will not compile. Recommendation: regenerate rather than patch. ### 3.1 ๐Ÿ”ด Functions that do not exist | Documented | Reality | |---|---| | `resolveDependencies(cubes, selectedCubeNames)` (`API.md:142-160`) | No such export. Resolution is `BuildContext.resolveCube` and returns nothing โ€” it accumulates into `deployCalls`. | | `buildDeployCalls(cubeNames, hosts, context)` (`API.md:286-313`) | No such export. The entire "Builder Module" section, and its `BuildResult` interface, describes code replaced by `BuildContext` (`docs/REFACTORING.md` item 2). Still listed in the table of contents at `API.md:12`. | ### 3.2 ๐Ÿ”ด `Cube` โ€” wrong shape entirely `API.md:75-84` documents an interface with `key`, `dependencies: string[]`, `schema`, `defaults()`, `before`, `after`. `Cube` (`cubes/types.ts:88-113`) is a **class**: constructor `(manifest, dir, deployScript)`, getters `id` and `name`, method `getDefaults()`. Everything else lives behind `.manifest`. Not one documented member name is correct โ€” `key` is `id`, `defaults()` is `getDefaults()`, and `dependencies`/`schema`/`before`/ `after` are on `cube.manifest`, not on `cube`. ### 3.3 ๐Ÿ”ด `Manifest` โ€” wrong shape `API.md:92-100` documents `key`, `dependencies: string[]`, `defaults: () => ...`. `Manifest` (`cubes/types.ts:43-56`) has `id` (not `key`), no `defaults` member at all, and `dependencies` is a **function of the collected variables**: ```typescript dependencies?: (variables: z.infer) => DependencySpec[]; ``` That signature change is the headline of `docs/REFACTORING.md` item 2. The `API.md` example at `:171` does use the function form, so the file contradicts itself two paragraphs apart. Both `Cube` and `Manifest` are additionally shown as generic over `z.AnyZodObject`, which zod 4 removed; the codebase defines `AnyObjectSchema` for exactly this reason (`cubes/types.ts:13`). ### 3.4 ๐ŸŸ  Incorrect signatures and examples | Location | Documented | Actual | |---|---|---| | `API.md:486-492` | `saveConfig(data, local?)`, example passes `false` | `saveConfig(data, configPath?: string)` (`nopy.config.ts:323`). Passing `false` writes nothing useful. | | `API.md:481-484` | Search order `./nopyrc.json` then `~/.nopyrc.json` | Filename is `.nopyrc.json` (leading dot). Home is applied **first** (lowest priority, `nopy.config.ts:117-120`), all ancestors are collected and merged root-first, and the function **throws** when none is found (`nopy.config.ts:285-289`). The `resolution` merge strategy is not mentioned. | | `API.md:534-540` | `VariableAssignment(cube, env)` returning vars | `VariableAssignment(cube, variables: Variables)` returns `Promise` and mutates the `Variables` instance (`nopy.prompts.ts:167`). The example's return value is always `undefined`. | | `API.md:321-331` | `runWorkflow(sessionPath, cubes, config, options?)` | Takes a fifth parameter `replaySession?: NopySession` (`nopy.workflow.ts:206-212`) โ€” the entire history-replay path. | | `API.md:337-344` | `WorkflowResult.cubesWithDependencies` | Field is `selectedCubes` (`nopy.workflow.ts:31`). | | `API.md:202-209` | `DeployCall.dependencies: string[]` | `DependencySpec[]` (`nopy.executor.ts:27`) โ€” and always `[]` in practice (`cubes/dependencies.ts:132`). | | `API.md:452-457` | `NopyConfig` with 4 fields | Missing `history` and `execution` (`nopy.config.ts:68-81`). | | `API.md:37-45` | 7 `NopyOptions` parameters | Missing `printOnly`, `replaySession`, `saveToHistory` (`nopy.main.ts:93-104`). | | `API.md:169-175` | `cubes.Manifest` example | Omits `id`, the field that determines the cube's identity. | ### 3.5 ๐ŸŸก Exported and undocumented Public API in `src/index.ts` with no `API.md` entry: the entire history module (`addToHistory`, `listHistory`, `getLastSession`, `getSessionById`, `clearHistory`, `removeFromHistory`, `loadHistory`, `saveHistory`, `getHistoryPath`, `formatHistoryList`, `HISTORY_FILE`, `DEFAULT_HISTORY_SIZE`, plus `HistoryEntry` / `SessionHistory`), `BuildContext`, `runSessionReplayWorkflow`, `getConfigPaths`, `findCubeDirectories`, `getCube`, ~~`filterInternalVariables`, `separateEnvAndCubeVariables`~~ โ€” those last two were dead on arrival (nothing called them once the session recorder stopped splitting `env` out) and have since been deleted rather than documented. `maskCommand`, `maskVariables`, `Variable`, `Variables`, `MASK` and the `Origin` / `Assignment` types are newly exported and also have no `API.md` entry. The CLI cheat-sheet (`API.md:554-578`) omits `-R`, `-H`, `-P`, `--no-history`, and the `history` / `clear-history` commands. `ManifestFactory` (`cubes/factories.ts:28`) is marked `@deprecated` but is not re-exported from `cubes/index.ts`, so it is unreachable dead code. --- ## 4. Undocumented behaviour ### 4.1 ๐Ÿ”ด Cubes in this repo cannot be loaded `CLAUDE.md` records the `@bitsquare/nopy` linking gotcha; the package README does not mention it at all, and the gotcha is incomplete. Cube manifests are loaded by dynamic `import()` from their own directory (`cubes/loader.ts:75`), so they resolve their imports through ordinary Node resolution from `cubes/โ€ฆ`. Nothing in the tree provides either dependency: ``` node_modules/@bitsquare/ โ†’ absent packages/nopy/node_modules/@bitsquare/ โ†’ absent cubes/node_modules/ โ†’ absent ``` `@bitsquare/nopy` is the documented half. **`zod` is the other half** โ€” 20 of 22 manifests `import { z } from 'zod'`, and that fails independently of the nopy link. Verified by loading every manifest with a resolver hook: with only `@bitsquare/nopy` mapped, 20 of 22 fail `ERR_MODULE_NOT_FOUND: zod`. Because `loadCubes` turns each failure into an `errors` entry and `nopy.main.ts:147-152` aborts when `errors.length > 0`, a fresh clone cannot run a single cube. Neither README mentions a setup step. ### 4.2 ๐ŸŸ  The SSH password is printed in plaintext โ€” **mostly fixed** > **Points 1 and 2 resolved; point 3 stands.** `maskCommand()` > (`nopy.executor.ts`) rewrites the SSH `--password` and every `--data` value the > manifest declared a secret, and it is applied at all three places the command > string is printed: the debug log, the dry-run plan, and `--print-only`. The > name heuristic described in point 2 is gone โ€” the manifest's `secrets` array > says which keys are sensitive, so `TOKEN`, `PSK` and `AUTH_KEY` are covered > too, and it no longer matters that a key merely *looks* like a password. > > Point 3 is unchanged and now documented instead: the value still reaches > pyinfra on its command line, so it is visible in `ps`. That is inherent to > pyinfra's `--data` interface, not something nopy can mask. The shell-quoting > concern in the same point is also still open. See `docs/REFACTORING.md` item 7. Not stated in any document, and it sits directly against the security notes at `README.md:217` and `:325` (which are narrowly about *storage*, and are correct as far as they go). `buildDeployCall` embeds the password in the command string (`cubes/dependencies.ts:112-113`): ```typescript parts.push(`--user ${this.auth.username} --password ${this.auth.password}`); ``` That string is then: 1. logged at debug level โ€” `log.debug(\`Command: ${commandStr}\`)` (`nopy.executor.ts:76`) โ€” and the `nopy` logger is configured with `lowestLevel: 'debug'` (`nopy.main.ts:41-44`), so it **prints to the console on every deployment**; 2. printed unmasked by `--dry-run` โ€” `outputExecutionPlan` masks values whose key contains "password" in the `--data` section (`nopy.executor.ts:134`) but prints `call.command.join(' ')` verbatim one line earlier (`nopy.executor.ts:127`); 3. passed through `execa({shell: true})` (`nopy.executor.ts:79`), making it visible in the process list and, without quoting, vulnerable to shell metacharacters in the password. ### 4.3 โœ… History and session files record only prompted values โ€” **fixed** > **Resolved.** `buildDeployCall` records `Variables.persistable(cubeId)` โ€” every > value the cube settled on, whatever its origin โ€” so a `--use-defaults` run no > longer records an empty object, and a replay reproduces the run rather than > re-deriving it from whatever the defaults and `env` say at replay time. The one > deliberate exclusion is a key the manifest declared a secret; those are prompted > for again on replay, and a `-D` replay that would need one fails by name. > > The divergence noted at the end of this finding narrows but does not vanish: a > dependency graph that resolves differently can still produce a different > command, because `param` outranks `session` by design. `README.md:414` says an entry records "the variable values that were answered at the prompts" โ€” accurate, but the consequence is not drawn out. `buildDeployCall` records `this.variables.get(cubeId, 'prompts')` (`cubes/dependencies.ts:138`) โ€” the prompts scope alone. Values that came from a dependency spec, a hook's `exec()`, `env`, or a schema default are **not** in the entry. Combined with ยง2.1 (dependency params outrank prompts) this means a replay can legitimately produce a different command than the run it replays, if the dependency graph resolved differently. ### 4.4 ๐ŸŸก `-P, --print-only` is undocumented Implemented (`nopy.cli.ts:61`, `nopy.main.ts:202-213`), listed in the CLI's own help examples (`nopy.cli.ts:38`), and absent from `README.md` and `docs/API.md`. It prints the built pyinfra commands grouped by cube and exits. Worth documenting alongside `--dry-run`, since the difference is not obvious: `--print-only` returns a `NopyResult` with `successful: 0` and skips execution entirely, while `--dry-run` goes through the executor. ### 4.5 ๐ŸŸก `--save-session` is ignored during a replay `nopy.main.ts:191` guards with `saveSessionPath && !workflow.isReplay`, so `nopy install -R -s out.json` writes nothing and says nothing. The "Recording a Session" section (`README.md:219-227`) does not mention it. ### 4.6 ๐ŸŸก `.npcubes` is documented but unused in this repo `README.md:43` shows a `cubes/.npcubes` marker in the layout diagram and `README.md:244` documents the mechanism. `find . -name .npcubes` returns nothing โ€” discovery here runs entirely off `cubeDirs` in `.nopyrc.json`. The feature exists in `findCubeDirectories` (`cubes/loader.ts:26-30`); the diagram just shows a file that no reader will find if they go looking. ### 4.7 ๐ŸŸก `ssh:keyman` depends on a global `env` value `cubes/ssh/keyman/deploy.py` reads `host.data.get('KEY_DIR')`, which no manifest declares โ€” it comes from `env` in `.nopyrc.json`. This works (ยง2.1: `env` is visible for keys the schema does not define) and it is the only cube relying on the mechanism, but nothing documents the coupling. Anyone running that cube from a project without `KEY_DIR` in their config gets `None`. --- ## 5. Cube documentation Two cubes have **no README at all**: `cubes/admin/hostname` and `cubes/git/clone` (20 of 22 have one). ### 5.1 ๐Ÿ”ด `cubes/service/autostart/README.md` documents a different cube The file is titled **"TypeStack Install Cube"** and describes cloning a git repository, `yarn install`, `yarn build`, `docker compose up -d`, and PM2 process management. The manifest (`service:autostart`, "Manage systemd service autostart") does none of that โ€” it has three fields and calls `systemd.service`. | README documents | In the schema? | |---|---| | `USER` | โŒ | | `REPO` | โŒ | | `ENV` | โŒ | | `NODE_PATH` | โŒ | | `APP` | โœ… | | `AUTOSTART` | โœ… | | โ€” | `SERVICE_NAME` (undocumented) | Every "Requirements" entry (Git, Yarn, Docker, PM2, NVM, SSH keys) is inapplicable. It reads as a leftover from a cube that was split or renamed. ### 5.2 ๐ŸŸ  `cubes/network/wifi/access-point/README.md` โ€” four wrong parameters | README | Manifest | |---|---| | `NETWORK_DEVICE` (default `wlan0`) | does not exist | | `CHANNEL` | does not exist โ€” **but `deploy.py` reads it** (see ยง6.3) | | `IP_ADDRESS` (default `192.168.50.1`) | field is `AP_IP`, default `192.168.4.1` | | `CONNECTION_NAME` default `net:wifi:ap` | default is `pi-point` | | SSID / PASSWORD listed as "Required" | both have defaults (`PiPoint` / `1223334444`) | Both worked examples set keys that will be ignored. ### 5.3 ๐ŸŸ  Two cubes claim to have no parameters | Cube | README says | Schema has | |---|---|---| | `cubes/runtime/docker` | "This cube currently has no configurable parameters." | `DISTRO` (`ubuntu` \| `debian`) | | `cubes/runtime/nodevm` | "This cube currently has no configurable parameters." | `VERSION`, `USER`, `ALIAS`, `GLOBAL_PACKAGES` | `nodevm`'s README also describes installing "the latest LTS via the official NodeSource setup script", while the manifest is named "Install nvm and nodejs with global packages" and takes an explicit `VERSION`. (`admin/cockpit` and `armor/fail2ban` make the same claim and are correct โ€” both have empty schemas.) ### 5.4 ๐ŸŸก Cube id conventions are inconsistent - `cubes/caddy/base` declares `id: 'caddy'` while its sibling declares `caddy:spa`. Every other nested cube uses the `group:name` form. - `net:wifi:connection` sets `name: 'network:wifi:connection - Connect to a WiFi network'` and `user:edit` sets `name: 'user:edit - Modify an existing user account'` โ€” the id is baked into the display name. Since the picker renders `${cube.id} - ${cube.name}` (`nopy.prompts.ts:43`), these show as `net:wifi:connection - network:wifi:connection - Connect to a WiFi network`. `README.md:54` documents `[id]`-in-name as a *fallback* for a missing `id` field, not as a prefix to carry alongside one. --- ## 6. Defects found while verifying Not documentation issues, but found while checking the docs and worth recording. ### 6.1 ๐Ÿ”ด `cubes/service/autostart/deploy.py` cannot run ```python from pyinfra.operations import systemd # `server` is never imported from pyinfra import host APP = host.data.APP # AUTOSTART and SERVICE_NAME never read if AUTOSTART: # NameError ... server.shell(...) # NameError, in the else branch ``` `AUTOSTART` and `SERVICE_NAME` are declared in the manifest and never pulled off `host.data`; `server` is used but not imported. The script raises `NameError` on the `if`. Per ยง2.2 this cube also gets no `--data` at all, so it fails twice over. ### 6.2 ๐Ÿ”ด `-H ` and `--no-history` share one destination Both options write to `options.history` (`nopy.cli.ts:57` and `:64`). Verified with Commander: ``` argv=[] -> {} # undefined โ†’ saves argv=["--no-history"] -> {"history":false} # correct argv=["-H","abc123"] -> {"history":"abc123"} # correct argv=["-H","abc123","--no-history"] -> {"history":false} # id destroyed ``` In the last case the `-H` argument is silently discarded and nopy falls through to a full interactive run instead of replaying. `saveToHistory: options.history !== false` (`nopy.cli.ts:104`) works only because the two meanings happen not to collide in the common cases. ### 6.3 ๐ŸŸ  `access-point/deploy.py` reads an undeclared variable `host.data.get('CHANNEL')` โ€” no manifest declares `CHANNEL`, so it is always `None`. The README documents it as a supported optional parameter (ยง5.2). One of the three has to give. ### 6.4 ๐ŸŸก Debug output left in the shipped code - ~~`nopy.common.ts:22` โ€” `console.log('Assigning', artefactId, scope, values)` fires on every variable assignment, printing values to the console. Combined with ยง4.2 this is a second path by which secrets reach stdout.~~ **Removed** alongside the `--use-defaults` work; it would have made an unattended run unreadable. The two other paths in ยง4.2 are untouched. - `keyman.encrypt.ts:19-20` โ€” `console.log(tmpKeys); console.log(sshKeys);` before the prompt. ### 6.5 ๐ŸŸก No cycle detection Covered under ยง1.5. `docs/API.md:160` documents the error; there is no code that raises it. Mutually dependent cubes recurse until the stack overflows. ### 6.6 ๐ŸŸ  `ssh:keygen` depends on `user:add` but shares nothing with it `ssh:keygen` declares `dependencies: () => ['user:add']` with no parameters, so the two cubes resolve their `USER` independently: - `ssh:keygen` defaults `USER` to `vagrant`; - `user:add` defaults `USER` to `` user${uniqid(5)} `` โ€” a fresh random name. So the dependency creates an account the dependent then ignores, and generates a key for a `vagrant` user it never created. Passing the value through (`[['user:add', {USER}]]`) is what the dependency spec exists for; `param` outranks `default`, so it would take effect. Surfaced by removing `user:add`'s `PUBKEY` default: `ssh:keygen` now fails a `-D` run with `Cube "user:add" cannot run with --use-defaults: PUBKEY has no default value`, naming a cube the operator did not select. The underlying mismatch is older than that change and is not fixed here. Related: `user:add`'s `USER` default is generated (`` user${uniqid(5)} ``), the same shape as the `PASSWORD` default that was removed. It is recorded in the session, so replays are stable, but each fresh `-D` run still creates a differently-named account. --- ## 7. Checked and accurate Recording what was verified and found correct, so a future pass need not redo it. - **`README.PUBLISH.md`** โ€” checked against `.gitea/workflows/*.yml` and both manifests. Workflow triggers, the `files` array, dist-tag rules, the snapshot version format, `upload-artifact@v3`, `cache@v4`, the `npm pack --dry-run` step, `retention-days: 7`, and all four coverage thresholds are right. The only file that states the coverage gate completely. - **Root `README.md`** โ€” pnpm/corepack, Node โ‰ฅ 22 with `.nvmrc` pinning 24, the script table, and both git hooks match the root `package.json`. Only the coverage line is incomplete (ยง2.11). - **`CLAUDE.md`** โ€” accurate throughout, including the `logConfigToFlags` drift note and the resolution/merge description. Two additions worth making: `zod` is missing from the cube-linking gotcha (ยง4.1), and `-D` being a no-op (ยง1.1) belongs under "Known drift". - **`README.md` history section** (`:392-429`) โ€” the recording rules, the fail-then-`-R` flow, replay-does-not-re-record, the four suppression cases, the `defaults`-layer replay semantics, and the `Cube not found` failure mode all check out against `nopy.history.ts`, `nopy.main.ts:195-200` and `cubes/dependencies.ts:62-69`. - **`README.md` continue-on-error section** (`:369-390`) โ€” fail-fast, no rollback, skipped-cubes-absent-from-results, exit code 1, and CLI-over-config precedence match `nopy.executor.ts:180-193` and `nopy.cli.ts:67-68`. - **`README.md` cube layout and discovery** โ€” the directory-pair rule, recursive scan, dotted/`node_modules` skipping, the prefixed `*.manifest.mjs` fallback, and the three-step id resolution match `cubes/loader.ts` exactly. - **pyinfra `--data` type coercion** (`README.md:101`) โ€” correct. - **keyman config** โ€” priority (`VAULT_ROOT` > file > defaults), the four default values, and the vault layout match `keyman.config.ts` and `keyman.encrypt.ts`. --- ## Suggested order of attack **1 โ€” ~~Decide on the three phantom features.~~ Two left.** ยง1.1 (`-D`) is **done** โ€” implemented, tested, and verified against every cube in `cubes/`. That closed ยง2.2 and half of ยง2.1 with it, since neither could be left standing under a run that never prompts. ยง1.2 (`--json`) and ยง1.3 (`log.*`) are still "documented, wired up, never read": each is a small implementation or a small deletion, but neither can stay documented as working. **4 โ€” Decide the `.describe()`/`.default()` ordering (ยง2.3).** Either read through the `ZodDefault` wrapper in `nopy.prompts.ts`, or fix the ordering in all 14 manifests and the README example. The first is one line and cannot regress. **5 โ€” ~~Regenerate `docs/API.md` (ยง3).~~ Done.** Rewritten against the source rather than patched, and extended to the exports that never had an entry (variables, history, prompts, the authoring package). One new finding came out of it: `CubePackageRef` is not re-exported from `src/index.ts` although `NopyConfig` refers to it โ€” a one-line fix, left for whoever next touches the export list. **6 โ€” Cube docs (ยง5) and the two missing READMEs.** `service/autostart` is the worst โ€” its README belongs to a different cube, and its `deploy.py` does not run at all (ยง6.1). **7 โ€” Secrets on stdout (ยง4.2, ยง6.4).** The `console.log` in `Variables.assign` is gone. Still open: mask the password in the executor's debug line and in the dry-run plan, and pass `--user`/`--password` as argv rather than interpolating into a shell string.