1192 lines
41 KiB
Markdown
1192 lines
41 KiB
Markdown
# 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.
|