nopy: add init command with bundled NOPY.LLM.md guide
`nopy init` writes a starter .nopyrc.json and NOPY.LLM.md — an LLM-facing usage guide covering cubes, config, variables, sessions, and pyinfra — into the working directory. Existing files are skipped unless --force. The guide ships as dist/templates/NOPY.LLM.md, resolved relative to the module so it works from source and from an installed package alike. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ce5atB2tXDXyz2jd9s1bqE
This commit is contained in:
co-authored by
Claude Fable 5
parent
4fa69cce0d
commit
f1cc9effa0
@@ -501,6 +501,19 @@ pnpm --filter @bitsquare/nopy run nopy # runs the CLI from source via tsx
|
||||
|
||||
### Basic Commands
|
||||
|
||||
**Start a new project**:
|
||||
|
||||
```bash
|
||||
nopy init
|
||||
```
|
||||
|
||||
Writes two files into the current directory and touches nothing that already
|
||||
exists (`--force` overwrites): a starter `.nopyrc.json` — the file without
|
||||
which `nopy install` refuses to run — and `NOPY.LLM.md`, a bundled usage guide
|
||||
written for AI assistants. Point your coding agent at it (or let it discover
|
||||
the file) and it can answer nopy questions, write cubes, and plan deployments
|
||||
from project-local context instead of guessing.
|
||||
|
||||
**Install cubes (default command)**:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -25,6 +25,7 @@ If you are writing cubes rather than calling nopy from code, you want
|
||||
- [Workflow Module](#workflow-module)
|
||||
- [Session Module](#session-module)
|
||||
- [History Module](#history-module)
|
||||
- [Init Module](#init-module)
|
||||
- [Config Module](#config-module)
|
||||
- [Prompts Module](#prompts-module)
|
||||
- [Update Module](#update-module)
|
||||
@@ -830,6 +831,33 @@ without the entry `-R` would have nothing to repeat.
|
||||
|
||||
---
|
||||
|
||||
## Init Module
|
||||
|
||||
Backs `nopy init`.
|
||||
|
||||
### `initProject(options?)`
|
||||
|
||||
```typescript
|
||||
function initProject(options?: { force?: boolean; dir?: string }): InitFileResult[];
|
||||
// InitFileResult: { file: string; path: string; status: 'created' | 'overwritten' | 'skipped' }
|
||||
```
|
||||
|
||||
Writes `STARTER_CONFIG` as `.nopyrc.json` and the bundled `NOPY.LLM.md` usage
|
||||
guide (`GUIDE_FILENAME`) into `dir` (default: the working directory). Existing
|
||||
files are skipped unless `force` is set; the result names what happened to each
|
||||
file. The guide template ships in `dist/templates/` and is resolved relative to
|
||||
the module, so it works from source and from an installed package alike.
|
||||
|
||||
`STARTER_CONFIG` deliberately leaves `cubePackages` empty: naming a bundle
|
||||
that is not installed is a hard error, and `init` must leave a config that
|
||||
loads.
|
||||
|
||||
### `formatInitResults(results)`
|
||||
|
||||
Renders the per-file report plus the next-steps hint that `nopy init` prints.
|
||||
|
||||
---
|
||||
|
||||
## Config Module
|
||||
|
||||
### `NopyConfig`
|
||||
@@ -1110,6 +1138,7 @@ do. Returns `{status, command, ran}`; `ran` is `false` for `dryRun`, and for
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
nopy init # write a starter .nopyrc.json + NOPY.LLM.md here (-f overwrites)
|
||||
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
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist .tsbuildinfo",
|
||||
"build": "tsc && cp src/cubes/*.mjs dist/cubes/",
|
||||
"build": "tsc && cp src/cubes/*.mjs dist/cubes/ && mkdir -p dist/templates && cp src/templates/*.md dist/templates/",
|
||||
"prepack": "pnpm run build",
|
||||
"link:local": "pnpm run build && npm link",
|
||||
"nopy": "tsx src/nopy.cli.ts",
|
||||
|
||||
@@ -62,6 +62,9 @@ export {
|
||||
removeFromHistory,
|
||||
saveHistory,
|
||||
} from './nopy.history.js';
|
||||
export type { InitFileResult, InitFileStatus, InitOptions } from './nopy.init.js';
|
||||
// Project initialization
|
||||
export { formatInitResults, GUIDE_FILENAME, initProject, STARTER_CONFIG } from './nopy.init.js';
|
||||
export type { NopyOptions, NopyResult } from './nopy.main.js';
|
||||
// Main entry point
|
||||
export { nopy } from './nopy.main.js';
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getSessionById,
|
||||
listHistory,
|
||||
} from './nopy.history.js';
|
||||
import { formatInitResults, initProject } from './nopy.init.js';
|
||||
import { nopy } from './nopy.main.js';
|
||||
import type { Channel } from './nopy.update.js';
|
||||
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
|
||||
@@ -58,6 +59,7 @@ program
|
||||
'after',
|
||||
`
|
||||
Examples:
|
||||
$ nopy init Set up this directory (.nopyrc.json + NOPY.LLM.md)
|
||||
$ nopy Interactive cube selection and deployment
|
||||
$ nopy -R Repeat the last deployment session
|
||||
$ nopy -H <id> Run a specific session from history
|
||||
@@ -162,6 +164,20 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description('Write a starter .nopyrc.json and the NOPY.LLM.md usage guide here')
|
||||
.option('-f, --force', 'Overwrite files that already exist')
|
||||
.action((options) => {
|
||||
try {
|
||||
const results = initProject({ force: options.force });
|
||||
console.log(formatInitResults(results));
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('history')
|
||||
.description('List session history')
|
||||
|
||||
@@ -129,7 +129,7 @@ const DEFAULT_CONFIG: NopyConfig = {
|
||||
env: {},
|
||||
};
|
||||
|
||||
const CONFIG_FILENAME = '.nopyrc.json';
|
||||
export const CONFIG_FILENAME = '.nopyrc.json';
|
||||
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Project initialization — `nopy init`
|
||||
* @module nopy.init
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { CONFIG_FILENAME, type NopyConfigFile } from './nopy.config.js';
|
||||
|
||||
/** The LLM-facing usage guide `init` drops next to the config. */
|
||||
export const GUIDE_FILENAME = 'NOPY.LLM.md';
|
||||
|
||||
/**
|
||||
* What a fresh project starts from. `cubePackages` stays empty on purpose:
|
||||
* naming a bundle is a hard error until it is installed, and `init` must leave
|
||||
* behind a config that loads.
|
||||
*/
|
||||
export const STARTER_CONFIG: NopyConfigFile = {
|
||||
hosts: [],
|
||||
cubeDirs: ['./cubes'],
|
||||
cubePackages: [],
|
||||
env: {},
|
||||
log: {
|
||||
verbosity: 'info',
|
||||
debug: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The bundled guide, resolved relative to this module so the same path works
|
||||
* from `src/` (tsx, vitest) and from `dist/` (the build copies `src/templates`
|
||||
* alongside the compiled module).
|
||||
*/
|
||||
const TEMPLATE_URL = new URL('./templates/NOPY.LLM.md', import.meta.url);
|
||||
|
||||
export type InitFileStatus = 'created' | 'overwritten' | 'skipped';
|
||||
|
||||
/** One file `init` considered, and what happened to it. */
|
||||
export interface InitFileResult {
|
||||
/** Basename, for reporting. */
|
||||
file: string;
|
||||
/** Absolute path that was written or left alone. */
|
||||
path: string;
|
||||
status: InitFileStatus;
|
||||
}
|
||||
|
||||
export interface InitOptions {
|
||||
/** Overwrite files that already exist. */
|
||||
force?: boolean;
|
||||
/** Target directory (defaults to the working directory). */
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
function writeGuarded(filePath: string, content: string, force: boolean): InitFileResult {
|
||||
const existed = fs.existsSync(filePath);
|
||||
if (existed && !force) {
|
||||
return { file: path.basename(filePath), path: filePath, status: 'skipped' };
|
||||
}
|
||||
fs.writeFileSync(filePath, content);
|
||||
return {
|
||||
file: path.basename(filePath),
|
||||
path: filePath,
|
||||
status: existed ? 'overwritten' : 'created',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a starter `.nopyrc.json` and the bundled `NOPY.LLM.md` guide into
|
||||
* `dir`. Existing files are left alone unless `force` is set; either way the
|
||||
* result names what happened to each file.
|
||||
*/
|
||||
export function initProject(options: InitOptions = {}): InitFileResult[] {
|
||||
const dir = options.dir ?? process.cwd();
|
||||
const force = options.force ?? false;
|
||||
|
||||
const config = `${JSON.stringify(STARTER_CONFIG, null, 2)}\n`;
|
||||
const guide = fs.readFileSync(fileURLToPath(TEMPLATE_URL), 'utf-8');
|
||||
|
||||
return [
|
||||
writeGuarded(path.join(dir, CONFIG_FILENAME), config, force),
|
||||
writeGuarded(path.join(dir, GUIDE_FILENAME), guide, force),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The report `nopy init` prints, one line per file plus a next-steps hint.
|
||||
* Lives here rather than in the CLI because the CLI is excluded from coverage.
|
||||
*/
|
||||
export function formatInitResults(results: InitFileResult[]): string {
|
||||
const lines = results.map((result) =>
|
||||
result.status === 'skipped'
|
||||
? ` exists, skipped ${result.file} (use --force to overwrite)`
|
||||
: ` ${result.status.padEnd(15)} ${result.file}`
|
||||
);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'Next steps:',
|
||||
` 1. Add target hosts to "hosts" in ${CONFIG_FILENAME}`,
|
||||
' 2. Put cubes in ./cubes, or install a bundle and list it under "cubePackages"',
|
||||
' 3. Run `nopy` to deploy — NOPY.LLM.md explains the rest'
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
# NOPY.LLM.md — nopy for language models
|
||||
|
||||
This file was written by `nopy init` and is bundled with the nopy release that
|
||||
wrote it. It is a working reference for AI assistants (and humans) operating in
|
||||
a project that deploys with **nopy**. Read it before answering questions about
|
||||
nopy, before writing or editing a cube, and before planning how to reach a
|
||||
deployment goal. When this guide and the installed CLI disagree, the CLI wins —
|
||||
check `nopy --help` and the package README.
|
||||
|
||||
## What nopy is
|
||||
|
||||
nopy is a CLI that wraps [pyinfra](https://docs.pyinfra.com/) — a Python
|
||||
infrastructure-as-code tool — in an interactive workflow. Deployments are
|
||||
organised into **cubes**: self-contained directories holding a JavaScript
|
||||
manifest (declaring typed input variables, secrets, dependencies, and hooks) and
|
||||
a plain pyinfra deploy script. nopy discovers cubes, prompts for a target host
|
||||
and variable values, resolves dependencies into a topological order, and then
|
||||
runs one `pyinfra` command per cube, sequentially. Every run is recorded and can
|
||||
be replayed.
|
||||
|
||||
nopy does not vendor pyinfra. `pyinfra` must be on `PATH`
|
||||
(`pipx install pyinfra`), and `docker` / `vagrant` too if those connectors are
|
||||
used. Node ≥ 22 is required.
|
||||
|
||||
## Quick facts
|
||||
|
||||
| Thing | Value |
|
||||
| --- | --- |
|
||||
| Binary | `nopy` (default subcommand: `install`) |
|
||||
| Config file | `.nopyrc.json` — cwd upward to `/`, plus `~/.nopyrc.json`, all merged |
|
||||
| Cube | a directory with `manifest.mjs` + `deploy.py` |
|
||||
| Session file | `*.nopysession.json` (`--save-session` / `--load-session`) |
|
||||
| History | `.nopy.history.json` in the working directory — add it to `.gitignore` |
|
||||
| Update cache | `~/.nopy/update-check.json` |
|
||||
| Cube marker | a `.npcubes` file makes its directory a cube root |
|
||||
| Authoring package | `@bitsquare/nopy-cubes` (imported by manifests) |
|
||||
| Core cube bundle | `@bitsquare/nopy-cubes-core` |
|
||||
|
||||
## How to help — a decision guide
|
||||
|
||||
When asked to achieve a deployment goal, work through this order:
|
||||
|
||||
1. **Find an existing cube.** List the project's cube sources: `cubeDirs` and
|
||||
`cubePackages` in the merged `.nopyrc.json`, plus any `.npcubes` marker
|
||||
directories. The core bundle's cubes are listed at the end of this file.
|
||||
Prefer configuring an existing cube over writing a new one.
|
||||
2. **Compose cubes.** One run can select several cubes; each cube's declared
|
||||
dependencies are pulled in automatically and deployed first. Do not
|
||||
hand-order cubes that already declare their relationship.
|
||||
3. **Configure, don't fork.** A cube's behaviour is steered by its schema
|
||||
variables. Project-wide values belong under `env` in `.nopyrc.json`
|
||||
(they override schema defaults); per-run values come from the prompts.
|
||||
4. **Write a new cube** only when nothing covers the goal — see
|
||||
[Authoring a cube](#authoring-a-cube). Keep it small, idempotent, and give
|
||||
every variable a `.describe()` and (usually) a `.default()`.
|
||||
5. **Make it repeatable.** For "run this again later": rely on history (`-R`,
|
||||
`-H <id>`) or record a session file (`-s file.nopysession.json`). For
|
||||
CI/unattended runs: `nopy install -D` plus values under `env` — see
|
||||
[CI and unattended runs](#ci-and-unattended-runs).
|
||||
|
||||
## CLI reference
|
||||
|
||||
`nopy` with no subcommand runs `install`. Everything nopy says about itself
|
||||
goes to **stderr**; stdout carries only deploy commands and pyinfra's own
|
||||
output. Exit code is `1` if any cube failed, `0` otherwise.
|
||||
|
||||
```
|
||||
nopy [install] interactive: pick cubes, host, auth, variables
|
||||
nopy init write a starter .nopyrc.json and this guide (-f overwrites)
|
||||
nopy history list recorded sessions (--json for machine-readable)
|
||||
nopy clear-history delete all recorded sessions
|
||||
nopy self-update update nopy on its release channel (--dry-run, --force,
|
||||
--channel <latest|next|main>, --registry <url>)
|
||||
```
|
||||
|
||||
`install` flags:
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| `-D, --use-defaults` | skip the variable form; values come from defaults, `env`, dependencies |
|
||||
| `-K, --auth-method-key` | SSH key auth without asking |
|
||||
| `-R, --repeat-last` | replay the newest history entry |
|
||||
| `-H, --history <id>` | replay a specific history entry (`nopy history` shows ids) |
|
||||
| `-s, --save-session <path>` | record the run to a session file |
|
||||
| `-l, --load-session <path>` | replay a session file |
|
||||
| `-n, --dry-run` | print the execution plan (commands + variables, secrets masked), run nothing |
|
||||
| `-P, --print-only` | print only the deploy commands to stdout, run nothing |
|
||||
| `-c, --continue-on-error` | keep deploying remaining cubes after a failure |
|
||||
| `--no-save-history` | do not record this run |
|
||||
|
||||
Environment variables: `NOPY_DEBUG=1` prints full stack traces;
|
||||
`NOPY_NO_UPDATE_CHECK=1` (or `CI` being set) disables the daily update check;
|
||||
`NOPY_REGISTRY`, `NOPY_REGISTRY_TOKEN`, `NOPY_PACKAGE_MANAGER` steer
|
||||
`self-update`.
|
||||
|
||||
## Configuration: `.nopyrc.json`
|
||||
|
||||
Every `.nopyrc.json` from the filesystem root down to the working directory,
|
||||
plus `~/.nopyrc.json`, is merged root-first — the nearer file wins ties. Arrays
|
||||
concatenate and dedupe, objects deep-merge; a child file can switch a property
|
||||
to wholesale replacement with `"resolution": { "<property>": "override" }`.
|
||||
Relative paths in `cubeDirs` resolve against the config file that wrote them,
|
||||
and each `cubePackages` entry resolves from that file's directory too. If no
|
||||
config file exists anywhere, `nopy install` refuses to run — `nopy init` fixes
|
||||
that.
|
||||
|
||||
All properties, all optional:
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["web-01.example.com", "@docker/my-container", "@vagrant/default"],
|
||||
"cubeDirs": ["./cubes"],
|
||||
"cubePackages": ["@bitsquare/nopy-cubes-core"],
|
||||
"env": { "KEY_DIR": "./keys" },
|
||||
"secrets": ["DEPLOY_TOKEN"],
|
||||
"log": { "verbosity": "info", "debug": false },
|
||||
"history": { "maxSessions": 10, "autoSave": true },
|
||||
"execution": { "continueOnError": false },
|
||||
"resolution": { "hosts": "override" }
|
||||
}
|
||||
```
|
||||
|
||||
- **`hosts`** seeds the host picker (see [Hosts](#hosts-connectors-and-auth)).
|
||||
- **`cubeDirs`** — directories scanned recursively for cubes.
|
||||
- **`cubePackages`** — installed npm packages that ship cubes in a `cubes/`
|
||||
directory (or wherever their `package.json` `nopy.cubes` points). Naming a
|
||||
package that is missing or malformed is a hard error, never a silent skip.
|
||||
- **`env`** — key/value pairs seeded onto **every** cube in the run, at a
|
||||
priority above schema defaults. This is how a project pins values and how
|
||||
`--use-defaults` runs are steered.
|
||||
- **`secrets`** — `env` keys to treat as sensitive even though no manifest
|
||||
declares them (masked, never recorded, delivered only to cubes whose schema
|
||||
names them).
|
||||
- **`log.verbosity`** — `silent` (default) | `info` (`-v`) | `verbose` (`-vv`)
|
||||
| `trace` (`-vvv`); **`log.debug`** adds `--debug`. These become pyinfra
|
||||
flags.
|
||||
- **`history`** — `maxSessions` (default 10) and `autoSave` (default true).
|
||||
- **`execution.continueOnError`** — project default for `-c`.
|
||||
|
||||
## Cubes
|
||||
|
||||
A cube is any directory holding both a manifest (`manifest.mjs` or
|
||||
`*.manifest.mjs`) and a deploy script (`deploy.py` or `*.deploy.py`).
|
||||
Discovery unions `cubeDirs`, the cube directories of every `cubePackages`
|
||||
entry, and every ancestor directory containing a `.npcubes` marker file, then
|
||||
scans recursively (skipping dot-directories and `node_modules`). Extra files in
|
||||
a cube directory are ignored by the loader but reachable from the script — **the
|
||||
deploy script runs with the cube directory as its working directory**.
|
||||
|
||||
Cube ids (e.g. `apt:install`, `net:tailscale`) are flat strings claimed
|
||||
**globally** across all sources. Two cubes with one id abort the run with an
|
||||
error naming both — there is no shadowing and no precedence. Prefix local cube
|
||||
ids distinctly when a bundle is also installed. The id need not mirror the
|
||||
path; it comes from `manifest.id`, falling back to an `[id]` prefix in
|
||||
`manifest.name`, then the directory basename.
|
||||
|
||||
## Authoring a cube
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
cubes/
|
||||
└── myapp/
|
||||
└── caddy-site/
|
||||
├── manifest.mjs
|
||||
└── deploy.py
|
||||
```
|
||||
|
||||
`manifest.mjs` — ESM, imports from `@bitsquare/nopy-cubes` (a local cube needs
|
||||
no `node_modules` of its own: when normal resolution fails, nopy resolves
|
||||
`@bitsquare/nopy-cubes` and `zod` from its own installation):
|
||||
|
||||
```javascript
|
||||
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'myapp:caddy-site',
|
||||
name: 'Serve the app behind Caddy',
|
||||
dependencies: (vars) => ['caddy'], // runs before this cube
|
||||
secrets: ['API_TOKEN'], // must be schema keys
|
||||
schema: z.object({
|
||||
DOMAIN: z.string().describe('Public domain for the site').default('example.com'),
|
||||
PORT: z.number().describe('Upstream port').default(3000),
|
||||
API_TOKEN: z.string().describe('Deploy token for the app'), // no default → required
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Schema rules:
|
||||
|
||||
- `.describe()` is the prompt label — set it on every field.
|
||||
- `.default()` gives the field a value at the lowest priority. A field
|
||||
**without** a default is required: interactive runs prompt for it, and a
|
||||
`--use-defaults` run fails naming it unless `env` or a dependency supplies
|
||||
it. Leave defaults off values that must not be guessed (a public key, a real
|
||||
credential). Defaults may be functions (`.default(() => ...)`).
|
||||
- `secrets` entries must name schema keys; anything else is a manifest error.
|
||||
Secrets are masked in all output, never written to sessions or history,
|
||||
re-prompted on replay, and delivered only to cubes whose schema declares
|
||||
them. A `.default()` on a secret is plain text in the repo — use a
|
||||
placeholder like `changeme` or none at all.
|
||||
- `dependencies` is a function of the *collected* variables, so it can be
|
||||
conditional. Each entry is an id or `[id, {VAR: value}]` to pass parameters;
|
||||
passed parameters outrank everything, including the user's prompt answers.
|
||||
- `before` / `after` are hook arrays: `(ctx, vars) => {}` where
|
||||
`ctx.exec(id, vars)` schedules another cube (before or after this one).
|
||||
Use dependencies for static requirements, hooks for conditional
|
||||
orchestration and explicit parameter passing.
|
||||
|
||||
`deploy.py` — a plain pyinfra script. Every schema key is guaranteed present on
|
||||
`host.data`:
|
||||
|
||||
```python
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import apt, files, systemd
|
||||
|
||||
DOMAIN = str(host.data.DOMAIN)
|
||||
PORT = host.data.PORT # arrives as int — pyinfra parses --data values
|
||||
|
||||
files.template(
|
||||
name='Write Caddyfile site',
|
||||
src='Caddyfile.j2', # relative to the cube directory (its cwd)
|
||||
dest=f'/etc/caddy/sites/{DOMAIN}',
|
||||
domain=DOMAIN, port=PORT,
|
||||
_sudo=True,
|
||||
)
|
||||
|
||||
systemd.service(name='Reload caddy', service='caddy', reloaded=True, _sudo=True)
|
||||
```
|
||||
|
||||
**`--data` value coercion**: pyinfra parses values before the script sees them —
|
||||
`"true"`/`"false"` become booleans, numeric strings become `int`, valid JSON
|
||||
becomes the parsed structure, everything else stays a string. Wrap in `str()`
|
||||
before string operations; pass booleans/ints straight through.
|
||||
|
||||
**pyinfra essentials**: operations live in `pyinfra.operations.*` (`apt`,
|
||||
`server`, `files`, `systemd`, `git`, `python`, …) and are declarative — they
|
||||
gather facts and no-op when the host already matches, so a well-written cube is
|
||||
idempotent and safe to re-run. Global arguments like `_sudo=True`,
|
||||
`_env={...}`, `_ignore_errors=True` work on every operation. Facts:
|
||||
`host.get_fact(...)` from `pyinfra.facts.*`. Full reference:
|
||||
<https://docs.pyinfra.com/>.
|
||||
|
||||
## Variables and precedence
|
||||
|
||||
A variable can be assigned from several places in one run; every assignment is
|
||||
kept and tagged with an **origin**, and the highest-ranked origin wins:
|
||||
|
||||
| Rank | Origin | Set by |
|
||||
| --- | --- | --- |
|
||||
| 0 | `default` | the schema's `.default()` |
|
||||
| 1 | `env` | the merged `env` block of `.nopyrc.json` |
|
||||
| 2 | `session` | a replayed session file or history entry |
|
||||
| 3 | `prompt` | what the user typed |
|
||||
| 4 | `param` | a dependency spec or a hook's `exec()` |
|
||||
|
||||
Consequences worth knowing:
|
||||
|
||||
- `env` beats defaults, so `.nopyrc.json` steers `--use-defaults` runs.
|
||||
- A recorded session beats current `env` and current defaults — replay is
|
||||
faithful, not re-derived. Editing a default does not change what a replay
|
||||
does; record a fresh session to pick it up.
|
||||
- A key supplied by a dependency (`param`) is never prompted for and never
|
||||
clobbered by a stale recording.
|
||||
- Ordinary `env` values reach every cube (a cube may read keys its schema never
|
||||
declared); declared secrets reach only cubes whose schema names them.
|
||||
|
||||
## Hosts, connectors, and auth
|
||||
|
||||
The host picker offers the configured `hosts`, a free-form `custom` entry, and
|
||||
two connector shortcuts:
|
||||
|
||||
- **`@docker/<name-or-image>`** — a running container is mutated in place; an
|
||||
image reference starts a throwaway container, applies the deploy, and commits
|
||||
the result as a new image. Which one is meant is decided by the docker
|
||||
connector (container match first).
|
||||
- **`@vagrant/<machine>`** — deploys into a Vagrant machine.
|
||||
|
||||
Connector strings can be written directly into `hosts`. Auth methods: password
|
||||
(prompts for user + password; becomes `--user <u> --password <p>`, masked in
|
||||
output, never recorded), SSH key (`-K`; nopy passes nothing — pyinfra uses your
|
||||
SSH config/agent), and `ssh` (session-recorded value meaning the connector owns
|
||||
auth — what `@docker/` and `@vagrant/` hosts get, which is why replaying one
|
||||
asks for nothing).
|
||||
|
||||
## Execution model
|
||||
|
||||
Per selected cube (dependencies first, post-order = topological order, cycles
|
||||
reported by name), nopy builds and spawns — without a shell —
|
||||
|
||||
```
|
||||
pyinfra <host> -y [-v|-vv|-vvv] [--debug] [--user U --password P] \
|
||||
--data KEY=value ... --chdir <cubeDir> <cubeDir>/deploy.py
|
||||
```
|
||||
|
||||
Commands run **sequentially** with inherited stdio, stopping at the first
|
||||
failure unless `--continue-on-error`. There is no rollback: cubes that already
|
||||
succeeded stay applied, cubes queued after the failure are skipped and not
|
||||
reported as failed. A cube already emitted for the same (cube, host) pair is
|
||||
not emitted twice.
|
||||
|
||||
## Sessions, history, and replay
|
||||
|
||||
Every completed run (including failed ones) is auto-recorded to
|
||||
`.nopy.history.json` in the working directory — per-project, newest-first,
|
||||
rotating at `history.maxSessions`. Not recorded: `--dry-run`, `--print-only`,
|
||||
`--no-save-history`, empty selections, and `-R`/`-H` replays themselves.
|
||||
|
||||
A session records the **full snapshot**: selected cubes with every variable
|
||||
value they settled on (whatever the origin), hosts, auth method and username.
|
||||
Never recorded: the SSH password and any declared secret — both re-prompted on
|
||||
replay. A replay also prompts for the host when none was recorded and for
|
||||
required keys the schema gained since recording. `-D` combined with a replay
|
||||
that would have to prompt fails naming the keys instead of deploying a
|
||||
placeholder.
|
||||
|
||||
Session files (`-s` / `-l`) use the same JSON structure as history entries and
|
||||
are the way to keep a run indefinitely — history rotates. `nopy history --json`
|
||||
is how scripts find ids for `-H`.
|
||||
|
||||
## CI and unattended runs
|
||||
|
||||
```sh
|
||||
nopy install --print-only > plan.txt # the commands, nothing else, stdout only
|
||||
nopy install -D -K # no prompts: defaults + env, SSH key auth
|
||||
nopy install -l ci.nopysession.json -D # replay a checked-in session
|
||||
```
|
||||
|
||||
- stdout carries only deploy commands and pyinfra output; all nopy chatter is
|
||||
stderr. The exit code is the verdict. There is deliberately no `--json` on
|
||||
`install`.
|
||||
- Values a `-D` run needs beyond schema defaults go under `env` in
|
||||
`.nopyrc.json`; sensitive ones also under config `secrets` so they stay
|
||||
masked and travel only to cubes that declare them.
|
||||
- Secrets are still visible in the process table while pyinfra runs (`--data`
|
||||
is argv) and in the prompt UI — `secrets` protects nopy's files and output,
|
||||
nothing more.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --- | --- |
|
||||
| `No .nopyrc.json found` | run `nopy init`, or create the file in the project or a parent |
|
||||
| spawn failure on first deploy | `pyinfra` not on `PATH` — `pipx install pyinfra` |
|
||||
| `Duplicate cube id '<id>' from 2 sources` | two sources claim one id; rename one or drop a source — there is no precedence |
|
||||
| cube package errors at startup | a `cubePackages` entry is not installed, has no `cubes/` dir and no `nopy.cubes` override, or points outside itself — all hard errors |
|
||||
| `cannot run with --use-defaults: <KEYS>` | required keys with no default; set them under `env`, pass from a dependency, or drop `-D` |
|
||||
| replay aborts `Cube not found: <id>` | the cube was renamed/deleted since recording; the entry is unreplayable |
|
||||
| replay asks for a value | it is a declared secret (never recorded) or a key added to the schema since the recording |
|
||||
| variable arrives wrong-typed in Python | pyinfra parsed the `--data` value; `str()` it before string ops |
|
||||
| error hides its stack | set `NOPY_DEBUG=1` |
|
||||
|
||||
## Core cube bundle
|
||||
|
||||
`@bitsquare/nopy-cubes-core` ships these cubes (snapshot — enumerate the
|
||||
installed bundle's `cubes/` directory for the authoritative list). Add it with
|
||||
`"cubePackages": ["@bitsquare/nopy-cubes-core"]` after installing it into the
|
||||
project.
|
||||
|
||||
| Id | Purpose |
|
||||
| --- | --- |
|
||||
| `admin:cockpit` | Cockpit web admin console |
|
||||
| `admin:hostname` | set the hostname |
|
||||
| `admin:locale` | configure system locale |
|
||||
| `apt:essentials` | baseline apt packages (git, curl, ufw, …) |
|
||||
| `apt:install` | install arbitrary apt packages |
|
||||
| `armor:fail2ban` | fail2ban hardening |
|
||||
| `armor:ssh` | SSH daemon hardening |
|
||||
| `armor:ufw` | UFW firewall rules |
|
||||
| `caddy` | Caddy web server base install |
|
||||
| `caddy:spa` | serve a single-page app via Caddy |
|
||||
| `git:clone` | clone a repository |
|
||||
| `net:tailscale` | install and authenticate Tailscale |
|
||||
| `net:wifi:access-point` | configure a Wi-Fi access point |
|
||||
| `net:wifi:connection` | join a Wi-Fi network |
|
||||
| `runtime:docker` | install Docker |
|
||||
| `runtime:nodevm` | install a Node.js runtime |
|
||||
| `service:autostart` | systemd autostart unit for a command |
|
||||
| `ssh:authorize` | authorize an SSH public key |
|
||||
| `ssh:keygen` | generate SSH keys |
|
||||
| `ssh:keyman` | deploy keys managed by keyman |
|
||||
| `user:add` | create a user (shell, groups, authorized key) |
|
||||
| `user:edit` | modify an existing user |
|
||||
|
||||
## Further reading
|
||||
|
||||
- Installed package README: full CLI walkthrough, secrets semantics, channels.
|
||||
- `docs/HOOKS.md`, `docs/CUBE-BUNDLES.md`, `docs/SESSION_FORMAT.md`,
|
||||
`docs/API.md` in the `@bitsquare/nopy` package.
|
||||
- pyinfra: <https://docs.pyinfra.com/> (operations, facts, global arguments,
|
||||
connectors).
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Tests for nopy.init module
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { CONFIG_FILENAME, loadConfig } from '../src/nopy.config.js';
|
||||
import { formatInitResults, GUIDE_FILENAME, initProject } from '../src/nopy.init.js';
|
||||
|
||||
describe('initProject', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// realpath: os.tmpdir() is a symlink on macOS, and paths reported back by
|
||||
// process.cwd() after a chdir are resolved — comparisons need one form.
|
||||
dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'nopy-init-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates both files in an empty directory', () => {
|
||||
const results = initProject({ dir });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.status)).toEqual(['created', 'created']);
|
||||
expect(results.map((r) => r.file)).toEqual([CONFIG_FILENAME, GUIDE_FILENAME]);
|
||||
for (const result of results) {
|
||||
expect(fs.existsSync(result.path)).toBe(true);
|
||||
expect(path.dirname(result.path)).toBe(dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes a config that parses and carries the starter shape', () => {
|
||||
initProject({ dir });
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(dir, CONFIG_FILENAME), 'utf-8'));
|
||||
expect(config.hosts).toEqual([]);
|
||||
expect(config.cubeDirs).toEqual(['./cubes']);
|
||||
expect(config.cubePackages).toEqual([]);
|
||||
expect(config.log.verbosity).toBe('info');
|
||||
});
|
||||
|
||||
it('writes a config that loadConfig accepts', () => {
|
||||
initProject({ dir });
|
||||
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const config = loadConfig();
|
||||
expect(config.cubeDirs).toContain(path.join(dir, 'cubes'));
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes the bundled guide', () => {
|
||||
initProject({ dir });
|
||||
|
||||
const guide = fs.readFileSync(path.join(dir, GUIDE_FILENAME), 'utf-8');
|
||||
expect(guide).toContain('# NOPY.LLM.md');
|
||||
expect(guide).toContain('pyinfra');
|
||||
expect(guide).toContain('.nopyrc.json');
|
||||
});
|
||||
|
||||
it('skips existing files without force', () => {
|
||||
fs.writeFileSync(path.join(dir, CONFIG_FILENAME), '{"hosts":["mine"]}');
|
||||
fs.writeFileSync(path.join(dir, GUIDE_FILENAME), 'my notes');
|
||||
|
||||
const results = initProject({ dir });
|
||||
|
||||
expect(results.map((r) => r.status)).toEqual(['skipped', 'skipped']);
|
||||
expect(fs.readFileSync(path.join(dir, CONFIG_FILENAME), 'utf-8')).toBe('{"hosts":["mine"]}');
|
||||
expect(fs.readFileSync(path.join(dir, GUIDE_FILENAME), 'utf-8')).toBe('my notes');
|
||||
});
|
||||
|
||||
it('overwrites existing files with force', () => {
|
||||
fs.writeFileSync(path.join(dir, GUIDE_FILENAME), 'my notes');
|
||||
|
||||
const results = initProject({ dir, force: true });
|
||||
|
||||
expect(results.map((r) => r.status)).toEqual(['created', 'overwritten']);
|
||||
expect(fs.readFileSync(path.join(dir, GUIDE_FILENAME), 'utf-8')).toContain('# NOPY.LLM.md');
|
||||
});
|
||||
|
||||
it('defaults to the working directory', () => {
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const results = initProject();
|
||||
expect(results.map((r) => path.dirname(r.path))).toEqual([dir, dir]);
|
||||
expect(fs.existsSync(path.join(dir, CONFIG_FILENAME))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInitResults', () => {
|
||||
it('reports created files and next steps', () => {
|
||||
const output = formatInitResults([
|
||||
{ file: CONFIG_FILENAME, path: `/x/${CONFIG_FILENAME}`, status: 'created' },
|
||||
{ file: GUIDE_FILENAME, path: `/x/${GUIDE_FILENAME}`, status: 'created' },
|
||||
]);
|
||||
|
||||
expect(output).toContain(`created`);
|
||||
expect(output).toContain(CONFIG_FILENAME);
|
||||
expect(output).toContain(GUIDE_FILENAME);
|
||||
expect(output).toContain('Next steps:');
|
||||
});
|
||||
|
||||
it('points skipped files at --force', () => {
|
||||
const output = formatInitResults([
|
||||
{ file: GUIDE_FILENAME, path: `/x/${GUIDE_FILENAME}`, status: 'skipped' },
|
||||
]);
|
||||
|
||||
expect(output).toContain('exists, skipped');
|
||||
expect(output).toContain('--force');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user