41 KiB
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 for the short list of those.
If you are writing cubes rather than calling nopy from code, you want CUBE-BUNDLES.md and HOOKS.md; only the Authoring API section here applies to you.
Table of Contents
- Two packages
- Authoring API (
@bitsquare/nopy-cube) - Main Module
- Cubes Module
- Variables Module
- Executor Module
- Workflow Module
- Session Module
- History Module
- Config Module
- Prompts Module
- Update Module
- CLI Usage
- Creating a Cube
- 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.
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.
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)
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.
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.
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
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. Full semantics in
HOOKS.md.
DependencySpec and CubeVariables
type CubeVariables = Record<string, string | number | boolean>;
type DependencySpec = string | [id: string, variables?: CubeVariables];
The tuple form passes variables down, at param precedence:
dependencies: (v) => ['apt:essentials', ['user:add', { USER: v.USER }]],
AnyObjectSchema
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)
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?)
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
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.
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. |
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.
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.
const { cubes, errors } = await loadCubes();
Roots come from three places, unioned:
cubeDirsfrom the merged configuration;- every ancestor of the working directory holding a
.npcubesmarker file; - 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
secretsentry naming a key that is not in the schema; - a package in
cubePackagesthat is not installed, cannot be read, or declares nonopy.cubes; - a
nopy.cubesentry 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()
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)
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.
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.
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):
- declare the cube's secrets, assign
overridesatparam, assign schema defaults atdefault; - collect variables — read them back from the session on replay (then prompt for
the gaps), skip the prompts under
useDefaults, otherwise prompt; - run
beforehooks; - call
manifest.dependencies(collectedVariables)and recurse into each; - emit the deploy call;
- run
afterhooks.
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
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
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
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
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.
const results = await executeDeployCalls(calls, {
continueOnError: false,
onProgress: (result, completed, total) => console.log(`${completed}/${total}`),
});
outputExecutionPlan(calls, asJson?)
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)
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)
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?)
const result = await runWorkflow(undefined, cubes, config, { useDefaults: false });
Dispatch order: replaySession (history) → sessionPath (file) → interactive.
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
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)
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)
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.
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.
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.
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
CubePackageRefname is currently not re-exported from the package root, thoughNopyConfigrefers to it. Import it from@bitsquare/nopyand you getNopyConfigbut not this type by name.
loadConfig()
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.
{
"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?)
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?)
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.
Prompts Module
CubeSelection(cubes)
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?)
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?)
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.
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)
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
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)
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
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 for what that message conflates.
-H <id>and--no-historyshare 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
// 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 aZodDefaultwrapper that does not inherit.descriptionfrom the type it wraps, and the prompt reads the description off the outer node. Soz.boolean().describe('Update cache').default(false)prompts with the bare keyUPDATE, whilez.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
# 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.
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, solog.verbosityandlog.debugin.nopyrc.jsonhave no effect today.--jsonemits nothing on success.jsonOutputsuppresses the banner and the progress lines, and prints{success: false, errors}when cube loading fails. The success path returnsNopyResultto the caller without printing it, so a CI job gets pyinfra's inherited stdio and an exit code.--dry-run --jsonprints 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.dependenciesis always[]. The field is populated nowhere; dependency information lives in the emission order.ExecutionResult.stdout/.stderrare alwaysundefined, 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.cubespoints at. self-updatereports an empty channel as unreachable.latest === nullmeans 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 nolatestlooks like — and both printCould not reach <registry>. The distinction exists infetchChannelVersion(a non-okresponse returnsnullrather than throwing) but is not carried out to the message.