[fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`. |
|
||||
@@ -1,6 +1,11 @@
|
||||
# Cube bundles as npm packages
|
||||
|
||||
Status: **Phase 0 has landed; Phases 1–6 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user