[refactor] moving cubes into own package"
Publish snapshot / snapshot (push) Successful in 1m2s

[fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes
This commit is contained in:
Benjamin Diedrichsen
2026-07-28 12:18:10 +02:00
parent ac050c4459
commit 6ecb2c366f
130 changed files with 3386 additions and 520 deletions
+71 -1
View File
@@ -65,6 +65,20 @@ interface NopyResult {
The cubes module provides types and functions for working with deployment units.
The authoring half of it — `Manifest`, `Cube`, `Hook`, `uniqid` and the rest —
actually lives in **[`@bitsquare/nopy-cube`](../../nopy-cube)**, a package with
no CLI and no dependency other than zod. `@bitsquare/nopy` re-exports all of it,
so both of these work:
```javascript
import { Manifest } from '@bitsquare/nopy-cube'; // in a manifest.mjs — prefer this
import { cubes } from '@bitsquare/nopy'; // cubes.Manifest — still supported
```
Import from `nopy-cube` in a cube bundle you intend to publish: it lets the
bundle depend on the authoring types without pulling the whole CLI in as a
dependency. See [CUBE-BUNDLES.md](CUBE-BUNDLES.md).
### Types
#### `Cube<Schema>`
@@ -76,6 +90,7 @@ interface Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
key: string; // Unique identifier
name: string; // Human-readable name
dir: string; // Absolute path to cube directory
source: CubeSource; // Where it was discovered
dependencies: string[];
schema: Schema;
defaults: () => z.infer<Schema>;
@@ -84,6 +99,18 @@ interface Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
}
```
#### `CubeSource`
Where a cube came from. Carried so that a duplicate-id error can name the origin
of each claimant, which is the difference between a usable error message and a
puzzle when the collision is between a local tree and an installed bundle.
```typescript
type CubeSource =
| { type: 'dir'; dir: string }
| { type: 'package'; packageName: string; dir: string };
```
#### `Manifest<Schema>`
Cube manifest (used in `manifest.mjs` files).
@@ -124,7 +151,9 @@ interface HookContext {
#### `loadCubes()`
Loads all cubes from discovered cube directories.
Loads all cubes from discovered cube directories`cubeDirs`, the directories
declared by every package in `cubePackages`, and any ancestor directory holding a
`.npcubes` marker.
```typescript
const { cubes, errors } = await loadCubes();
@@ -139,6 +168,27 @@ interface LoadResult {
}
```
`errors` is non-empty for a duplicate id, a manifest that fails to load, a
package in `cubePackages` that is not installed or declares no cubes, and a
`nopy.cubes` entry that is missing or points outside its package. Any of them
aborts the run — none is a silent skip.
#### `resolveCubePackages(refs)`
Resolves `CubePackageRef[]` to installed packages and their cube directories.
Called by `loadCubes()`; exported because the resolution failures are worth
testing on their own.
```typescript
const { packages, errors } = resolveCubePackages(config.cubePackages);
interface CubePackage {
name: string; // the name it was requested under
root: string; // absolute path to the package root
dirs: string[]; // absolute paths from its `nopy.cubes` field
}
```
#### `resolveDependencies(cubes, selectedCubeNames)`
Resolves all transitive dependencies for selected cubes.
@@ -452,11 +502,31 @@ Configuration file structure.
interface NopyConfig {
hosts: string[];
cubeDirs: string[];
cubePackages: CubePackageRef[];
env: EnvConfig;
log?: LogConfig;
}
```
#### `CubePackageRef`
A package named in `cubePackages`, paired with where it was named. In the config
file an entry is just a string (`"@bitsquare/cubes-core"`); `loadConfig()`
normalises it.
```typescript
interface CubePackageRef {
/** The package name, as written in the config. */
spec: string;
/** Directory of the config file that named it — resolution starts here. */
from: string;
}
```
`from` is what makes a package named in a parent config resolve against *that*
config's `node_modules`, not the working directory's. It is the same problem
`PATH_PROPERTIES` solves for relative `cubeDirs`.
#### `LogConfig`
Logging configuration.
+263
View File
@@ -0,0 +1,263 @@
# Cube bundles
How to package cubes as an npm package so other projects can install them, and
what changes once a cube lives in `node_modules` instead of in your own tree.
If you only want to *use* a published bundle, you need one line of config:
```json
{ "cubePackages": ["@bitsquare/cubes-core"] }
```
The rest of this document is for writing one.
- [What a bundle is](#what-a-bundle-is)
- [The package manifest](#the-package-manifest)
- [Writing the cubes](#writing-the-cubes)
- [Ids are claimed globally](#ids-are-claimed-globally)
- [An installed bundle is read-only](#an-installed-bundle-is-read-only)
- [How resolution actually works](#how-resolution-actually-works)
- [Publishing](#publishing)
- [Troubleshooting](#troubleshooting)
## What a bundle is
An ordinary npm package that ships cube directories and points at them from its
own `package.json`. There is no build step, no plugin API and no entry point —
nopy reads the directories off disk and imports each `manifest.mjs` directly.
```
@acme/cubes-web
├── package.json nopy.cubes → ["./cubes"]
├── README.md
└── cubes/
├── nginx/
│ ├── manifest.mjs
│ └── deploy.py
└── certbot/
├── manifest.mjs
└── deploy.py
```
`@bitsquare/cubes-core` in this repository is the worked example, and is consumed
by this repository through exactly the mechanism described here — it is not
special-cased.
## The package manifest
```json
{
"name": "@acme/cubes-web",
"version": "1.0.0",
"type": "module",
"nopy": { "cubes": ["./cubes"] },
"files": ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"],
"publishConfig": { "access": "public" },
"dependencies": {
"@bitsquare/nopy-cube": "^1.0.0",
"zod": "^4.4.3"
}
}
```
**`nopy.cubes`** is the only field nopy requires. It is an array of directories,
relative to the package root, each scanned recursively for cubes. Several
entries are fine; a single `["./cubes"]` is the norm. Every entry must exist and
must stay inside the package — a path escaping the root is refused, not resolved.
**`type: "module"`** matters: manifests are ESM. Without it a `manifest.mjs` still
loads (the extension carries the day), but anything it imports relatively will
not behave the way you expect.
**`files`** decides the tarball. Note the negation: a cube that has been run
leaves a `pyinfra-debug.log` next to its `deploy.py`, and `.gitignore` has no
effect on what npm packs. Check with `npm pack --dry-run` before publishing.
**Dependencies** are `@bitsquare/nopy-cube` and `zod`, both real dependencies
rather than peers — a bundle is a leaf, and the copies it gets are the copies its
manifests use. Do **not** depend on `@bitsquare/nopy`: the CLI is what installs
your bundle, not the other way round, and depending on it invites two copies of
the same code into one tree.
## Writing the cubes
A cube directory holds a manifest (`manifest.mjs` or `*.manifest.mjs`) and a
deploy script (`deploy.py` or `*.deploy.py`). Anything else in the directory is
invisible to the loader but readable from the script, which runs with the cube
directory as its working directory.
```javascript
// cubes/nginx/manifest.mjs
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default Manifest({
id: 'web:nginx',
name: 'Install and configure nginx',
dependencies: () => ['apt:essentials'],
secrets: ['TLS_KEY'],
schema: z.object({
SERVER_NAME: z.string().describe('Server name').default('example.com'),
TLS_KEY: z.string().describe('TLS private key (PEM)').default(''),
HTTP2: z.boolean().describe('Enable HTTP/2').default(true),
}),
});
```
```python
# cubes/nginx/deploy.py
from pyinfra import host
from pyinfra.operations import apt, files
SERVER_NAME = host.data.SERVER_NAME
apt.packages(name='Install nginx', packages=['nginx'], _sudo=True)
```
Import **`@bitsquare/nopy-cube`**, not `@bitsquare/nopy`. It is types and a
factory with zod as its only peer — no CLI, no prompts, no process spawning — so
your bundle stays a leaf. (`@bitsquare/nopy` re-exports the same surface as
`cubes.Manifest`, which is what older manifests use. It still works; it just
drags the CLI into your dependency graph if you declare it.)
Four things the schema is load-bearing for:
- **`.describe()` is the prompt label.** A field without one prompts with its raw
key.
- **`.default()` makes the field optional.** A field with no default is required,
and is re-prompted on replay if a session has no value for it.
- **Every schema key reaches pyinfra** as `--data KEY=value`, so `host.data.KEY`
is always defined. pyinfra parses the values itself: `"true"` arrives as a
bool, `"8080"` as an int.
- **`secrets` names keys whose values must not be persisted.** They are excluded
from session files and history, masked wherever a command is printed, and
re-prompted on replay. Naming a key that is not in the schema is a load error.
A secret is still visible in `ps` while pyinfra runs — masking covers nopy's
own output, not the process table — so treat it as protection against writing
credentials to disk, not as protection against a shared host.
`dependencies` is a function of the *collected* variables, so it can branch on
what the user actually answered, and it may pass parameters:
```javascript
dependencies: (v) => (v.HTTP2 ? ['apt:essentials', ['web:tls', { MODE: 'strict' }]] : []),
```
`before` / `after` hooks get a context whose `exec(id, vars)` pulls in any cube
by id, declared dependency or not. See [HOOKS.md](HOOKS.md).
## Ids are claimed globally
An id is claimed across every source at once — `cubeDirs`, `.npcubes` trees and
every installed bundle share one flat namespace. Two cubes claiming the same id
abort the run with an error naming both and where each came from.
There is no precedence and no shadowing, deliberately, in either direction: a
local cube does not quietly win over a packaged one, and installing a second
bundle cannot silently change what an existing id deploys. Overriding a cube from
a bundle is not a supported operation; fork the cube under your own id instead.
So prefix distinctly. `@acme/cubes-web` claiming `nginx` is asking for trouble the
first time someone installs a second bundle; `web:nginx` is not. Ids need not
mirror the directory layout — `cubes/network/tailscale` declares `net:tailscale`
— so the prefix is free.
An id is also the session key. Renaming one silently invalidates every recorded
session that used it, so treat a rename as a breaking change of the bundle.
## An installed bundle is read-only
Under pnpm, installed files are **hardlinked into a global store shared by every
project on the machine**. A cube that writes next to its own `deploy.py` does not
just dirty one `node_modules` — it corrupts that store for every other project.
Write to `/tmp`, to a path the user configured, or to the remote host. Never to
the cube's own directory. Files the cube needs to *read* (templates, config
fragments, systemd units) are fine and are exactly what the cube directory is for
`deploy.py` runs with it as the working directory, so `files.template('nginx.conf.j2', ...)`
resolves.
This is the one constraint that does not exist while the cubes live in your own
repo, which makes it the one most likely to be discovered late. Test against an
installed copy, not a linked one.
## How resolution actually works
Worth knowing, because two of the failure modes are otherwise baffling.
**Where a package is looked up from.** Each `cubePackages` entry is resolved from
the directory of the config file that named it, not from the working directory.
Configs merge upward, so a `.nopyrc.json` two levels up can name a bundle that
only exists in *its* `node_modules`, and it resolves. The lookup reads
`package.json` off disk via `createRequire(...).resolve.paths()` rather than
going through `exports` — a bundle ships directories and has no entry point to
declare.
**Why the loader does not simply scan `node_modules`.** It cannot: pnpm plants a
symlink at `node_modules/<name>`, and `readdir` reports it as a symlink, not a
directory, so a recursive scan skips every package silently. Naming packages
explicitly is the fix, and it is also the reason `node_modules` is skipped during
the cube scan itself.
**How a manifest finds its imports.** Ordinary Node resolution, from the
manifest's own directory. An installed bundle has its own `node_modules` with
`@bitsquare/nopy-cube` and `zod` in it, so this just works. A hand-written cube
sitting in a directory with no `node_modules` would historically fail with
`ERR_MODULE_NOT_FOUND`; nopy now registers a resolve hook that catches exactly
that case and falls back to resolving `@bitsquare/nopy-cube`, `@bitsquare/nopy`
and `zod` from the running CLI. Normal resolution is always tried first, so a
cube that ships its own zod keeps it. Treat the hook as a convenience for local
cubes — a published bundle must declare its dependencies properly.
**Two copies of zod is a real hazard.** `instanceof` comparisons fail across
copies, which is why nopy inspects schemas structurally (`schema.def.type`) and
why `secrets` is a plain array rather than `.meta()` metadata — zod's metadata
registry is per-copy, and a marker written into one copy's registry is invisible
to another. Keep your zod range compatible with the CLI's (`^4.4.3`) and the
package manager will usually give you one copy.
## Publishing
Nothing bundle-specific: `npm publish` (or `pnpm publish`) with a version bump.
Some things worth deciding once:
- **Version the bundle independently of nopy.** There is no compatibility check
between the two — the loader reads whatever `nopy.cubes` points at. Document
the nopy version you test against in your README.
- **Renaming or removing an id is breaking.** It invalidates recorded sessions
and breaks any manifest listing it as a dependency, including manifests in
other people's bundles.
- **Changing a schema key is breaking** in the same way; adding one with a
`.default()` is not.
- **Test the installed shape, not the linked one.** `npm pack`, install the
tarball into a throwaway directory with a `.nopyrc.json` naming it, and deploy
from it. This is what catches a missing file, a cube that writes to its own
directory, and an undeclared dependency — none of which show up while the
package is symlinked into the repo that wrote it.
For an unattended check, replay a session file rather than reaching for `-P`
alone, which still opens the interactive picker:
```sh
nopy install -l session.json -P -D
```
Note that a replay re-prompts for anything a manifest lists in `secrets` —
those are never written to a session — so pick a cube without them, or put the
values under `env` in `.nopyrc.json`.
For how this repository releases its own packages, see
[README.PUBLISH.md](../../../README.PUBLISH.md).
## Troubleshooting
| Symptom | Cause |
| --- | --- |
| `Cube package 'X' is not installed (looked up from …)` | Not installed, or installed somewhere other than the config that named it. The path in the message is where the lookup started. |
| `Cube package 'X' declares no cubes` | Missing or malformed `nopy.cubes` in the package's `package.json`. It must be a non-empty array of strings. |
| `'./cubes' does not exist in …` | The directory was not packed. Check `files` and `npm pack --dry-run`. |
| `'…' points outside the package` | A `nopy.cubes` entry escaping the package root. Not allowed. |
| `Duplicate cube id 'X' from N sources:` | Two or more cubes claiming one id; the message lists each source. Rename one — there is no precedence rule to lean on. |
| `ERR_MODULE_NOT_FOUND` for `zod` or `@bitsquare/nopy-cube` | The bundle did not declare them as dependencies. The resolve-hook fallback covers loose local cubes, not published packages. |
| `Invalid manifest in …: 'secrets' names X, which is not in the schema` | A `secrets` entry with no matching schema key — usually a typo or a renamed field. |
| Cubes work linked, fail installed | Almost always a write into the cube's own directory, or a file missing from `files`. |
+154 -45
View File
@@ -1,6 +1,11 @@
# Cube bundles as npm packages
Status: **Phase 0 has landed; Phases 16 are still a plan, not a record.**
Status: **All six phases have landed. This document is now a record, not a plan.**
The one thing still unproven is the publish lane against a real registry — see
*Risks*.
`cubePackages` resolves and loads end to end, `@bitsquare/nopy-cube` exists and
the publish lane can ship a linked package. What is missing is a bundle to point
`cubePackages` at.
Distributing cubes as npm packages so a project can `pnpm add @acme/cubes-net`
and have its cubes show up in `nopy` alongside local ones.
@@ -166,7 +171,7 @@ Rules:
- A bundle must not ship a `.nopyrc.json`. Config discovery walks up from
`process.cwd()`, never from cube directories, so it would never be read.
## Phase 2 — resolution
## Phase 2 — resolution — **done**
### Config surface
@@ -271,7 +276,7 @@ it is exported from `src/cubes/index.ts` and covered by tests. The
`node_modules` skip inside `scanDirectory` stays and is now *correct*: a
bundle's own `node_modules` should not be scanned.
## Phase 3 — hard errors with attribution
## Phase 3 — hard errors with attribution — **done**
`Cube` gains a source, as an optional fourth constructor parameter so the public
signature stays backwards compatible:
@@ -306,7 +311,7 @@ claim the same id they are mutually exclusive, and the fix is upstream.
Surface the source in the interactive picker and in `--json` output so a user can
see where a cube came from before running it.
## Phase 4 — `@bitsquare/nopy-cube`, the authoring package
## Phase 4 — `@bitsquare/nopy-cube`, the authoring package — **done**
The problem: a manifest does `import { cubes } from '@bitsquare/nopy'`, resolved
by ordinary Node resolution from the manifest's own directory. From inside
@@ -350,20 +355,33 @@ already coverage-excluded barrels — so `import { cubes } from '@bitsquare/nopy
in every existing manifest keeps working unchanged. Nothing in `cubes/` has to be
touched at migration time.
Repo plumbing this requires:
`cubes/types.ts` and `cubes/factories.ts` moved wholesale, with
`tests/cubes.types.test.ts` and `tests/cubes.factories.test.ts` behind them.
`tests/helpers/foreign-zod.ts` is duplicated rather than shared — fifteen lines,
and the alternative is a test-only dependency edge between the packages.
- `tsconfig.base.json`: add `"@bitsquare/nopy-cube": ["./packages/nopy-cube/src"]`
to `paths`.
- Root `tsconfig.json`: add the project reference.
- `packages/nopy/tsconfig.json`: `references` is currently `[]` — add
`{ "path": "../nopy-cube" }`. This is the first reference edge in the repo, so
`tsc --build` ordering starts mattering.
Repo plumbing it took:
- `tsconfig.base.json`: `"@bitsquare/nopy-cube": ["./packages/nopy-cube/src"]`.
- Root `tsconfig.json` and `packages/nopy/tsconfig.json`: the project reference.
This is the first reference edge in the repo, and it broke the gate
immediately: **`tsc --build --noEmit` is not legal once a project has
references** — TS6310, "referenced project may not disable emit", because a
composite project has to emit the declarations its dependents read. The root
`typecheck` script is now plain `tsc --build`. It still fails on a type error,
and it now also proves the build works; the cost is that it writes `dist`,
which is gitignored.
- `packages/nopy/package.json`: `"@bitsquare/nopy-cube": "workspace:*"`.
- A `vitest.config.ts` for the new package with the same thresholds. `Manifest()`,
`Manifest.create()` and `Cube.getDefaults()` all carry logic, so the relevant
cases move over from `tests/cubes.factories.test.ts`.
- `packages/nopy/vitest.config.ts`: a `resolve.alias` for `@bitsquare/nopy-cube`
pointing at `../nopy-cube/src/index.ts`. Without it the workspace link
resolves through `exports` to `dist`, so `pnpm test` on a clean checkout would
fail until something had built it, and a stale `dist` would silently be what
the tests ran against. The same config excludes `**/nopy-cube/**` from
coverage — the aliased files were being counted against nopy's thresholds.
- A `vitest.config.ts` for the new package with the same thresholds. It sits at
100 % statements/functions/lines, 91 % branches.
### The release lane needs fixing first
### The release lane needed fixing first
This is the part that is easy to miss. `link-workspace-packages` is unset and
pnpm 10+ defaults it to `false`, so a plain semver range would resolve
@@ -389,10 +407,40 @@ Pick one before publishing anything:
passes — compute every snapshot version first, then publish — so `nopy` can pin
the exact `nopy-cube` snapshot from the same run.
Recommendation: `pnpm publish`, and verify against the Gitea registry with a
**Measured, both directions.** `npm pack` in `packages/nopy` produces a tarball
whose manifest still reads `"@bitsquare/nopy-cube": "workspace:*"`; `pnpm pack`
produces one that reads `"1.0.0-alpha0"`. So the failure was real and the fix
works.
Went with `pnpm publish --ignore-scripts --no-git-checks` in both workflows.
`--no-git-checks` is not optional in either: `release.yml` runs on a detached
HEAD, and `publish-snapshot.yml` dirties the tree by stamping versions.
(`pnpm pack` has no `--ignore-scripts`, only `pnpm publish` does.)
Three small scripts carry the parts that are easy to get wrong, all runnable
locally:
- **`scripts/verify-pack.mjs`** — packs every publishable package and fails if a
`workspace:` range survived into the tarball. Runs between build and publish
in both workflows. Turns "npm would have shipped a broken manifest" from an
install-time surprise into a red run.
- **`scripts/publish-order.mjs`** — topologically sorts the publishable packages.
`packages/*/` alphabetically puts `nopy` ahead of the `nopy-cube` it depends
on; the snapshot workflow now iterates this instead.
- **`scripts/linked-deps.mjs`** — lists a package's workspace links as
`<name> <version>`, resolved by package name rather than by directory.
`release.yml` uses it to refuse a release whose linked dependency is not on
npmjs yet, which is the one mistake that cannot be taken back after 72 hours.
`publish-snapshot.yml` also became two passes over the packages: stamp every
version first, then publish. `pnpm publish` substitutes the version the linked
package declares *at pack time*, so `nopy-cube` has to be carrying its snapshot
version before `nopy` is packed.
Still unverified: none of this has run against the Gitea registry. Worth a
throwaway version before the first real release.
### Also: the resolve hook
### Also: the resolve hook — built
Independent of the split, and worth building anyway — it retires the
`ERR_MODULE_NOT_FOUND` gotcha CLAUDE.md documents for the local `cubes/` tree,
@@ -414,33 +462,48 @@ Falling back for `zod` hands local cubes the *CLI's* zod instance, so no
duplication arises there. Bundles are the case that duplicates it, and Phase 0.4
is what makes that safe.
New `packages/nopy/src/nopy.resolve-hook.mjs`, registered once from `loadCubes()`
`packages/nopy/src/cubes/resolve-hook.mjs`, registered once from `loadCubes()`
before the first `import(manifestPath)`:
```ts
module.register('./nopy.resolve-hook.mjs', import.meta.url, {
data: { fallback: import.meta.resolve('./index.js') },
});
module.register('./resolve-hook.mjs', import.meta.url, { data: { from: import.meta.url } });
```
The hook tries `next(specifier, ctx)` **first** and only falls back to the
running CLI's own copy on failure. That ordering matters: a consumer that has its
own `@bitsquare/nopy` installed keeps using it, so the hook never silently
introduces version skew.
`from` is a URL inside the running CLI's own package; the hook thread builds a
`createRequire` from it and resolves the fallbacks out of the CLI's own
dependencies.
Constraints:
The hook tries `next(specifier, ctx)` **first** and only falls back on failure.
That ordering matters: a consumer that has its own copy installed keeps using
it, so the hook never silently introduces version skew. There is a test for
exactly that — a stub `zod` beside the cube wins over the CLI's real one.
- `module.register()` is process-global and cannot be undone. Install it once,
behind a module-level guard.
Constraints, as built:
- `module.register()` is process-global and cannot be undone. Installed once,
behind a module-level guard, and wrapped in a `try` — the hook is a
convenience, so a registration failure must not abort a run.
- The hook file runs on a separate thread; the `data` payload must be
structured-cloneable (a string URL is).
- The `.mjs` must ship in `dist` and be listed in `files` — it already is, via
the `dist` entry.
- It resolves `@bitsquare/nopy` and `zod`, not `@bitsquare/nopy-cube`. Bundles
never depend on the hook; only the in-repo `cubes/` tree and hand-written local
cubes do.
- The `.mjs` has to reach `dist`, and `tsc` does not copy it: nopy's `build` is
now `tsc && cp src/cubes/*.mjs dist/cubes/`. `files` already covers it via the
`dist` entry.
- It covers **three** specifiers, not the two the plan named: `zod`,
`@bitsquare/nopy`, and `@bitsquare/nopy-cube` — a hand-written local cube is
as entitled to the new authoring package as to the old one. Subpaths count
(`@bitsquare/nopy/package.json`), anything else stays a hard failure.
## Phase 5 — proof of concept: `packages/cubes-core`
**The tests have to spawn a real `node`.** Written inside the vitest worker they
pass whether or not the hook is installed: vite resolves the dynamic import
itself and finds `zod` from the project root. `tests/cubes.resolve-hook.test.ts`
therefore runs each case in a child process, and the first case asserts the
*failure* without the hook so the rest cannot silently stop proving anything.
**Verified end to end.** From a plain `node` at the repo root, with nothing
linked, the built loader reads all 22 cubes under `cubes/` with zero errors. The
`ERR_MODULE_NOT_FOUND` gotcha in `CLAUDE.md` is retired.
## Phase 5 — proof of concept: `packages/cubes-core` — **done**
Depends on Phase 4 shipping first — the bundle cannot declare
`@bitsquare/nopy-cube` as a dependency until it exists, and the publish-lane fix
@@ -478,19 +541,50 @@ has to be in place before either package is published.
`nopy-cube` references from Phase 4 are separate.)
8. Biome already lints `cubes/**/*.mjs` from the root; only the path changes.
### Verifying the PoC
### What differed from the plan
- **In-workspace:** `pnpm --filter @bitsquare/nopy run nopy -P` from the repo
root lists `net:tailscale`, `apt:install`, … and prints deploy commands whose
`--chdir` points into `node_modules/@bitsquare/cubes-core/cubes/…`.
- **Out-of-workspace (the real test):** `npm pack` the bundle, install the
tarball into a throwaway directory with a `.nopyrc.json` naming it, install
`nopy` *globally*, and run `nopy -P`. This is what actually exercises Phase 4 —
a manifest resolving its import from a `node_modules` tree that has no
`@bitsquare/nopy` in it. Check the installed tarball's `package.json` really
carries a concrete `@bitsquare/nopy-cube` range and not `workspace:*`.
- **Step 2's optional migration was done.** All 22 manifests now import
`{ Manifest }` from `@bitsquare/nopy-cube`, not `{ cubes }` from
`@bitsquare/nopy`. Optional for correctness, but it is the only version of the
PoC that proves anything: leaving the old import in place would have resolved
through the CLI that happens to sit in the same tree.
- **`uniqid` had to move too.** Two manifests use it (`admin:hostname` bare,
`user:add` via `cubes.uniqid`), so `src/cubes/utils.ts` and its test went to
`nopy-cube` alongside `types.ts`, and `uniqid` joined the authoring barrel.
Otherwise one migrated manifest would still have been importing the CLI.
- **`files` needs a log exclusion.** Cubes that have been run leave a gitignored
`pyinfra-debug.log` next to `deploy.py`; gitignore does not filter an npm
tarball. `"files": ["cubes", "!cubes/**/*.log", …]` does. Verified: 22
manifests, 22 deploy scripts, 0 logs in the packed artefact.
- **`verify-pack.mjs` picks the bundle up for free** — it walks every non-private
`packages/*`, so `cubes-core`'s `workspace:*` edge is checked like nopy's.
## Phase 6 — documentation
### Verifying the PoC — done
- **In-workspace:** the built loader, run from the repo root against the new
root `.nopyrc.json`, reads 22 cubes with 0 errors and reports
`source: { type: 'package', packageName: '@bitsquare/cubes-core', dir:
'…/node_modules/@bitsquare/cubes-core/cubes' }` — the pnpm symlink path, not a
plain directory.
- **Out-of-workspace (the real test):** `pnpm pack` for `nopy-cube`, `nopy` and
`cubes-core`, then **`npm install`** of all three tarballs into a throwaway
directory with a `.nopyrc.json` naming only the bundle. npm is the strict test
here — it does not understand `workspace:`, so a leaked range fails the install
outright. It installed clean, and the installed
`@bitsquare/nopy/package.json` carries `"@bitsquare/nopy-cube":
"1.0.0-alpha0"`. `nopy install -l session.json -P -D` then resolved
`apt:essentials` and printed a `--chdir` into
`node_modules/@bitsquare/cubes-core/cubes/apt/essentials`. Since the loader
aborts on any manifest error and this run did not, all 22 manifests imported
`@bitsquare/nopy-cube` and `zod` successfully from a tree containing no
workspace links.
Note for anyone repeating this: `-P` on its own is interactive, and a replay
still prompts for anything a manifest declares in `secrets` (they are never
persisted to a session) — `net:tailscale` will sit there waiting. Use a
session file with a cube that has no secrets, or answer the prompt.
## Phase 6 — documentation — **done**
- `CLAUDE.md`: the repo table gains two rows (`packages/nopy-cube`,
`packages/cubes-core`) and loses the `cubes/` one; "The two packages do not
@@ -503,6 +597,21 @@ has to be in place before either package is published.
plus the ordering constraint — `nopy-cube` releases before anything that
depends on it.
Beyond the list: `CLAUDE.md` also needed the `typecheck` command corrected
(`tsc --build`, not `--noEmit` — see Phase 4), a note on the vitest source alias
and the coverage exclusion, and three entries under *Known drift*. `README.PUBLISH.md`
absorbed the whole publish-lane rework, not just the tag prefixes: `pnpm publish`
over `npm publish` and why, the two-pass version stamping, `verify-pack.mjs`,
`publish-order.mjs`, `linked-deps.mjs`, and a local rehearsal recipe that uses
**npm** to install the tarballs precisely because npm is the one that rejects a
leaked `workspace:` range.
One workflow change came out of writing this up: `ci.yml` now runs
`verify-pack.mjs` too. It was only in the two publish workflows, which means a
leaked range would have failed the release rather than the pull request that
introduced it — the wrong end of the process for a mistake that is free to catch
early.
## Testing
The coverage gate (85 % branches/functions, 80 % lines/statements, per package)
+26
View File
@@ -54,3 +54,29 @@ This document tracks the major refactoring of the `nopy` package.
- `VariableAssignment` offers every schema key, not only the ones carrying a default, and shows the value the run would actually use as the initial.
- **Proposed Solution**: (Done)
### 6. Make variable assignment a first-class concept
- **Status**: ✅ Completed
- **Goal**: Give a variable an identity and a provenance, instead of inferring both from which bag it happened to sit in.
- **Rationale**: Item 5 left precedence encoded as the field order of an object literal inside `Variables.get()``defaults`, then `global`, then `prompts`, then `params`. Nothing named the ranking, nothing could be asked where a value came from, and a replay had to be smuggled into the `prompts` bag because there was no origin that meant "recorded". Every question that followed — what should a session record, which values are safe to print — needed provenance to answer.
- **Context**:
- `Assignment { value, origin }` and an `Origin` ranked `default(0) < env(1) < session(2) < prompt(3) < param(4)`. Precedence is now data, not the order lines appear in.
- `Variable` is a class over an assignment list. `assignments` is the true history, newest first and never reordered; `ordered` is a *stable* sort of it by origin rank, and `value`/`origin` read the head of that. Stability is what makes the two views coexist: same-origin ties keep the newest in front while the value it displaced stays visible.
- The `global` bag is gone. Config `env` is seeded per cube as a real assignment at origin `env`, so `variables.get('global')` — a cube id that was never a cube — is no longer a thing.
- Replay assigns at origin `session`, which outranks `env` and `default` on its own. The `prompts`-bag workaround is deleted.
- A session records `Variables.persistable()` — every effective value, not just prompted ones. A `-D` run used to record nothing and replay by re-deriving from whatever the defaults said at replay time.
- **Trade-off accepted**: recorded values now outrank the current `.nopyrc.json` `env` and the current schema defaults, so editing either no longer leaks into an existing session's replay. That is the point of a snapshot, but it does mean picking up a new default requires re-recording.
- **Proposed Solution**: (Done)
### 7. Manifest-declared secrets
- **Status**: ✅ Completed
- **Goal**: Let a manifest say which schema keys hold sensitive values, and act on it.
- **Rationale**: Item 6 made sessions record everything, which forced the question of what must *not* be recorded. The codebase already had an answer of sorts — `outputExecutionPlan` masked any variable whose name contained "password" — that missed `TOKEN`, `PSK` and `AUTH_KEY`, and was defeated anyway by the unmasked command printed one line above it.
- **Context**:
- `Manifest.secrets?: string[]`, validated at load: an entry that is not a key of `schema` is a manifest error and aborts the run, so a typo cannot silently leave a value unprotected.
- Deliberately a plain array, not zod metadata. `.meta()` and `.describe()` store into `z.globalRegistry`, which is per-copy — a manifest built by a different zod copy would look up empty. Fail-open is fine for a missing prompt label and unacceptable for a secret marker.
- `maskCommand()` replaces declared `--data` values and the SSH `--password` in the command string itself, and is wired into `--print-only`, the dry-run plan and the debug log. The `nopy` logger runs at `lowestLevel: 'debug'`, so that last one was printing credentials on every run.
- Secrets are excluded from `persistable()`, so a replay has a gap where one used to be. `fillSessionGaps` prompts for `requiredKeys() secrets`; under `-D` it fails naming them, consistent with item 5's fail-fast.
- **Scope limit**: `secrets` keeps a value out of what nopy writes. The value is still on pyinfra's command line (visible in `ps`), still echoed by the variable form, and a `.default()` is still plain text in the manifest. Documented rather than fixed — the first is inherent to pyinfra's interface.
- **Bug fixed along the way**: `cubes/user/add` generated a random password as its schema `.default()`. Because the key had a default it was never in `requiredKeys()`, and because a generated default is re-evaluated on every read, an unattended run created an account with a credential nobody had seen and a replay created a different one again. It is now the literal `changeme`.
- **Proposed Solution**: (Done)
+15 -1
View File
@@ -294,8 +294,9 @@ export default {
2. **Document your cubes** - Add comments explaining what each cube does
3. **Use environment variables** - Make sessions reusable across environments
4. **Extract common config** - Share configuration across multiple sessions
5. **Version control** - Both formats work well with git
5. **Version control** - Both formats work well with git, but a recorded session holds every value its run used; read one before committing it
6. **Validate at runtime** - The loader validates the structure regardless of format
7. **Leave secrets out** - Declare them in the manifest instead, and let the replay ask
## Session Schema
@@ -321,3 +322,16 @@ interface AuthSession {
username?: string;
}
```
A session nopy *writes* holds, per cube, every value that cube ran with — what
was typed, what came from `.nopyrc.json`, what a dependency supplied, and what
fell through to the schema's `.default()`. Two things are deliberately absent and
are asked for again on replay: the SSH password, and any key the cube's manifest
listed under `secrets`.
A session you write by hand is under no such obligation — `variables` may hold as
few keys as you like, and anything missing resolves the usual way. Note that a
key declared a secret is prompted for whether or not the session carries a value:
writing one in only pre-fills the prompt, it does not skip it. The variable form
shows what it is editing, so a secret you put in a session file appears on screen
as well as on disk.