Files
ansiblings/packages/nopy/docs/API.md
T
2026-07-29 11:16:52 +02:00

1192 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Nopy API Reference
The public surface of **`@bitsquare/nopy`** (the CLI and its library exports) and
of **`@bitsquare/nopy-cube`** (the authoring package a `manifest.mjs` imports).
Everything below was checked against the source. Where the code does something a
reader would not expect — a field that is always empty, a function nothing calls
— it is documented as it behaves, not as it reads. See
[Known gaps](#known-gaps) for the short list of those.
If you are writing cubes rather than calling nopy from code, you want
[CUBE-BUNDLES.md](CUBE-BUNDLES.md) and [HOOKS.md](HOOKS.md); only the
[Authoring API](#authoring-api-bitsquarenopy-cube) section here applies to you.
---
## Table of Contents
- [Two packages](#two-packages)
- [Authoring API (`@bitsquare/nopy-cube`)](#authoring-api-bitsquarenopy-cube)
- [Main Module](#main-module)
- [Cubes Module](#cubes-module)
- [Variables Module](#variables-module)
- [Executor Module](#executor-module)
- [Workflow Module](#workflow-module)
- [Session Module](#session-module)
- [History Module](#history-module)
- [Config Module](#config-module)
- [Prompts Module](#prompts-module)
- [Update Module](#update-module)
- [CLI Usage](#cli-usage)
- [Creating a Cube](#creating-a-cube)
- [Known gaps](#known-gaps)
---
## Two packages
| Package | Contains | Depends on |
| --- | --- | --- |
| `@bitsquare/nopy-cube` | `Manifest`, `Cube`, `Hook`, `uniqid`, the zod helpers | zod (peer) |
| `@bitsquare/nopy` | the CLI, the loader, config, sessions, execution | `@bitsquare/nopy-cube` |
A `manifest.mjs` should import from **`@bitsquare/nopy-cube`**: it is types and a
factory with no CLI, no prompts and no process spawning, so a cube bundle can
depend on it without pulling the whole tool into its dependency graph.
```javascript
import { Manifest } from '@bitsquare/nopy-cube'; // prefer this
import { cubes } from '@bitsquare/nopy'; // cubes.Manifest — still supported
```
`@bitsquare/nopy` re-exports the entire authoring surface, so both forms work and
older manifests keep loading. The `cubes` namespace object
(`src/nopy.cubes.ts`) is marked `@deprecated` and exists only for that
compatibility; it also carries a `cubes.load` alias for `loadCubes`.
---
## Authoring API (`@bitsquare/nopy-cube`)
### `Manifest(opts)`
Factory for a cube manifest. `name` is the only required option; everything else
is filled in.
```javascript
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default Manifest({
id: 'apt:essentials',
name: 'Install essential apt packages',
secrets: [],
dependencies: (vars) => (vars.WITH_BUILD_TOOLS ? ['apt:build'] : []),
schema: z.object({
PACKAGES: z.string().default('curl,git').describe('Comma-separated packages'),
}),
});
```
Defaults applied by the factory: `id: ''`, `schema: z.object({})`,
`secrets: []`, `before: []`, `after: []`, `dependencies: undefined`.
`createManifest` and `manifest` are exported as identical aliases.
`ManifestFactory` is a third alias, marked `@deprecated` — and it is not
re-exported through `@bitsquare/nopy`, so it is only reachable from
`@bitsquare/nopy-cube` directly.
#### `Manifest<Schema>` (interface)
```typescript
interface Manifest<Schema extends AnyObjectSchema = AnyObjectSchema> {
/** Unique identifier, used for dependency references and as the session key. */
id: string;
/** Human-readable name, shown in the picker. */
name: string;
/** Zod object schema for the cube's variables. */
schema: Schema;
/** Schema keys whose values must never be persisted or printed. */
secrets?: string[];
/** Dependencies, computed from the *collected* variables. */
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
before?: Hook<Schema>[];
after?: Hook<Schema>[];
}
```
`dependencies` is a **function**, not an array: it runs after the cube's
variables have been collected, so it can branch on what the user actually
answered.
`secrets` is a plain array rather than schema metadata on purpose. `.meta()` and
`.describe()` store into zod's global registry, which is per-copy — a manifest
that built its schema with its own copy of zod would write the marker somewhere
this process cannot read it. A missed `.describe()` costs an ugly prompt label; a
missed secret marker writes a password to disk, so this one cannot be allowed to
fail open. An entry naming a key that is not in the schema is a load error.
### `Cube<Schema>`
A loaded cube: its manifest, where it lives, and how it got into the run. This is
a **class**, constructed by the loader; the manifest's fields stay on
`.manifest` rather than being flattened onto it.
```typescript
class Cube<Schema extends AnyObjectSchema = AnyObjectSchema> {
constructor(
manifest: Manifest<Schema>,
dir: string, // absolute path to the cube directory
deployScript: string, // filename, e.g. 'deploy.py' — not a path
source?: CubeSource, // defaults to { type: 'dir', dir }
);
get id(): string; // manifest.id
get name(): string; // manifest.name
get secrets(): string[]; // manifest.secrets ?? []
getDefaults(): z.infer<Schema>;
requiredKeys(): string[];
isSecret(key: string): boolean;
}
```
`getDefaults()` parses `{}` against the schema, which resolves every default in
one pass — but that throws as soon as one field has no `.default()`. It then
falls back to a per-field read so the defaults that *are* declared survive; a
single required field used to leave the cube with no variables at all.
`requiredKeys()` returns the keys nothing can fill in on its own: no `.default()`
and not optional. A `--use-defaults` run that cannot supply one aborts by name
rather than deploying the cube with the value missing.
### `CubeSource`
Where a cube came from. Carried because a cube's directory does not say how it
got into the run — `/…/node_modules/@acme/cubes-net/cubes/x` could equally have
come from a `cubeDirs` entry pointing straight at it. It is what makes a
duplicate-id error legible 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 };
```
The cube picker also uses it: a cube from a package is labelled
`id - name (@acme/cubes-net)`, and because the fuzzy filter matches on the label,
typing a package name narrows the list to that bundle.
### `Hook<Schema>` and `HookContext`
```typescript
type Hook<Schema extends AnyObjectSchema> = (
ctx: HookContext,
variables: z.infer<Schema>
) => void | Promise<void>;
interface HookContext {
/** Pulls another cube into the run, optionally passing it variables. */
exec: (key: string, variables: CubeVariables) => Promise<void> | void;
}
```
`exec()` re-enters the resolver, so a hook can pull in a cube that is not a
declared dependency. The `variables` argument is the cube's *effective* values,
not schema-validated output — see [Known gaps](#known-gaps). Full semantics in
[HOOKS.md](HOOKS.md).
### `DependencySpec` and `CubeVariables`
```typescript
type CubeVariables = Record<string, string | number | boolean>;
type DependencySpec = string | [id: string, variables?: CubeVariables];
```
The tuple form passes variables down, at `param` precedence:
```javascript
dependencies: (v) => ['apt:essentials', ['user:add', { USER: v.USER }]],
```
### `AnyObjectSchema`
```typescript
type AnyObjectSchema = z.ZodObject<Record<string, z.ZodType<any>>>;
```
Stands in for zod 3's `z.AnyZodObject`, which zod 4 removed.
### `zodKind(node)` / `zodInner(node)`
```typescript
function zodKind(zodType: unknown): string; // node.def.type — 'default', 'boolean', …
function zodInner(zodType: unknown): z.ZodType; // node.def.innerType
```
Schema introspection that survives a second copy of zod. `instanceof z.ZodDefault`
compares against the *running* copy; a manifest is free to build its schema with
its own, and then every `instanceof` quietly returns false and the caller falls
through to a wrong answer. Nothing that inspects a cube's schema may go back to
`instanceof`. `zodInner` is only valid for a node whose `zodKind` is a wrapper
(`default`, `optional`, `nullable`).
### `uniqid(length?)`
```typescript
const id = uniqid(); // 'Kx7Pm'
const long = uniqid(10); // 'Kx7PmQr2Yw'
```
An LCG seeded from `process.hrtime.bigint()`. Unique enough for an identifier,
not cryptographic. Note that using it in a `.default()` means a fresh value on
every run — fine for a session that gets recorded, surprising for anything else.
### `LoadResult`
```typescript
interface LoadResult {
cubes: Record<string, Cube>; // keyed by cube id
errors: string[];
}
```
---
## Main Module
### `nopy(options?)`
Runs one full deployment pass: load config → load cubes → pick a workflow →
resolve cubes and their dependencies → execute.
```typescript
import { nopy } from '@bitsquare/nopy';
const result = await nopy({ useDefaults: true, dryRun: true });
```
**`NopyOptions`**
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `useDefaults` | `boolean` | `false` | Skip the variable prompts. A cube with a required key nothing supplied aborts the run by name. |
| `useAuthKey` | `boolean` | `false` | Force SSH key auth, skipping the auth prompt. |
| `saveSession` | `string` | | Path to write the session to. **Ignored during a replay.** |
| `loadSession` | `string` | | Path to a session file to replay. |
| `replaySession` | `NopySession` | | A session object to replay, used by `-R` / `-H` from history. Takes precedence over `loadSession`. |
| `dryRun` | `boolean` | `false` | Print the execution plan instead of running it. |
| `printOnly` | `boolean` | `false` | Print the built pyinfra commands and return; the executor is never reached. |
| `continueOnError` | `boolean` | `false` | Keep going after a cube fails. |
| `jsonOutput` | `boolean` | `false` | Suppress the config banner and progress lines. See [Known gaps](#known-gaps). |
| `saveToHistory` | `boolean` | `true` | Record the session in `.nopy.history.json`. |
**Returns:** `Promise<NopyResult | undefined>``undefined` when cube loading
produced errors, which is the one failure mode that returns rather than throws.
```typescript
interface NopyResult {
success: boolean; // summary.failed === 0
results: ExecutionResult[];
summary: {
total: number;
successful: number;
failed: number;
totalDuration: number;
};
}
```
`--dry-run` and `--print-only` both return with an empty `results` array;
`--print-only` additionally reports `total` as the number of commands built.
---
## Cubes Module
### `loadCubes()`
Loads every cube from every discovered root.
```typescript
const { cubes, errors } = await loadCubes();
```
Roots come from three places, unioned:
1. `cubeDirs` from the merged configuration;
2. every ancestor of the working directory holding a `.npcubes` marker file;
3. the directories declared by each package in `cubePackages`.
A directory is a cube when it holds both a manifest (`manifest.mjs` or
`*.manifest.mjs`) and a deploy script (`deploy.py` or `*.deploy.py`). Scanning is
recursive and skips dotted directories and `node_modules`. Manifests are loaded
by dynamic `import()`.
The cube's id is `manifest.id`, falling back to an `[id]` prefix in
`manifest.name`, then to the directory's basename. Ids are flat and need not
mirror the path, and they are claimed **globally** — across `cubeDirs`,
`.npcubes` trees and every installed bundle at once.
`errors` is non-empty for:
- a duplicate id (the message names every claimant and how each got into the run);
- a manifest that throws on import, exports a non-object, or has no `name`;
- a `secrets` entry naming a key that is not in the schema;
- a package in `cubePackages` that is not installed, cannot be read, or declares
no `nopy.cubes`;
- a `nopy.cubes` entry that does not exist or points outside its package root.
Any of them aborts the run (`nopy.main.ts` returns before the workflow). None is
a silent skip. Note that `cubes` is still populated when a duplicate is reported,
for callers that only want to display what was found.
`loadCubes()` also registers the resolve hook (`cubes/resolve-hook.mjs`) before
importing anything. The hook tries ordinary Node resolution first and only on
failure falls back to resolving `@bitsquare/nopy-cube`, `@bitsquare/nopy` and
`zod` from the running CLI's own `node_modules` — so a hand-written cube in a
directory with no `node_modules` loads, while a cube shipping its own zod keeps
it. Registration is best-effort: it is a convenience, never load-bearing.
### `findCubeRoots()`
```typescript
const { roots, errors } = findCubeRoots();
interface CubeRoot {
dir: string;
source: CubeSource;
}
```
The roots `loadCubes()` would scan, each tagged with where it came from. A
missing `cubeDirs` entry is ignored; a missing package is not.
### `findCubeDirectories()`
`findCubeRoots().roots.map(r => r.dir)` — the paths alone. It **drops the
errors**, so anything that needs to know a named package was missing should call
`findCubeRoots()` instead.
### `getCube(cubeName)`
```typescript
const cube = await getCube('apt:essentials'); // Cube | undefined
```
Convenience wrapper over `loadCubes()`. It discards `errors` too.
### `resolveCubePackages(refs)`
Resolves `CubePackageRef[]` to installed packages and their cube directories.
Called by `findCubeRoots()`; exported because its failure modes 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 the package's `nopy.cubes` field
}
```
Resolution goes through `createRequire(...).resolve.paths()` plus `existsSync` on
`<dir>/<spec>/package.json`, deliberately bypassing the `exports` map: a bundle
ships directories and has no entry point to declare. `existsSync` also follows
the symlink pnpm plants at `node_modules/<name>` — which is why the loader cannot
simply scan `node_modules` instead, since `readdir` reports that entry as a
symlink rather than a directory and skips every package silently.
Duplicate refs are deduped here, last-wins: `mergeValue` only dedupes arrays of
primitives and these are objects, so a package named by both a parent and a child
config arrives twice. Configs merge root-first, so the last occurrence is the one
from the most specific config and carries the right resolution origin.
### `BuildContext`
The resolver. One instance per run; it accumulates rather than returning.
```typescript
const context = new BuildContext(
cubes, // Record<string, Cube>
variables, // Variables
session, // NopySession
config, // NopyConfig
{ method: 'ssh-key', username: undefined, password: undefined },
{ useDefaults: false, isSessionReplay: false }
);
for (const host of session.hosts!) {
for (const cubeId of selectedCubes) {
await context.resolveCube(cubeId, host);
}
}
context.deployCalls; // DeployCall[] — in execution order
context.cubeSessions; // CubeSession[] — what a session file would record
```
#### `resolveCube(cubeId, host, overrides?)`
Recursive, per (cube, host):
1. declare the cube's secrets, assign `overrides` at `param`, assign schema
defaults at `default`;
2. collect variables — read them back from the session on replay (then prompt for
the gaps), skip the prompts under `useDefaults`, otherwise prompt;
3. run `before` hooks;
4. call `manifest.dependencies(collectedVariables)` and recurse into each;
5. emit the deploy call;
6. run `after` hooks.
There is no separate topological sort — the ordering falls out of the recursion,
and a `${cubeId}:${host}` set makes emission idempotent. Consequently there is no
cycle detection either: two mutually dependent cubes recurse until the stack
overflows.
**Throws** when the cube id is unknown, when `useDefaults` cannot fill a required
key, when a replay would need a value only the user has (secrets are never
recorded), and when a cancelled prompt leaves a required key empty.
The command it builds:
```
pyinfra <host> -y [--user U --password P] --data "K=V" … --chdir <cubeDir> <cubeDir>/<deployScript>
```
---
## Variables Module
One `Variable` per (cube, key), holding every value it has ever been given.
### `Origin`
Where a value came from, in ascending precedence:
| Origin | Rank | Source |
|---|---|---|
| `default` | 0 | a `.default()` on the cube's schema |
| `env` | 1 | the `env` block of `.nopyrc.json` |
| `session` | 2 | read back from a recorded session on replay |
| `prompt` | 3 | what the user typed |
| `param` | 4 | a dependency spec or a hook's `exec()` |
The order used to be the field order of an object literal — load-bearing,
invisible, and one careless reformat away from silently changing which value
wins. It is stated once now and everything derives from it.
There are no scope bags: config `env` is seeded onto each cube as a real
assignment, and a replay assigns at `session` rather than being smuggled into the
prompts.
### `Variable`
```typescript
class Variable {
readonly assignments: Assignment[]; // the raw trace, newest first, never reordered
redacted: boolean; // declared a secret by the manifest
get ordered(): Assignment[]; // the trace re-ranked by origin, winner first
get effective(): Assignment;
get value(): Value;
get origin(): Origin;
assign(assignment: Assignment): void;
toJSON(): { cube: string; name: string; value: Value; origin: Origin };
}
interface Assignment { value: Value; origin: Origin }
type Value = string | number | boolean;
type TVariables = Record<string, Value>;
```
`ordered` is a **stable** sort of `assignments`. That stability is load-bearing:
the trace is newest-first and `Array.prototype.sort` is stable per spec, so two
assignments sharing an origin resolve to the newer one while the value it
displaced stays visible underneath. The trace is never persisted.
`toJSON()` yields `MASK` instead of the value when `redacted`.
### `Variables`
```typescript
class Variables {
constructor(env?: TVariables);
declareSecrets(cube: string, keys: readonly string[]): void;
isSecret(cube: string, name: string): boolean;
assign(cube: string, origin: Origin, values?: TVariables): void;
all(cube: string): Variable[];
of(cube: string, name: string): Variable | undefined;
get(cube: string): TVariables; // effective values → the pyinfra command line
persistable(cube: string): TVariables; // the same, minus declared secrets
}
const MASK = '********';
```
`declareSecrets()` is retroactive as well as prospective, so it does not matter
whether the caller declares before or after the values arrive.
`persistable()` leaves a secret out entirely rather than masking it, so a replay
sees it as absent and asks for it again. That is why replaying a session whose
cubes declare secrets is interactive even under `-D` — a `-D` replay that would
need one fails by name instead of hanging.
---
## Executor Module
### `DeployCall`
```typescript
interface DeployCall {
cube: string;
host: string;
cwd: string;
command: string[];
env: Record<string, unknown>; // the cube's effective variables
secrets?: string[]; // schema keys the manifest declared secret
dependencies: DependencySpec[]; // always [] — see Known gaps
}
```
### `ExecutionResult` / `ExecutionOptions`
```typescript
interface ExecutionResult {
cube: string;
host: string;
success: boolean;
duration: number; // ms
stdout?: string; // never populated — stdio is inherited
stderr?: string; // never populated — stdio is inherited
error?: Error;
}
interface ExecutionOptions {
continueOnError?: boolean;
dryRun?: boolean;
onProgress?: (result: ExecutionResult, completed: number, total: number) => void;
onStart?: (cube: string, host: string) => void;
}
```
### `executeDeployCalls(calls, options?)`
Runs the calls **sequentially**, in the order they were built, through
`execa({ shell: true })` with `stdio: 'inherit'` so pyinfra's output reaches the
terminal live. Stops at the first failure unless `continueOnError`. With
`dryRun`, prints the plan and returns `[]` without executing.
```typescript
const results = await executeDeployCalls(calls, {
continueOnError: false,
onProgress: (result, completed, total) => console.log(`${completed}/${total}`),
});
```
### `outputExecutionPlan(calls, asJson?)`
```typescript
outputExecutionPlan(deployCalls); // text
outputExecutionPlan(deployCalls, true); // JSON
```
Both forms mask secrets. Note that `executeDeployCalls` calls this without the
second argument, so `--dry-run --json` prints the text plan.
### `maskCommand(call)` / `maskVariables(call)`
```typescript
maskCommand(call); // string — the command as it is safe to print
maskVariables(call); // Record<string, string>
```
pyinfra takes its data on the command line, so the real values have to be in
`call.command`; these are the last point before they would reach a log, a
`--print-only` dump or a dry-run plan. `maskCommand` replaces the SSH
`--password` argument and every `--data "KEY=…"` whose key the manifest declared
a secret.
This covers nopy's own output only. The value still reaches pyinfra on its
command line, so it is visible in `ps` — inherent to pyinfra's `--data`
interface, not something nopy can mask.
### `summarizeResults(results)`
```typescript
const summary = summarizeResults(results);
// { total, successful, failed, totalDuration, failures: ExecutionResult[] }
```
---
## Workflow Module
Picks interactive, file-replay or history-replay and normalises all three into
one shape.
### `runWorkflow(sessionPath, cubes, config, options?, replaySession?)`
```typescript
const result = await runWorkflow(undefined, cubes, config, { useDefaults: false });
```
Dispatch order: `replaySession` (history) → `sessionPath` (file) → interactive.
```typescript
interface WorkflowResult {
session: NopySession;
selectedCubes: string[]; // ids chosen, or the session's cube keys on replay
authMethod: string;
username?: string;
password?: string;
isReplay: boolean;
}
interface WorkflowOptions {
useDefaults?: boolean;
useAuthKey?: boolean;
}
```
### `runInteractiveWorkflow(cubes, config, options?)`
Cube picker → host picker → auth. A host matching `@vagrant` or `@docker` skips
the auth prompt and uses `ssh`.
### `runReplayWorkflow(sessionPath, cubes, config)`
Loads and replays a session file. Re-prompts only for a missing host and for a
password (never persisted); a cube in the session that no longer exists is warned
about here and fails later in `resolveCube`.
### `runSessionReplayWorkflow(session, cubes, config)`
The same, from a session object rather than a path — the `-R` / `-H` path.
---
## Session Module
### Types
```typescript
interface NopySession {
name?: string;
cubes: CubeSession[];
hosts?: string[];
auth: AuthSession;
env?: TVariables;
}
interface CubeSession {
key: string; // the cube id
variables: TVariables;
}
interface AuthSession {
method: 'ssh-key' | 'password' | 'ssh';
username?: string;
// password is intentionally absent — never persisted
}
```
There is no `version` or `timestamp` field, and nothing validates compatibility.
A `CubeSession` records every value the cube settled on, whatever its origin —
not just the prompted ones — minus anything the manifest declared a secret. So a
`--use-defaults` run records a usable session instead of an empty one, and a
replay reproduces the run rather than re-deriving it from whatever the defaults
and `env` happen to say later.
### `saveSession(session, filePath)`
Writes JSON, creating the directory if needed. Note that `nopy()` skips this
during a replay.
### `loadSession(filePath)`
```typescript
const session = await loadSession('./deployment.session.json');
const session = await loadSession('./deployment.session.mjs'); // default export
```
Dispatches on the extension; `.json` and `.mjs` only. Validates that `cubes` is
an array, that `hosts` (if present) is an array, and that `auth` exists.
### `createSession(params)`
```typescript
const session = createSession({
cubes: [{ key: 'apt:essentials', variables: {} }],
hosts: ['localhost'],
auth: { method: 'ssh-key' },
});
```
### `listSessions(dirPath?)`
Non-recursive; matches **`*.session.json`** and **`*.session.mjs`** only.
A file named `deploy.nopysession.json` will not be listed, though `loadSession`
reads it fine.
---
## History Module
Sessions are recorded automatically after a successful non-replay run, into
`.nopy.history.json` in the working directory.
```typescript
const HISTORY_FILE = '.nopy.history.json';
const DEFAULT_HISTORY_SIZE = 10;
interface HistoryEntry {
id: string; // base36 timestamp + random suffix
name: string; // "MM/DD/YYYY, HH:mm - cube1, cube2 → host"
timestamp: string; // ISO
session: NopySession;
}
interface SessionHistory {
entries: HistoryEntry[]; // newest first
}
```
| Function | Returns | Notes |
|---|---|---|
| `getHistoryPath()` | `string` | `<cwd>/.nopy.history.json` |
| `loadHistory()` | `SessionHistory` | empty history if absent or unparseable |
| `saveHistory(history)` | `void` | |
| `addToHistory(session, maxEntries?)` | `HistoryEntry` | prepends, then trims to `maxEntries` |
| `getLastSession()` | `HistoryEntry \| undefined` | |
| `getSessionById(id)` | `HistoryEntry \| undefined` | |
| `listHistory()` | `HistoryEntry[]` | |
| `clearHistory()` | `void` | |
| `removeFromHistory(id)` | `boolean` | `false` if the id was not found |
| `formatHistoryList(entries)` | `string` | what `nopy history` prints |
Recording is suppressed for a dry run, a replay, a run that built no deploy
calls, `--no-history`, and `history.autoSave: false` in the config.
---
## Config Module
### `NopyConfig`
The merged result. A config *file* is `NopyConfigFile`, which is this partial
plus a `resolution` block, and which lists `cubePackages` as plain strings.
```typescript
interface NopyConfig {
hosts: string[];
cubeDirs: string[];
cubePackages: CubePackageRef[];
env: TVariables;
log?: LogConfig;
history?: HistoryConfig;
execution?: ExecutionConfig;
}
interface LogConfig {
verbosity?: 'silent' | 'info' | 'verbose' | 'trace';
debug?: boolean;
}
interface HistoryConfig {
maxSessions?: number; // default 10
autoSave?: boolean; // default true
}
interface ExecutionConfig {
continueOnError?: boolean;
}
type ResolutionStrategy = 'merge' | 'override';
type ResolutionConfig = { [K in keyof NopyConfig]?: ResolutionStrategy };
```
### `CubePackageRef`
A package named in `cubePackages`, paired with where it was named. In the 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 `.nopyrc.json` 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` rather than the working directory's. It is the same
problem `PATH_PROPERTIES` solves for relative `cubeDirs`, with a different answer:
a reference to resolve later instead of a rewritten path.
> The `CubePackageRef` name is currently not re-exported from the package root,
> though `NopyConfig` refers to it. Import it from `@bitsquare/nopy` and you get
> `NopyConfig` but not this type by name.
### `loadConfig()`
```typescript
const config = loadConfig();
```
Collects every `.nopyrc.json` from the working directory up to the filesystem
root, plus `~/.nopyrc.json`, and merges them **root-first** — so the most
specific file wins. The home config is applied first, at the lowest priority.
Per-property strategy comes from the child's `resolution` block, defaulting to
`merge`: arrays concatenate (and dedupe, when every element is a primitive),
objects deep-merge, primitives are replaced. `override` replaces outright.
```json
{
"hosts": ["local-host"],
"cubePackages": ["@bitsquare/cubes-core"],
"resolution": { "hosts": "override" }
}
```
Only `cubeDirs` has its relative paths resolved against its own config file's
directory. `cubePackages` gets the origin recorded instead, as above.
**Throws** when no config file exists anywhere — which is why `nopy.cli.ts` calls
it lazily inside the action, so `--help` and `--version` work outside a project.
### `getConfigPaths()`
The config files that would be loaded, in merge order. Used for the banner.
### `saveConfig(data, configPath?)`
```typescript
saveConfig({ hosts: ['server.local'] }); // <cwd>/.nopyrc.json
saveConfig({ hosts: ['server.local'] }, '/etc/.nopyrc.json');
```
Shallow-merges over whatever the target file already holds. The second parameter
is a **path**, not a boolean.
### `logConfigToFlags(logConfig?)`
```typescript
logConfigToFlags({ verbosity: 'verbose', debug: true }); // ['-vv', '--debug']
```
`silent → []`, `info → ['-v']`, `verbose → ['-vv']`, `trace → ['-vvv']`.
Nothing feeds the result into the built command — see
[Known gaps](#known-gaps).
---
## Prompts Module
### `CubeSelection(cubes)`
```typescript
const { selectedCubes } = await CubeSelection(cubes); // string[] of ids
```
Multi-select with fuzzy filtering on the rendered label. A cancelled prompt
returns an empty array rather than throwing.
### `HostSelection(hosts)`
Offers `docker`, `vagrant`, the configured hosts, and `custom`, returning
`@vagrant/<vm>` or `@docker/<container>` where applicable.
### `AuthSelection(useAuthKey?)`
```typescript
const { authMethod, username, password } = await AuthSelection();
```
Returns `{ authMethod: 'ssh-key' }` immediately when `useAuthKey` is set.
### `PasswordSelection(username)`
Masked single prompt; returns the password.
### `VariableAssignment(cube, variables, opts?)`
```typescript
await VariableAssignment(cube, variables); // every schema key
await VariableAssignment(cube, variables, { keys: gaps }); // a subset
```
**Returns `Promise<void>` and mutates the `Variables` instance**, assigning at
`prompt`. Answers are coerced back to the schema's declared type — booleans from
`true`/`yes`/`1`, numbers where parseable — via `zodKind`, not `instanceof`.
It reads what to offer out of `variables`, so the caller is expected to have
assigned the schema defaults first (which `BuildContext.resolveCube` does). It
deliberately does not fall back to `cube.getDefaults()`: calling that a second
time re-evaluates every lazily declared default, so a cube generating one would
show a value different from the one the run already recorded.
Every schema key is offered, not just the defaulted ones — a field without a
default is precisely the field that has to be asked about. Keys already supplied
at `param` are skipped, since the operator's answer could not win anyway. A
cancelled form leaves the existing values in place.
---
## Update Module
`src/nopy.update.ts`. Backs `nopy self-update` and the one-line notice printed
before an install run. Every network call, clock read and process spawn is an
injectable option (`fetchImpl`, `now`, `run`, `spawn`), which is what makes the
module testable without a registry.
`@bitsquare/keyman` carries a near-identical copy (`keyman.update.ts`,
`KEYMAN_*` env vars, `~/.keyman/` cache). The duplication is deliberate: a fifth
workspace package would add a publish-order edge for ~250 lines.
### Channels
There is no stored channel — **the running version is the state**.
```typescript
channelForVersion('0.5.0'); // 'latest'
channelForVersion('0.6.0-rc.1'); // 'next'
channelForVersion('0.5.0-main.42.gabc'); // 'main'
```
`channelForVersion(version)` returns `'main'` when any prerelease part is the
literal `main`, `'next'` for any other prerelease, and `'latest'` otherwise —
including for an unparseable version, where the worst case is a check that finds
nothing newer. It is the mirror image of the rule `release.yml` publishes under,
so a binary always checks the tag it came from.
### `resolveRegistry(options?)`
`NOPY_REGISTRY``npm config get @bitsquare:registry``NPMJS_REGISTRY`.
Asking npm is the load-bearing part: a CLI installed from Gitea checks Gitea for
its own updates with nothing else configured, because the scope mapping that
installed it is still in `.npmrc`. npm prints the literal string `undefined` for
an unset key, which is treated as unset; a missing npm is swallowed. Returns
trailing-slash form (`normalizeRegistry`).
### `fetchChannelVersion(options)`
```typescript
await fetchChannelVersion({ registry, channel, timeoutMs?, token?, fetchImpl?, packageName? });
```
One `GET ${registry}${encodeURIComponent(name)}` with the abbreviated-packument
accept header, returning `body['dist-tags'][channel] ?? null`. A non-`ok`
response is `null`, not a throw. Deliberately `fetch` rather than shelling out to
`npm view`: one request, a real timeout, and immune to npm's startup cost.
`token` (from `NOPY_REGISTRY_TOKEN`) becomes a bearer header for a private
registry.
### `checkForUpdate(options)` → `UpdateStatus`
```typescript
interface UpdateStatus {
current: string; // the running version
latest: string | null; // what the channel points at, null if undeterminable
channel: Channel;
registry: string;
updateAvailable: boolean; // semver.gt(latest, current)
fromCache: boolean;
}
```
Cached in `~/.nopy/update-check.json` for `DEFAULT_CHECK_INTERVAL_MS` (24 h). An
entry counts as fresh only when its channel **and** registry match the current
question and its age is finite, `>= 0` (a future timestamp is rejected) and under
the interval. A failed lookup degrades to the applicable cached answer rather
than to no answer.
### `buildSelfUpdateCommand(options)`
```typescript
buildSelfUpdateCommand({ packageManager: 'npm', channel: 'main', registry: gitea });
// → npm install --global @bitsquare/nopy@main --@bitsquare:registry=https://…/npm/
```
The registry flag is **scope-mapped, never bare `--registry`**: the Gitea
registry serves `@bitsquare` only and does not proxy npmjs, so a bare
`--registry` breaks the install's transitive dependencies. It is omitted
entirely when the registry is already npmjs. `pnpm add --global`,
`yarn global add` and `bun add --global` are the other three forms.
### `detectPackageManager(options?)`
`NOPY_PACKAGE_MANAGER` wins; otherwise the install path is the evidence —
`/pnpm/`, `/.bun/`, `/.yarn/`|`/yarn/`, else npm. The point is that
`self-update` re-runs whatever installed the CLI instead of leaving two copies
on `PATH`.
### `updateNotice(options)` / `formatUpdateNotice(status, pm?)`
`updateNotice()` is the startup path: returns the string to print or `null`, and
**never throws** — it sits in front of every command the user actually asked
for. Returns `null` immediately when `isUpdateCheckDisabled(env)`:
`NOPY_NO_UPDATE_CHECK` set to anything but `0`/`false`, or `CI` set at all. The
CLI prints it to **stderr**, so `--json` and piped stdout stay clean.
### `selfUpdate(options)` → `SelfUpdateResult`
Always checks with `force: true` — the user asked, so a cached answer will not
do. Returns `{status, command, ran}`; `ran` is `false` for `dryRun`, and for
"already current" unless `force`. The install inherits stdio.
| Env var | Effect |
| --- | --- |
| `NOPY_REGISTRY` | registry to check and install from |
| `NOPY_REGISTRY_TOKEN` | bearer token for a private registry |
| `NOPY_NO_UPDATE_CHECK` | suppress the startup notice |
| `NOPY_PACKAGE_MANAGER` | override install-command detection |
| `CI` | suppresses the notice implicitly |
---
## CLI Usage
```bash
nopy install # interactive (the default command; `nopy` alone works, as does `nopy i`)
nopy install -D # use defaults, no variable prompts
nopy install -K # force SSH key auth
nopy install -R # repeat the last session from history
nopy install -H <id> # replay a specific session from history
nopy install -s ./sess.json # save the session after deploying
nopy install -l ./sess.json # replay a session file
nopy install -n # dry run — print the plan, execute nothing
nopy install -P # print the built pyinfra commands and exit
nopy install -c # continue after a failure
nopy install -j # JSON output
nopy install --no-history # do not record this run
nopy history # list recorded sessions (alias: h; -j for JSON)
nopy clear-history # drop them all
nopy self-update # install the newest version on the current channel (alias: upgrade)
nopy self-update -n # print the install command, run nothing
nopy self-update -f # reinstall even when already current
nopy self-update --channel next --registry <url>
```
`--continue-on-error` overrides `execution.continueOnError` from the config.
Exit code is 1 when any cube failed.
`self-update` prints Installed / Channel / Registry / Available, then one of
"Updated to X.", "Would run: …", or "Already up to date." When `latest` is
`null` it reports `Could not reach <registry>` and exits 1 — deliberately not
"up to date", since an unanswerable check is not a negative answer. See
[Known gaps](#known-gaps) for what that message conflates.
> `-H <id>` and `--no-history` share one Commander destination, so passing both
> discards the id and falls through to an interactive run.
---
## Creating a Cube
### File structure
A cube is a directory holding both a manifest and a deploy script:
```
cubes/
└── apt/
└── essentials/
├── manifest.mjs
└── deploy.py
```
Directories may be nested for grouping, and any extra files alongside the pair
are reachable from the deploy script, which runs with the cube directory as its
working directory. The prefixed forms `<name>.manifest.mjs` and
`<name>.deploy.py` are still recognised.
### Manifest
```javascript
// manifest.mjs
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default Manifest({
id: 'apt:essentials',
name: 'Install essential packages',
dependencies: () => ['apt:update'],
schema: z.object({
PACKAGES: z.string().default('curl,git').describe('Comma-separated packages'),
ENABLE_FEATURE: z.boolean().default(false).describe('Enable the optional feature'),
}),
before: [(ctx, vars) => console.log('before', vars.PACKAGES)],
after: [(ctx, vars) => ctx.exec('admin:report', { STAGE: 'apt' })],
});
```
> **Call `.default()` before `.describe()`.** In zod 4, `.default()` returns a
> `ZodDefault` wrapper that does not inherit `.description` from the type it
> wraps, and the prompt reads the description off the outer node. So
> `z.boolean().describe('Update cache').default(false)` prompts with the bare key
> `UPDATE`, while `z.boolean().default(false).describe('Update cache')` prompts
> with the sentence. Verified against zod 4.4.3.
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 and
numeric strings as ints.
### Deploy script
```python
# deploy.py
from pyinfra import host
from pyinfra.operations import apt, server
PACKAGES = host.data.PACKAGES.split(',')
ENABLE_FEATURE = host.data.ENABLE_FEATURE
apt.packages(name='Install packages', packages=PACKAGES, update=True, _sudo=True)
if ENABLE_FEATURE:
server.shell(name='Enable feature', commands=['my-package --enable-feature'])
```
For packaging cubes as an installable npm bundle, see
[CUBE-BUNDLES.md](CUBE-BUNDLES.md).
---
## Known gaps
Real behaviour that a reader would otherwise take on trust. Tracked in
`DOCS-AUDIT.md` and summarised in `CLAUDE.md`.
- **`logConfigToFlags()` is never consumed.** It is exported and unit-tested, but
nothing feeds its output into the built pyinfra command, so `log.verbosity` and
`log.debug` in `.nopyrc.json` have no effect today.
- **`--json` emits nothing on success.** `jsonOutput` suppresses the banner and
the progress lines, and prints `{success: false, errors}` when cube *loading*
fails. The success path returns `NopyResult` to the caller without printing it,
so a CI job gets pyinfra's inherited stdio and an exit code. `--dry-run --json`
prints the *text* plan.
- **No cycle detection.** Ordering is a side effect of recursion, not a
topological sort. Two mutually dependent cubes overflow the stack.
- **`DeployCall.dependencies` is always `[]`.** The field is populated nowhere;
dependency information lives in the emission order.
- **`ExecutionResult.stdout` / `.stderr` are always `undefined`,** because the
executor inherits stdio rather than capturing it.
- **Hook variables are not schema-validated.** The second argument to a hook is
the effective values as collected. `schema.parse()` runs in exactly one place —
`Cube.getDefaults()`, against `{}` — and prompt input is type-coerced, which is
not the same thing.
- **Nothing checks bundle/CLI compatibility.** A cube package declares no
supported nopy range and the loader reads whatever `nopy.cubes` points at.
- **`self-update` reports an empty channel as unreachable.** `latest === null`
means either the request failed *or* the registry answered normally and the
dist-tag simply has no version — the second is exactly what a Gitea package
with no `latest` looks like — and both print `Could not reach <registry>`.
The distinction exists in `fetchChannelVersion` (a non-`ok` response returns
`null` rather than throwing) but is not carried out to the message.