improving documentation consistency. auditing documentation drifts. planning cube packaging

This commit is contained in:
Benjamin Diedrichsen
2026-07-27 21:58:54 +02:00
parent fcc181700e
commit 5ed68c0065
7 changed files with 1562 additions and 105 deletions
+141 -69
View File
@@ -6,15 +6,52 @@ A CLI tool that simplifies **pyinfra** script management and execution, providin
Nopy wraps pyinfra with structure, validation, and an interactive experience for managing complex infrastructure deployments. It organizes deployments into self-contained "cubes" with dependency management, schema validation, and lifecycle hooks.
## Features
- **Dependency resolution** with topological sorting
- **Before/after hooks** for multi-cube orchestration
- **SSH key or password authentication**
- **Default values** with optional customization via manifest `env`
- **Schema validation** using Zod
- **Recursive cube directory discovery**
- **Dry-run mode** for previewing deployments
- **JSON output** for CI/CD integration
- **Session history** with replay capability
## Workflow
1. **Load cubes** - Discovers and validates cubes from configured directories
2. **Interactive prompts** - Select cubes, target host, and authentication method
3. **Dependency resolution** - Topologically sorts cubes based on dependencies
4. **Variable assignment** - Validates and collects configuration with schema validation
5. **Execute hooks** - Runs before/after hooks for orchestration
6. **Deploy** - Sequentially executes pyinfra commands
## Core Concepts
### Cubes
Self-contained deployment units consisting of:
A cube is a **directory** containing two files:
- **Python deployment script**: `<cube-name>.deploy.py`
- **JavaScript manifest**: `<cube-name>.manifest.mjs` defining schema, dependencies, defaults, and hooks
- **Configuration variables**: Validated with Zod schemas
- **JavaScript manifest**: `manifest.mjs` defining schema, dependencies, defaults, and hooks
- **Python deployment script**: `deploy.py`, a plain pyinfra script
Configuration variables are declared in the manifest and validated with Zod schemas before the deployment script runs.
```
cubes/
├── .npcubes
└── apt/
└── install/
├── manifest.mjs
└── deploy.py
```
Any directory holding both files is treated as a cube, so cubes can be nested as deeply as you like to group them by topic. Discovery is recursive; directories starting with `.` and `node_modules` are skipped. Additional files in the cube directory (a `README.md`, config templates, and so on) are ignored by the loader and can be referenced from the deploy script — the script runs with its cube directory as the working directory.
The prefixed forms `<cube-name>.manifest.mjs` and `<cube-name>.deploy.py` are also still recognized, but plain `manifest.mjs` / `deploy.py` is the current convention.
A cube's identity comes from the manifest's `id` field (see below). If `id` is omitted, nopy falls back to an `[id]` prefix in the manifest `name`, and finally to the directory's own name. Note that the id does not have to mirror the folder path — `cubes/network/tailscale` declares `id: 'net:tailscale'`.
#### Cube Manifest
@@ -33,6 +70,36 @@ export default cubes.Manifest({
})
```
#### Deployment Script
The matching `deploy.py` is a plain pyinfra script. Nopy passes each schema variable to pyinfra as `--data KEY=value`, so they are available on `host.data`:
```python
from pyinfra import host
from pyinfra.operations import apt
UPDATE = host.data.UPDATE
PACKAGES = str(host.data.PACKAGES).split(' ')
apt.packages(
name='Install essential packages',
packages=['ca-certificates', 'gnupg', 'lsb-release'],
update=UPDATE,
_sudo=True,
)
apt.packages(
name='Install custom packages',
packages=[p.strip() for p in PACKAGES if p],
update=UPDATE,
_sudo=True,
)
```
Every key defined in the manifest `schema` is guaranteed to be present on `host.data` — either from the Zod `.default()`, from `.nopyrc.json`, from a dependency, or from a user prompt.
**Value types**: pyinfra parses `--data` values before your script sees them. `"true"` / `"false"` become booleans, numeric strings become `int`, valid JSON becomes the parsed structure, and everything else stays a string. This is why `UPDATE` can be handed straight to pyinfra's `update=` argument, while `PACKAGES` is wrapped in `str(...)` before splitting.
#### Variable Defaults
Variable defaults are defined directly in the Zod schema using `.default()`. This ensures that every cube has a predictable starting state and provides type-safe default values.
@@ -60,10 +127,19 @@ Uses `.nopyrc.json` files (project-level or home directory) containing:
"log": {
"verbosity": "info",
"debug": false
},
"history": {
"maxSessions": 10,
"autoSave": true
},
"execution": {
"continueOnError": false
}
}
```
`history` controls automatic session recording (see [Deployment History](#deployment-history)), and `execution.continueOnError` sets the default for `--continue-on-error`.
#### Logging Configuration
Control pyinfra output verbosity and debug information using the `log` configuration object:
@@ -84,39 +160,6 @@ Control pyinfra output verbosity and debug information using the `log` configura
| `false` | (none) | No debug logs (default) | Normal operation |
| `true` | `--debug` | Enable pyinfra debug logs | Deep debugging of pyinfra internals |
**Examples:**
Basic troubleshooting:
```json
{
"log": {
"verbosity": "info"
}
}
```
Debug command failures:
```json
{
"log": {
"verbosity": "trace"
}
}
```
Deep debugging with pyinfra internals:
```json
{
"log": {
"verbosity": "trace",
"debug": true
}
}
```
**Recommendation:** Start with `"info"` for typical troubleshooting, use `"trace"` when investigating command failures, and enable `debug: true` only when debugging pyinfra itself.
### Session Recording and Replay
@@ -264,11 +307,23 @@ nopy install -K
**Repeat last run**:
```bash
nopy install --repeat-last-run
nopy install --repeat-last
# or
nopy install -R
```
Every deployment is automatically recorded to a `.nopy.history.json` file in the current working directory, so the last run is always available to `-R` without having to pass `--save-session` first. The default retention is the 10 most recent sessions (configurable via `history.maxSessions`); use `nopy history` to list them and `nopy install -H <id>` to replay any one of them — see [Deployment History](#deployment-history).
The recording happens before the deploy commands run, so a **failed** deployment is recorded too — `-R` is the quick way to retry one after fixing the cause. Replaying a session with `-R` or `-H` does not itself create a new entry, so repeating never pushes the original run out of the list.
A run is *not* recorded when:
- `--dry-run` or `--no-history` is passed
- No cubes were selected, so there was nothing to deploy
- `history.autoSave` is set to `false` in `.nopyrc.json`
Because the history file is resolved against the current working directory, each project keeps its own history — running nopy from a different directory will not find the previous run. As with session files, passwords are never stored and are re-prompted on replay.
**Save session for replay**:
```bash
@@ -302,14 +357,6 @@ nopy install --dry-run
Shows the execution plan including commands, environment variables, and targets without running anything. Sensitive data is masked in output.
**Parallel execution**:
```bash
nopy install --parallel
```
Executes independent cubes in parallel using a dependency graph. Cubes are grouped into execution stages, with a default concurrency limit of 4.
**JSON output (for CI/CD)**:
```bash
@@ -323,17 +370,64 @@ Machine-readable JSON output for scripting and CI/CD integration.
```bash
nopy install --continue-on-error
# or
nopy install -c
```
Continue deploying remaining cubes even if one fails.
**View deployment history**:
**Default behaviour (fail-fast)**: without this flag, nopy stops at the first cube that fails. Cubes are deployed sequentially in dependency order, so the failing cube's output is the last thing you see — every cube still queued behind it is skipped entirely and is never attempted.
This is deliberate: because cubes are topologically sorted, a cube that fails is often a dependency of the ones after it, and continuing would deploy them onto a half-configured host.
Two consequences worth knowing:
- **Cubes that already succeeded are not rolled back.** The host is left in a partial state — the cubes before the failure are applied, the rest are not. Fix the cause and re-run; well-written cubes are idempotent, so re-applying the earlier ones is normally harmless.
- **Skipped cubes are not reported as failed.** They are simply absent from the results, so a summary of "3 successful, 1 failed" out of 6 cubes means the remaining 2 were never run.
Either way, the command exits with code `1` if any cube failed, which is what CI picks up. Use `--continue-on-error` when your cubes are genuinely independent and you would rather collect every failure in one run than stop at the first.
The default can be flipped for a project by setting `execution.continueOnError` in `.nopyrc.json`; the CLI flag takes precedence over it.
#### Deployment History
```bash
nopy history # List recent deployments
nopy history --json # Same list as JSON, including each recorded session
nopy install -H <id> # Replay a specific deployment by ID
nopy clear-history # Delete all recorded sessions
```
History is what makes [Repeat last run](#basic-commands) work, but it holds more than just the last deployment: every recorded run stays replayable until newer runs push it out. `nopy history` (alias `nopy h`) lists them newest first, with `` marking the entry that `-R` would replay:
```
Session History:
→ [1] 07/26/2026, 14:32 - apt:install, net:tailscale → root@web-01
ID: mdk3n1qx4a2fh
[2] 07/26/2026, 09:05 - apt:install → root@web-01
ID: mdk0zzp8b71cq
Total: 2 session(s)
```
Each entry records the selected cubes together with the variable values that were answered at the prompts, the target hosts, the authentication method, and the username — never the password. Pass an ID to `-H` to run that exact combination again:
```bash
nopy install -H mdk0zzp8b71cq
```
A replay is non-interactive: cube selection, host, and variable values all come from the entry, so nopy runs straight through without asking anything. The two exceptions are password authentication, which always re-prompts, and an entry with no recorded host, which falls back to the host picker.
Two things are worth knowing before relying on an older entry:
- **Recorded values are applied as defaults, not as a frozen snapshot.** If a cube's schema has gained a variable since the run was recorded, the replay neither prompts for it nor fails — the new variable quietly takes its Zod `.default()`. Global `env` values are likewise read from the *current* `.nopyrc.json` rather than from the entry.
- **A replay fails if a cube no longer exists.** Renaming or deleting a cube id makes every history entry that referenced it unreplayable: nopy logs `Cube from session not found` and then aborts with `Cube not found: <id>`.
The history lives in `.nopy.history.json` in the working directory and uses the same structure as a session file, so trimming the array by hand is a perfectly good way to prune it. It does contain the variable values that were entered, which is why it is listed in this repository's `.gitignore` — treat it like any other file holding deployment configuration. A corrupt or unreadable history file is treated as empty rather than raising an error, which looks exactly like a project that has never been deployed from.
For a run you want to keep indefinitely, don't rely on history — it rotates. Use `--save-session` to write it to a file you control (see [Session Recording and Replay](#session-recording-and-replay)).
### Development
**Run without building**:
@@ -348,28 +442,6 @@ npm run nopy
npm run debug
```
## Workflow
1. **Load cubes** - Discovers and validates cubes from configured directories
2. **Interactive prompts** - Select cubes, target host, and authentication method
3. **Dependency resolution** - Topologically sorts cubes based on dependencies
4. **Variable assignment** - Validates and collects configuration with schema validation
5. **Execute hooks** - Runs before/after hooks for orchestration
6. **Deploy** - Sequentially executes pyinfra commands
## Features
- **Dependency resolution** with topological sorting
- **Parallel execution** of independent cubes in stages
- **Before/after hooks** for multi-cube orchestration
- **SSH key or password authentication**
- **Default values** with optional customization via manifest `env`
- **Schema validation** using Zod
- **Recursive cube directory discovery**
- **Dry-run mode** for previewing deployments
- **JSON output** for CI/CD integration
- **Session history** with replay capability
## Documentation
- [Cube Hooks](docs/HOOKS.md) - Lifecycle hooks for dynamic orchestration
+21 -29
View File
@@ -41,7 +41,6 @@ const result = await nopy({
| `saveSession` | `string` | - | Path to save session file |
| `loadSession` | `string` | - | Path to load session for replay |
| `dryRun` | `boolean` | `false` | Show execution plan without running |
| `parallel` | `boolean` | `false` | Execute independent cubes in parallel |
| `continueOnError` | `boolean` | `false` | Continue after failures |
| `jsonOutput` | `boolean` | `false` | Output results as JSON |
@@ -87,7 +86,7 @@ interface Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
#### `Manifest<Schema>`
Cube manifest (used in `*.manifest.mjs` files).
Cube manifest (used in `manifest.mjs` files).
```typescript
interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
@@ -160,23 +159,14 @@ const order = resolveDependencies(cubes, ['apt-all']);
**Throws:** `Error` if cube not found or circular dependency detected
#### `buildExecutionStages(cubes, selectedCubeNames)`
Groups cubes into stages for parallel execution.
```typescript
const stages = buildExecutionStages(cubes, ['apt-all', 'docker']);
// Returns: [['apt:essentials'], ['apt-more', 'docker'], ['apt-all']]
```
**Returns:** `string[][]` - Array of stages
#### `createManifest(options)`
#### `cubes.Manifest(options)`
Factory function for creating cube manifests.
```typescript
export default createManifest({
import { cubes } from '@bitsquare/nopy';
export default cubes.Manifest({
name: 'My Cube',
dependencies: () => [['apt:essentials']],
schema: z.object({
@@ -185,6 +175,8 @@ export default createManifest({
});
```
`createManifest` and `manifest` are exported as equivalent aliases; `cubes.Manifest` is the documented form.
#### `uniqid(length?)`
Generates a random alphanumeric string.
@@ -239,8 +231,6 @@ Options for deployment execution.
```typescript
interface ExecutionOptions {
parallel?: boolean;
concurrency?: number;
continueOnError?: boolean;
dryRun?: boolean;
onProgress?: (result: ExecutionResult, completed: number, total: number) => void;
@@ -252,12 +242,11 @@ interface ExecutionOptions {
#### `executeDeployCalls(calls, options?)`
Executes an array of deployment calls.
Executes an array of deployment calls sequentially, in the order they were built.
```typescript
const results = await executeDeployCalls(calls, {
parallel: true,
concurrency: 4,
continueOnError: false,
onProgress: (result, completed, total) => {
console.log(`${completed}/${total}`);
},
@@ -581,9 +570,6 @@ nopy install -l ./my-session.json
# Dry run
nopy install -n
# Parallel execution
nopy install -p
# JSON output
nopy install -j
@@ -597,21 +583,27 @@ nopy install -c
### File Structure
A cube is a directory containing both a `manifest.mjs` and a `deploy.py`:
```
cubes/
└── my-cube/
├── my-cube.manifest.mjs
└── my-cube.deploy.py
├── manifest.mjs
└── deploy.py
```
Cube directories may be nested for grouping (`cubes/apt/install/`), and any extra files alongside the pair are available to the deploy script via relative paths.
The prefixed forms `<cube-name>.manifest.mjs` and `<cube-name>.deploy.py` are still recognized for backwards compatibility.
### Manifest Example
```javascript
// my-cube.manifest.mjs
import { createManifest } from '@bitsquare/nopy';
// manifest.mjs
import { cubes } from '@bitsquare/nopy';
import { z } from 'zod';
export default createManifest({
export default cubes.Manifest({
name: 'My Cube',
dependencies: () => [['apt:essentials']],
schema: z.object({
@@ -634,7 +626,7 @@ export default createManifest({
### Deploy Script Example
```python
# my-cube.deploy.py
# deploy.py
from pyinfra import host
from pyinfra.operations import apt, server
+529
View File
@@ -0,0 +1,529 @@
# Cube bundles as npm packages
Status: **plan, not a record.** Nothing here is implemented yet.
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.
## Goals
- A cube bundle is an ordinary npm package, publishable to npmjs or Gitea
through the existing release lanes.
- A consuming project opts into a bundle explicitly, by name, in `.nopyrc.json`.
- Existing manifests, dependency specs (`dependencies: () => ['apt:essentials']`)
and stored session history keep working untouched.
- The in-repo `cubes/` tree becomes the first published bundle, proving the path.
## Non-goals
- Automatic discovery of bundles from the dependency tree. Cubes run privileged
deploy scripts against real hosts; a transitive dependency contributing one
silently is a supply-chain hole. Opt-in per package, always.
- Namespacing or id rewriting. Ids stay flat and global (see *Decisions*).
- Version compatibility checks between a bundle and the `nopy` running it.
Noted as a risk, deferred.
## Decisions
| Question | Decision |
| --- | --- |
| Duplicate cube ids across sources | **Hard error.** No precedence, no shadowing. Mitigation is a good error message, not a fallback. |
| Id format | Unchanged, flat. The id is the session key (`dependencies.ts:135`); changing it breaks `--repeat-last` and `--history`. |
| Discovery | Explicit `cubePackages` list in `.nopyrc.json`. |
| Migrate in-repo `cubes/` | Yes — `packages/cubes-core`, as the proof of concept. |
| Split an authoring package (`@bitsquare/nopy-cube`) | **Yes.** Bundles take a regular dependency on it; `@bitsquare/nopy` re-exports it for backwards compatibility. See *Phase 4*. |
## Current state
What already works, unchanged:
- `--chdir <cubeDir>` (`nopy.executor.ts`) means a `deploy.py` under
`node_modules` runs fine; pyinfra only needs the path.
- `scanDirectory` skips `node_modules` when *descending* (`loader.ts:101`), not
for the root it is handed. So `"cubeDirs": ["./node_modules/@acme/cubes-net/cubes"]`
works today. That is the escape hatch until this lands, and it stays working
afterwards.
What blocks a clean story:
1. **Manifest imports.** `manifest.mjs` does `import { cubes } from '@bitsquare/nopy'`,
resolved by ordinary Node resolution from the manifest's own directory. From
inside `node_modules/@acme/cubes-net/`, that resolves upward into the
consumer's `node_modules` — fine if the consumer installed `@bitsquare/nopy`,
`ERR_MODULE_NOT_FOUND` if `nopy` is only installed globally. Same gotcha
CLAUDE.md already documents for the local `cubes/` tree.
2. **No way to name a package** in config, only paths.
3. **Recursively scanning `node_modules` is not a workaround.** pnpm symlinks
direct deps, and `readdir(withFileTypes)` reports a symlink as
`isSymbolicLink()`, not `isDirectory()` — the scan would skip every package.
Package roots must be resolved explicitly.
## Phase 0 — fixes that land first
Independent of packaging, and the duplicate-id work depends on them.
**0.1 `scanDirectory` drops subtrees on duplicates.** `loader.ts:84-87` pushes
the error and `return`s, which exits before the recursive descent at line 100.
Cubes nested below a duplicate never get scanned, so the error report is
incomplete: you fix one collision, re-run, find the next. Should record the
duplicate and keep descending.
**0.2 Duplicate detection is order-dependent.** `loadCubes()` runs
`Promise.all` over folders into a shared `cubes` object, so which source is
"first" and which is "the duplicate" varies run to run. Restructure: the scan
emits a flat list of candidates, then a single grouping pass builds `cubes` and
the error list. Makes the hard-error path deterministic, which the tests need.
**0.3 `apt:essentials` is already declared twice.** `cubes/apt/essentials`
declares it via `id`; `packages/nopy/cubes/apt/essentials` declares it via the
`[apt:essentials]` prefix in `name`. `cubeDirs` merges root-first, so running
`nopy` from `packages/nopy` already collects both and errors. Rename the
`packages/nopy/cubes` fixtures (`[test:apt-essentials]`, `[test:apt-all]`,
`[test:apt-more]`) — they are dev fixtures, not real cubes, and the migration in
Phase 5 makes the collision permanent otherwise.
**0.4 `coerceValue` breaks if zod is ever duplicated.** `nopy.prompts.ts:147-154`
discriminates with `instanceof z.ZodDefault`, `z.ZodBoolean`, `z.ZodNumber` and
friends — checks against the *running CLI's* zod instance. The moment a bundle
resolves its own copy of zod (entirely possible once manifests arrive from
`node_modules`; see Phase 4), every check returns false and `coerceValue` falls
through to the raw string, silently. Booleans stop being booleans.
Rewrite against the string discriminant, which is instance-agnostic. Verified on
the installed zod 4.4.3:
```
z.boolean().default(false).def.type → 'default'
z.boolean().default(false).def.innerType → { def: { type: 'boolean' } }
z.number().def.type → 'number'
```
Do this before anything else in Phase 4 lands, and it stops being a footgun for
the local `cubes/` tree too.
## Phase 1 — the bundle contract
A cube bundle is an npm package with a `nopy` field:
```json
{
"name": "@acme/cubes-net",
"version": "1.0.0",
"type": "module",
"nopy": { "cubes": ["./cubes"] },
"files": ["cubes", "README.md", "LICENSE"],
"keywords": ["nopy", "nopy-cubes", "pyinfra"],
"dependencies": {
"@bitsquare/nopy-cube": "^1.0.0",
"zod": "^4.4.3"
},
"publishConfig": { "access": "public" }
}
```
Rules:
- `nopy.cubes` — directories relative to the package root, scanned exactly like
`cubeDirs` entries. Required; a package listed in `cubePackages` without a
`nopy` field is an error, not a silent skip. Listing it means the user expects
cubes from it.
- Both dependencies are **regular dependencies, not peers**, and both are
load-bearing: a manifest imports `Manifest` from `@bitsquare/nopy-cube` and `z`
from `zod`. `@bitsquare/nopy-cube` peer-depends on zod, so the bundle's copy is
the one everybody uses — see Phase 4.
- The package needs no `exports` entry for this to work — resolution reads
`package.json` off disk (Phase 2), so the `exports` map is irrelevant.
- **A bundle's directory is read-only at runtime.** Under pnpm, `node_modules`
content is hardlinked into the global store; a cube writing next to its own
`deploy.py` corrupts that store for every project on the machine. Cubes must
write to `/tmp` or the remote host, never their own dir.
- 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
### Config surface
```json
{
"cubePackages": ["@acme/cubes-net", "@acme/cubes-caddy"]
}
```
Merges through the existing `resolution` machinery for free — arrays concat and
dedupe — so a parent config supplies the org baseline and a child adds to it.
**Resolution origin.** A package must be resolved from *the directory of the
config file that declared it*, not from `process.cwd()`. Otherwise a bundle
listed in `~/.nopyrc.json` cannot resolve unless every project happens to depend
on it. This is the same problem `PATH_PROPERTIES` solves for `cubeDirs`, but the
output is a tagged reference rather than a rewritten string:
```ts
export interface CubePackageRef {
spec: string; // '@acme/cubes-net'
from: string; // dirname of the .nopyrc.json that declared it
}
```
So the file format and the loaded format diverge for this one key:
```ts
interface NopyConfigFile extends Omit<Partial<NopyConfig>, 'cubePackages'> {
cubePackages?: string[];
resolution?: ResolutionConfig;
}
interface NopyConfig {
cubePackages: CubePackageRef[];
// ...
}
```
`resolveConfigPaths()` performs the `string → CubePackageRef` conversion, next to
where it resolves `PATH_PROPERTIES`. Two consequences to handle:
- `mergeValue`'s array dedupe only fires when every element is a primitive
(`config.ts:142`), so refs fall through to plain concat. Dedupe by `spec` in
the resolver instead.
- Dedupe is **last-wins**: merge order is root-first, so the last occurrence is
the most specific config, and its `from` is the right resolution origin.
### Resolver
New file `packages/nopy/src/cubes/packages.ts`:
```ts
export interface CubePackage {
name: string;
root: string;
dirs: string[]; // absolute, from nopy.cubes
}
export function resolveCubePackages(
refs: CubePackageRef[]
): { packages: CubePackage[]; errors: string[] };
```
Locate the package root without going through `exports` and without tripping on
pnpm symlinks:
```ts
const req = createRequire(path.join(ref.from, 'noop.js'));
for (const dir of req.resolve.paths(ref.spec) ?? []) {
const manifest = path.join(dir, ref.spec, 'package.json');
if (fs.existsSync(manifest)) return path.dirname(manifest);
}
```
`resolve.paths()` walks the `node_modules` chain upward from `ref.from` plus the
global paths; `existsSync` follows symlinks, so pnpm's
`node_modules/@acme/cubes-net → ../.pnpm/…` resolves correctly.
Errors (each aborts the run, consistent with the existing `errors` contract):
- package not found on any candidate path
- `package.json` unparseable
- no `nopy.cubes`, or it is not a non-empty array of strings
- a `nopy.cubes` entry escapes the package root, or does not exist
### Wiring
`findCubeDirectories()` currently returns `string[]`. It becomes the union of
three sources, each tagged so the loader can attribute a cube to it:
```ts
export type CubeRoot =
| { type: 'dir'; dir: string } // cubeDirs, .npcubes markers
| { type: 'package'; dir: string; packageName: string }; // cubePackages
export function findCubeRoots(): { roots: CubeRoot[]; errors: string[] };
```
Keep `findCubeDirectories()` as a thin wrapper returning `roots.map(r => r.dir)`
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
`Cube` gains a source, as an optional fourth constructor parameter so the public
signature stays backwards compatible:
```ts
export type CubeSource =
| { type: 'dir'; dir: string }
| { type: 'package'; packageName: string; dir: string };
class Cube {
constructor(
manifest: Manifest<Schema>,
dir: string,
deployScript: string,
source: CubeSource = { type: 'dir', dir }
) {}
}
```
The duplicate error carries both sources and is order-independent (Phase 0.2):
```
Duplicate cube id 'apt:essentials' from 2 sources:
package @bitsquare/cubes-core /…/node_modules/@bitsquare/cubes-core/cubes/apt/essentials
directory /repo/packages/nopy/cubes/apt/essentials
Rename one of them, or remove a source from .nopyrc.json.
```
There is deliberately no override, alias or precedence rule. If two bundles ever
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
The problem: a manifest does `import { cubes } from '@bitsquare/nopy'`, resolved
by ordinary Node resolution from the manifest's own directory. From inside
`node_modules/@acme/cubes-net/`, that only resolves if the consumer installed
`@bitsquare/nopy` locally — a globally-installed CLI leaves nothing to find.
The fix is to give bundles something they can depend on *normally*, so resolution
is plain, boring, spec-compliant Node with no loader tricks in the critical path.
### The package
`packages/nopy-cube` — the `Manifest` factory, the `Cube` class, and the types
from `cubes/types.ts`. No CLI, no `execa`, `inquirer`, `enquirer`, `zx`, or
`commander`. Today a cube manifest — a file that ships nothing but data — drags
the entire CLI in as a transitive dependency; this makes the authoring surface
honest about how small it is, and gives the *contract* a version number that
moves independently of the CLI's.
```json
{
"name": "@bitsquare/nopy-cube",
"version": "1.0.0-alpha0",
"type": "module",
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
"peerDependencies": { "zod": "^4.4.3" },
"files": ["dist", "README.md", "LICENSE"]
}
```
**zod is a peer, deliberately.** Bundles declare zod as a regular dependency, so
exactly one zod instance serves the manifest, the schema it builds, and the
`Manifest` factory. Phase 0.4 removes the CLI's `instanceof` dependence on that
being the *same* copy the CLI uses, but keeping the bundle side single-instance
is still the right default.
### Moving `cubes/types.ts`
`@bitsquare/nopy` re-exports everything from `@bitsquare/nopy-cube` — through
`src/cubes/index.ts` and the `cubes` namespace in `src/nopy.cubes.ts`, both
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:
- `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.
- `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`.
### The release lane needs 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
`@bitsquare/nopy-cube` from the registry instead of linking the workspace copy —
the dependency has to use `workspace:*`.
But **both workflows publish with `npm publish`**, and npm does not understand
the `workspace:` protocol. `@bitsquare/nopy` would ship a manifest carrying
`"@bitsquare/nopy-cube": "workspace:*"`, which fails on install with
`EUNSUPPORTEDPROTOCOL`. This has never mattered because the two current packages
do not depend on each other; `nopy → nopy-cube` is the first edge, and the PoC
bundle in Phase 5 adds a second.
Pick one before publishing anything:
- **Switch to `pnpm publish --no-git-checks`**, which rewrites the protocol to a
concrete version on pack. Cleanest, but changes the publish step in both
workflows and pulls in pnpm's own lifecycle behaviour.
- **Rewrite the range with `npm pkg set` before publishing**, extending the
pattern `publish-snapshot.yml` already uses for `version`. In `release.yml` one
package ships at a time, so it pins to whatever version `packages/nopy-cube/package.json`
declares at that commit. In `publish-snapshot.yml` the loop needs to become two
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
throwaway version before the first real release.
### Also: the resolve hook
Independent of the split, and worth building anyway — it retires the
`ERR_MODULE_NOT_FOUND` gotcha CLAUDE.md documents for the local `cubes/` tree,
where manifests import `@bitsquare/nopy` from a directory that has no link to it.
With the split, the hook is a convenience rather than load-bearing: bundles
resolve `@bitsquare/nopy-cube` through their own `node_modules` and never reach
it.
New `packages/nopy/src/nopy.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') },
});
```
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.
Constraints:
- `module.register()` is process-global and cannot be undone. Install it once,
behind a module-level guard.
- 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`, not `@bitsquare/nopy-cube`. Bundles never depend
on the hook; only the in-repo `cubes/` tree and hand-written local cubes do.
## Phase 5 — proof of concept: `packages/cubes-core`
Depends on Phase 4 shipping first — the bundle cannot declare
`@bitsquare/nopy-cube` as a dependency until it exists, and the publish-lane fix
has to be in place before either package is published.
1. `git mv cubes packages/cubes-core/cubes` — preserves per-file history.
2. Add `packages/cubes-core/package.json` per the Phase 1 contract. Version
`1.0.0-alpha0`, tracking the current alpha train. Not private. Its
`@bitsquare/nopy-cube` dependency uses `workspace:*` in the repo, which is
exactly the case the Phase 4 publish fix has to handle.
Migrating the manifests' `import { cubes } from '@bitsquare/nopy'` to
`import { Manifest } from '@bitsquare/nopy-cube'` is optional — the re-export
keeps the old form working — but doing it here is what proves the bundle
resolves without the CLI present at all.
3. Root `.nopyrc.json`: **replace** `"cubeDirs": ["./cubes"]` with
`"cubePackages": ["@bitsquare/cubes-core"]`. Replace, not add — keeping both
means every id resolves from two sources and the hard error fires on every
run.
4. Root `package.json`: add `"@bitsquare/cubes-core": "workspace:*"` to
`devDependencies`, so pnpm symlinks it into the root `node_modules`. This is
what makes the PoC exercise the real pnpm symlink resolution path rather than
a plain directory.
5. `packages/nopy/.nopyrc.json` keeps `"cubeDirs": ["./cubes"]` for its fixtures.
Config merges root-first, so running from `packages/nopy` now pulls in
`@bitsquare/cubes-core` *and* the fixtures — which is exactly the collision
Phase 0.3 renames away.
6. Workflow changes are limited to the publish-lane fix from Phase 4.
`publish-snapshot.yml` loops `for dir in packages/*/` and picks both new
packages up automatically; `release.yml` resolves `packages/<pkg>` from the
tag, so `cubes-core-v1.0.0` and `nopy-cube-v1.0.0` work as-is. Verify on the
first snapshot run that a package with no `build` script is skipped cleanly by
`pnpm -r run build` (it is) and that publishing is happy with no lifecycle
scripts.
7. No `tsconfig` reference for `cubes-core` — the bundle has no TypeScript. (The
`nopy-cube` references from Phase 4 are separate.)
8. Biome already lints `cubes/**/*.mjs` from the root; only the path changes.
### Verifying the PoC
- **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:*`.
## Phase 6 — documentation
- `CLAUDE.md`: the repo table gains two rows (`packages/nopy-cube`,
`packages/cubes-core`) and loses the `cubes/` one; "The two packages do not
depend on each other" is no longer true; the loader section in *nopy
architecture*; and the *Gotcha* paragraph, which the resolve hook retires.
- `packages/nopy/docs/CUBE-BUNDLES.md` (new): authoring guide — package shape,
read-only constraint, id collision policy, publishing.
- `packages/nopy/docs/API.md` + `README.md`: `cubePackages`.
- `README.PUBLISH.md`: `nopy-cube-v*` and `cubes-core-v*` as new tag prefixes,
plus the ordering constraint — `nopy-cube` releases before anything that
depends on it.
## Testing
The coverage gate (85 % branches/functions, 80 % lines/statements, per package)
is not a CI flag — new modules without tests fail the gate locally and on the
runner alike.
`tests/cubes.packages.test.ts` (new) — build a fake `node_modules` tree under
`os.tmpdir()` and `chdir` into it, as the existing loader/config tests do:
- resolves a scoped and an unscoped package
- resolves through a symlinked package directory (mimicking pnpm)
- resolves from the declaring config's directory, not `cwd`
- missing package → error naming the spec
- package without `nopy.cubes` → error
- `nopy.cubes` entry that does not exist, and one that escapes the root → errors
- last-wins dedupe when parent and child config both name a package
`tests/cubes.loader.test.ts` — package-sourced cubes load; `source` attribution
is correct for all three root types.
`tests/cubes.loader.edge.test.ts` — duplicate across a dir and a package errors
and names both; cubes nested below a duplicate still get scanned (Phase 0.1);
the error is identical regardless of scan order (Phase 0.2).
`tests/config.test.ts``cubePackages` merge, `override` resolution strategy,
`CubePackageRef` provenance.
`tests/prompts.test.ts``coerceValue` against schemas built by a *different*
zod instance, so Phase 0.4 cannot silently regress to `instanceof`.
`packages/nopy-cube/` — its own `vitest.config.ts` at the same thresholds. The
`Manifest()` / `Manifest.create()` / `Cube.getDefaults()` cases move over from
`tests/cubes.factories.test.ts`; what stays behind is whatever tests the
re-export surface.
Resolve hook — `module.register()` is process-global, so this cannot be unit
tested in-process. Add an integration test that spawns the CLI as a child process
against a fixture tree, under the existing `test:integration` script.
## Risks
1. **`module.register()` is irreversible and process-wide.** It affects
everything loaded afterwards, including the CLI's own lazy imports. Guarded
single install, `next()`-first ordering.
2. **Hard-error duplicates have no escape hatch.** Two bundles claiming one id
cannot be used together, full stop. If that bites in practice the follow-up is
a `cubeAliases` map or a per-package id prefix — explicitly out of scope here.
3. **Store corruption.** A bundled cube writing to its own directory damages the
pnpm global store for every project on the machine. Documented in Phase 1;
a runtime warning is a possible follow-up.
4. **No version compatibility check.** A bundle authored against a future `nopy`
loaded by an older one fails at manifest-import time with a confusing error.
A `nopy.engines` field checked at resolution time would fix it. Deferred.
5. **Bundles vendoring cubes in their own `node_modules`** will not be found, by
design.
6. **`workspace:*` escaping into a published manifest.** The failure is silent at
publish time and only shows up when someone installs the package. Phase 4
fixes the lane; a `postpack` assertion that no dependency range starts with
`workspace:` would make it impossible to regress.
7. **Three packages, three version lines.** `nopy-cube` is the contract, so a
breaking change there ripples to every published bundle in the wild — which is
the point of versioning it separately, but it means the compatibility question
from risk 4 gets more pressing, not less.
+3 -7
View File
@@ -51,19 +51,15 @@ Hooks can be synchronous or asynchronous (returning a `Promise`).
## Mechanics
### Sequential Execution
### Execution Order
In sequential execution mode (the default), cubes added via hooks will follow the order in which they were pushed to the deployment plan:
Cubes are always deployed sequentially, in the order they were pushed to the deployment plan. For a cube with hooks that means:
1. Cubes from `before` hooks.
2. The current cube itself.
3. Cubes from `after` hooks.
### Parallel Execution
In parallel execution mode, cubes added via hooks **do not automatically inherit dependencies**.
If a `before` hook calls `exec('setup-cube')`, it ensures that `setup-cube` is placed earlier in the deployment plan, but for parallel execution, you should still ensure that dependencies are correctly specified if one cube relies on another's completion.
Because a `before` hook only places its cube *earlier in the plan*, it guarantees ordering but not much else — if the relationship is a real dependency rather than a one-off ordering nudge, declare it in `dependencies` so it is resolved and deduplicated like any other.
### Variable Passing
+2
View File
@@ -7,10 +7,12 @@ This document tracks the major refactoring of the `nopy` package.
### 1. Remove parallel execution
- **Status**: ✅ Completed
- **Goal**: Remove all logic supporting parallel execution of cubes to simplify the execution flow and improve reliability.
- **Rationale**: The feature never shipped. Concurrent pyinfra processes interleave their output, which made deployment logs unreadable — a cost that outweighed the wall-clock saving. Do not reintroduce it without first solving per-cube output buffering.
- **Context**:
- Parallelism removed from `NopyConfig`, `NopyOptions`, and `executeDeployCalls`.
- `buildExecutionStages` deleted.
- CLI flags `--parallel` and `--concurrency` removed.
- Documentation caught up later: `README.md`, `docs/API.md`, and `docs/HOOKS.md` had all continued to describe the feature as if it existed.
- **Proposed Solution**: (Done)
### 2. Rework cube building process & Dependency Resolution