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:
Benjamin Diedrichsen
2026-09-02 13:06:42 +02:00
co-authored by Claude Fable 5
parent 4fa69cce0d
commit f1cc9effa0
9 changed files with 684 additions and 2 deletions
+13
View File
@@ -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
+29
View File
@@ -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
+1 -1
View File
@@ -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",
+3
View File
@@ -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';
+16
View File
@@ -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')
+1 -1
View File
@@ -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
+106
View File
@@ -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');
}
+392
View File
@@ -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).
+123
View File
@@ -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');
});
});