From f1cc9effa02a7d151ae29664f8d9043c3034aeb4 Mon Sep 17 00:00:00 2001 From: Benjamin Diedrichsen Date: Wed, 2 Sep 2026 13:06:42 +0200 Subject: [PATCH] nopy: add init command with bundled NOPY.LLM.md guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Ce5atB2tXDXyz2jd9s1bqE --- packages/nopy/README.md | 13 + packages/nopy/docs/API.md | 29 ++ packages/nopy/package.json | 2 +- packages/nopy/src/index.ts | 3 + packages/nopy/src/nopy.cli.ts | 16 + packages/nopy/src/nopy.config.ts | 2 +- packages/nopy/src/nopy.init.ts | 106 +++++++ packages/nopy/src/templates/NOPY.LLM.md | 392 ++++++++++++++++++++++++ packages/nopy/tests/init.test.ts | 123 ++++++++ 9 files changed, 684 insertions(+), 2 deletions(-) create mode 100644 packages/nopy/src/nopy.init.ts create mode 100644 packages/nopy/src/templates/NOPY.LLM.md create mode 100644 packages/nopy/tests/init.test.ts diff --git a/packages/nopy/README.md b/packages/nopy/README.md index 4b4c6cd..f25560c 100644 --- a/packages/nopy/README.md +++ b/packages/nopy/README.md @@ -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 diff --git a/packages/nopy/docs/API.md b/packages/nopy/docs/API.md index 63a5941..36e7455 100644 --- a/packages/nopy/docs/API.md +++ b/packages/nopy/docs/API.md @@ -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 diff --git a/packages/nopy/package.json b/packages/nopy/package.json index 1094ce1..c0052ca 100644 --- a/packages/nopy/package.json +++ b/packages/nopy/package.json @@ -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", diff --git a/packages/nopy/src/index.ts b/packages/nopy/src/index.ts index 6aec811..ab1a77f 100644 --- a/packages/nopy/src/index.ts +++ b/packages/nopy/src/index.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'; diff --git a/packages/nopy/src/nopy.cli.ts b/packages/nopy/src/nopy.cli.ts index fd2bd0a..4dd418c 100644 --- a/packages/nopy/src/nopy.cli.ts +++ b/packages/nopy/src/nopy.cli.ts @@ -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 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') diff --git a/packages/nopy/src/nopy.config.ts b/packages/nopy/src/nopy.config.ts index 11d5945..56cc3b3 100644 --- a/packages/nopy/src/nopy.config.ts +++ b/packages/nopy/src/nopy.config.ts @@ -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 diff --git a/packages/nopy/src/nopy.init.ts b/packages/nopy/src/nopy.init.ts new file mode 100644 index 0000000..9b35715 --- /dev/null +++ b/packages/nopy/src/nopy.init.ts @@ -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'); +} diff --git a/packages/nopy/src/templates/NOPY.LLM.md b/packages/nopy/src/templates/NOPY.LLM.md new file mode 100644 index 0000000..e03a6c0 --- /dev/null +++ b/packages/nopy/src/templates/NOPY.LLM.md @@ -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 `) 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 , --registry ) +``` + +`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 ` | replay a specific history entry (`nopy history` shows ids) | +| `-s, --save-session ` | record the run to a session file | +| `-l, --load-session ` | 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": { "": "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: +. + +## 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/`** — 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/`** — deploys into a Vagrant machine. + +Connector strings can be written directly into `hosts`. Auth methods: password +(prompts for user + password; becomes `--user --password

`, 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 -y [-v|-vv|-vvv] [--debug] [--user U --password P] \ + --data KEY=value ... --chdir /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 '' 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: ` | required keys with no default; set them under `env`, pass from a dependency, or drop `-D` | +| replay aborts `Cube not found: ` | 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: (operations, facts, global arguments, + connectors). diff --git a/packages/nopy/tests/init.test.ts b/packages/nopy/tests/init.test.ts new file mode 100644 index 0000000..229fe1b --- /dev/null +++ b/packages/nopy/tests/init.test.ts @@ -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'); + }); +});