6 Commits
Author SHA1 Message Date
Benjamin Diedrichsen 37566873cc release: @bitsquare/keyman@0.7.3, @bitsquare/nopy-cubes@1.0.2, @bitsquare/nopy@1.0.2, @bitsquare/nopy-cubes-core@1.0.2
Release / release (push) Successful in 1m21s
Publish snapshot / snapshot (push) Skipped
2026-09-02 14:03:38 +02:00
Benjamin Diedrichsen 70c3d1e36e remove cockpit cube
Publish snapshot / snapshot (push) Successful in 1m6s
2026-09-02 14:00:44 +02:00
Benjamin DiedrichsenandClaude Fable 5 8973ff7113 nopy: add create-cube command scaffolding a cube from bundled templates
Publish snapshot / snapshot (push) Successful in 1m17s
Gathers id, name and directory from flags or prompts (only what the flags
do not supply), then writes manifest.mjs + deploy.py from templates under
src/templates/cube. Templates are named *.example.* so the template
directory itself can never match the loader's manifest+deploy pair rule.

The scaffold refuses a directory that already holds cube files by the
loader's own patterns, checks the id against the loaded cube set (best
effort, exempting the target directory so --force re-scaffolds work), and
warns when the target lands outside every configured cube directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuW1MMGK2pUQ9mTVRTxc5
2026-09-02 13:57:51 +02:00
Benjamin DiedrichsenandClaude Opus 5 568d4c83ff keyman: replace removed inquirer prompt type 'list' with 'select'
inquirer v10 removed the legacy 'list' prompt in favour of 'select', so
every list-style prompt — starting with the main menu — died with
"Prompt type \"list\" is not registered" on any real run against the
declared ^14 dependency. The tests never saw it because they all mock
inquirer.prompt, which accepts any type string. Choice shapes and the
'default' option are unchanged; 'select' takes them as-is.

Bump to 0.7.2 to ship the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ce5atB2tXDXyz2jd9s1bqE
2026-09-02 13:50:59 +02:00
Benjamin DiedrichsenandClaude Fable 5 643d7379ba cubes: user:add gets optional PUBKEY, space-separated GROUPS, exists guard
PUBKEY defaults to empty now — empty means no key is authorised, and some
users need none. GROUPS was always split on whitespace by deploy.py, so the
comma-separated prompt label and README were documenting a bug; both now say
space-separated. The deploy script checks the Users fact up front and noops
when the user exists, since rerunning reset the password and overwrote
~/.config/fish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ce5atB2tXDXyz2jd9s1bqE
2026-09-02 13:36:51 +02:00
Benjamin DiedrichsenandClaude Fable 5 f1cc9effa0 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
2026-09-02 13:06:42 +02:00
29 changed files with 1423 additions and 186 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@bitsquare/keyman", "name": "@bitsquare/keyman",
"version": "0.7.1", "version": "0.7.3",
"description": "A system to simplify ssh key management", "description": "A system to simplify ssh key management",
"keywords": [ "keywords": [
"ssh", "ssh",
+1 -1
View File
@@ -22,7 +22,7 @@ export async function copyKey(sshDir: string, tmpDir: string) {
const { selectedKey } = await inquirer.prompt<{ selectedKey: string }>([ const { selectedKey } = await inquirer.prompt<{ selectedKey: string }>([
{ {
type: 'list', type: 'select',
name: 'selectedKey', name: 'selectedKey',
message: 'Select key to copy public key from:', message: 'Select key to copy public key from:',
choices: keys, choices: keys,
+1 -1
View File
@@ -31,7 +31,7 @@ export async function decryptKeys(sshDir: string, keysDir: string, tmpDir: strin
choices: vaultKeys, choices: vaultKeys,
}, },
{ {
type: 'list', type: 'select',
name: 'decryptMode', name: 'decryptMode',
message: 'Choose decryption location:', message: 'Choose decryption location:',
// Named after the directories actually in use, which are configurable. // Named after the directories actually in use, which are configurable.
+1 -1
View File
@@ -21,7 +21,7 @@ export interface KeyOptions {
export async function promptKeyOptions(defaultIdentity?: string): Promise<KeyOptions> { export async function promptKeyOptions(defaultIdentity?: string): Promise<KeyOptions> {
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([ const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
{ {
type: 'list', type: 'select',
name: 'algorithm', name: 'algorithm',
message: 'Select algorithm:', message: 'Select algorithm:',
choices: ['ed25519', 'rsa'], choices: ['ed25519', 'rsa'],
+1 -1
View File
@@ -67,7 +67,7 @@ export async function keyman() {
// 🔹 Show category selection // 🔹 Show category selection
const { category } = await inquirer.prompt<{ category: string }>([ const { category } = await inquirer.prompt<{ category: string }>([
{ {
type: 'list', type: 'select',
name: 'category', name: 'category',
message: 'Select operation:', message: 'Select operation:',
choices: [ choices: [
+2 -2
View File
@@ -120,7 +120,7 @@ export async function rotateKey(
const { key } = await inquirer.prompt<{ key: string }>([ const { key } = await inquirer.prompt<{ key: string }>([
{ {
type: 'list', type: 'select',
name: 'key', name: 'key',
message: 'Select the key to rotate:', message: 'Select the key to rotate:',
choices: vaultKeys, choices: vaultKeys,
@@ -180,7 +180,7 @@ export async function retireKey(sshDir: string, keysDir: string, tmpDir: string)
const { key } = await inquirer.prompt<{ key: string }>([ const { key } = await inquirer.prompt<{ key: string }>([
{ {
type: 'list', type: 'select',
name: 'key', name: 'key',
message: 'Select the key to retire:', message: 'Select the key to retire:',
choices: vaultKeys, choices: vaultKeys,
@@ -1,76 +0,0 @@
# cockpit
**Install Cockpit web-based server management interface**
## Purpose
This cube installs Cockpit, a powerful web-based interface for managing Linux servers, making system administration accessible through your browser.
## What is Cockpit?
Cockpit is a modern, interactive server admin interface that runs in your web browser. It provides:
- **Real-time monitoring**: CPU, memory, disk, and network usage graphs
- **Container management**: View and manage Docker containers
- **Service management**: Start, stop, and manage systemd services
- **Storage administration**: Manage disks, RAID, and filesystems
- **Network configuration**: Configure network interfaces and firewall
- **Terminal access**: Built-in terminal for command-line access
- **User management**: Create and manage user accounts
- **Software updates**: View and apply system updates
Think of it as a control panel for your Linux server - all accessible from any web browser.
## What This Cube Does
1. Installs the `cockpit` package
2. Installs `sscg` (Simple Signed Certificate Generator) for HTTPS support
3. Starts the Cockpit service
4. Makes Cockpit accessible on port 9090
## Configuration
This cube currently has no configurable parameters.
## Dependencies
None - this cube can run standalone.
## Post-Installation
Access Cockpit by navigating to:
```
https://your-server-ip:9090
```
Login with any valid system user account (e.g., root or a user created with the `user-add` cube).
### Security Notes
- Cockpit uses HTTPS by default (self-signed certificate)
- Your browser will show a security warning on first access (expected with self-signed certs)
- Consider using UFW to restrict access: `sudo ufw allow from YOUR_IP to any port 9090`
- Disable Cockpit when not in use: `sudo systemctl stop cockpit.socket`
## Common Use Cases
- Monitor server performance in real-time
- Manage Docker containers without command-line
- View system logs and troubleshoot issues
- Configure network settings
- Apply system updates
- Manage storage and filesystems
## Managing Cockpit
Start/stop Cockpit:
```bash
sudo systemctl start cockpit.socket
sudo systemctl stop cockpit.socket
sudo systemctl status cockpit.socket
```
Disable Cockpit from starting on boot:
```bash
sudo systemctl disable cockpit.socket
```
@@ -1,13 +0,0 @@
from pyinfra import host
from pyinfra.operations import server, apt
apt.packages(
packages=[ "sscg cockpit"],
present=True,
_sudo=True
)
server.service(
'cockpit',
running=True,
)
@@ -1,7 +0,0 @@
import { Manifest } from '@bitsquare/nopy-cubes';
export default Manifest({
id: 'admin:cockpit',
name: 'Install cockpit and utils',
dependencies: () => [],
});
@@ -47,22 +47,19 @@ This cube creates a new user account with a modern shell environment (Fish), SSH
credential nobody had seen, and replaying that run produced a different one. credential nobody had seen, and replaying that run produced a different one.
- **GROUPS** (string, default: `''`) - **GROUPS** (string, default: `''`)
- Comma-separated list of additional groups (e.g., `"docker,sudo"`) - Space-separated list of additional groups (e.g., `"docker sudo"`)
- Common groups: - Common groups:
- `docker` - Run Docker without sudo - `docker` - Run Docker without sudo
- `sudo` - Administrative privileges - `sudo` - Administrative privileges
- `www-data` - Web server file access - `www-data` - Web server file access
- **PUBKEY** (string, **required** — no default) - **PUBKEY** (string, default: `''`)
- SSH public key to authorize for the user - SSH public key to authorize for the user
- Should be your public key for passwordless SSH access - Empty (the default) authorizes no key at all — the account is created with
- There is deliberately no default. It used to be a specific personal key, so password login only. Some users simply do not need one.
accepting the default authorized *someone else's* key on the new account. - The default is deliberately empty, never a specific key. It used to be a
No key would be a sensible guess, so the cube asks instead. personal key, so accepting the default authorized *someone else's* key on
- Because it is required, `--use-defaults` refuses to run this cube unless the new account.
`PUBKEY` comes from `env` in `.nopyrc.json`, a dependency, or a hook.
- Submitting an empty value at the prompt authorizes no key at all (the account
is still created, with password login only).
## Dependencies ## Dependencies
@@ -87,6 +84,9 @@ After deployment:
## Notes ## Notes
- If the user already exists, the cube does nothing at all — rerunning it would
reset the password and overwrite `~/.config/fish`, so an existing account is
left untouched.
- The user's home directory is created at `/home/{USER}` - The user's home directory is created at `/home/{USER}`
- Fish configuration is stored in `/home/{USER}/.config/fish/` - Fish configuration is stored in `/home/{USER}/.config/fish/`
- Oh My Fish provides package management: `omf install <package>` - Oh My Fish provides package management: `omf install <package>`
@@ -1,6 +1,6 @@
from pyinfra import host from pyinfra import host
from pyinfra.operations import server, files, apt from pyinfra.operations import server, files, apt
from io import StringIO from pyinfra.facts.server import Users
# Define the username, password, and public key for the new admin user # Define the username, password, and public key for the new admin user
USER = host.data.USER USER = host.data.USER
@@ -11,22 +11,28 @@ PASSWORD = host.data.PASSWORD
# line, so an absent key means no key rather than a blank one. # line, so an absent key means no key rather than a blank one.
PUBKEY = host.data.PUBKEY PUBKEY = host.data.PUBKEY
PUBKEYS = [PUBKEY] if PUBKEY and str(PUBKEY).strip() else [] PUBKEYS = [PUBKEY] if PUBKEY and str(PUBKEY).strip() else []
GROUPS = list(filter(None, map(str.strip, str(host.data.GROUPS).split()))) GROUPS = str(host.data.GROUPS).split()
FISH_PATH = "/usr/bin/fish" FISH_PATH = "/usr/bin/fish"
FISH_CONFIG_DIR = f"{HOME_DIR}/.config/fish" FISH_CONFIG_DIR = f"{HOME_DIR}/.config/fish"
FISH_CONFIG_FILE = f"{FISH_CONFIG_DIR}/config.fish" FISH_CONFIG_FILE = f"{FISH_CONFIG_DIR}/config.fish"
FISH_RC_DIR = f"{FISH_CONFIG_DIR}/rc" FISH_RC_DIR = f"{FISH_CONFIG_DIR}/rc"
SSH_AGENT_SCRIPT = f"{FISH_RC_DIR}/ssh-agent.fish" SSH_AGENT_SCRIPT = f"{FISH_RC_DIR}/ssh-agent.fish"
apt.packages( # An existing user is left entirely alone — everything below would reset the
# password and overwrite ~/.config/fish, clobbering whatever the user has
# changed since their account was created.
if host.get_fact(Users).get(USER):
host.noop(f"User {USER} already exists")
else:
apt.packages(
name='Ensure fish shell is installed', name='Ensure fish shell is installed',
packages=[ 'fish'], packages=[ 'fish'],
_sudo=True _sudo=True
) )
# Ensure the user exists with a login shell # Ensure the user exists with a login shell
server.user( server.user(
name=f"Create user {USER} [{GROUPS}]", name=f"Create user {USER} [{GROUPS}]",
present=True, present=True,
user=USER, user=USER,
@@ -36,9 +42,9 @@ server.user(
shell=FISH_PATH, shell=FISH_PATH,
public_keys=PUBKEYS, public_keys=PUBKEYS,
_sudo=True _sudo=True
) )
for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]: for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]:
files.directory( files.directory(
name=f"Ensure {dir} directory exists", name=f"Ensure {dir} directory exists",
path=dir, path=dir,
@@ -51,16 +57,16 @@ for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]:
_use_sudo_login=True _use_sudo_login=True
) )
files.file( files.file(
name="Ensure .ssh/config exists", name="Ensure .ssh/config exists",
path=f"{HOME_DIR}/.ssh/config", path=f"{HOME_DIR}/.ssh/config",
present=True, present=True,
user=USER, user=USER,
group=USER, group=USER,
_sudo=True _sudo=True
) )
server.shell( server.shell(
name=f"Install OMF(Oh My Fish) for {USER}", name=f"Install OMF(Oh My Fish) for {USER}",
commands=[ commands=[
f"curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install > install-omf", f"curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install > install-omf",
@@ -69,9 +75,9 @@ server.shell(
_sudo=True, _sudo=True,
_sudo_user=USER, _sudo_user=USER,
_use_sudo_login=True _use_sudo_login=True
) )
files.put( files.put(
name="Add SSH agent auto-load script to Fish rc directory", name="Add SSH agent auto-load script to Fish rc directory",
src="ssh-agent.fish", src="ssh-agent.fish",
dest=SSH_AGENT_SCRIPT, dest=SSH_AGENT_SCRIPT,
@@ -80,9 +86,9 @@ files.put(
mode="755", # Make it executable mode="755", # Make it executable
_sudo=True, _sudo=True,
) )
files.put( files.put(
name="Add custom config.fish", name="Add custom config.fish",
src="config.fish", src="config.fish",
dest=FISH_CONFIG_FILE, dest=FISH_CONFIG_FILE,
@@ -90,4 +96,4 @@ files.put(
group=USER, group=USER,
mode="755", # Make it executable mode="755", # Make it executable
_sudo=True, _sudo=True,
) )
@@ -17,12 +17,14 @@ export default Manifest({
PASSWORD: z.string().describe('Password for the new user account').default('changeme'), PASSWORD: z.string().describe('Password for the new user account').default('changeme'),
GROUPS: z GROUPS: z
.string() .string()
.describe('Comma-separated list of additional groups (e.g., "docker,sudo")') .describe('Space-separated list of additional groups (e.g., "docker sudo")')
.default(''),
// Empty by default, never a specific key: this used to carry a personal
// key, which meant an unattended run authorised someone else's key on the
// new account. Empty means no key is authorised — some users need none.
PUBKEY: z
.string()
.describe('SSH public key to authorize for the user (empty for none)')
.default(''), .default(''),
// No default on purpose. This used to carry a specific personal key, which
// meant an unattended run authorised someone else's key on the new account.
// Leaving it required makes `--use-defaults` refuse by name instead of
// guessing, and there is no key that would be a sensible guess.
PUBKEY: z.string().describe('SSH public key to authorize for the user'),
}), }),
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@bitsquare/nopy-cubes-core", "name": "@bitsquare/nopy-cubes-core",
"version": "1.0.1", "version": "1.0.2",
"description": "The core nopy cube bundle: apt, users, ssh, networking, services and runtimes.", "description": "The core nopy cube bundle: apt, users, ssh, networking, services and runtimes.",
"keywords": [ "keywords": [
"nopy", "nopy",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@bitsquare/nopy-cubes", "name": "@bitsquare/nopy-cubes",
"version": "1.0.1", "version": "1.0.2",
"description": "Authoring types for nopy cubes: the Manifest factory and the Cube contract.", "description": "Authoring types for nopy cubes: the Manifest factory and the Cube contract.",
"keywords": [ "keywords": [
"nopy", "nopy",
+13
View File
@@ -501,6 +501,19 @@ pnpm --filter @bitsquare/nopy run nopy # runs the CLI from source via tsx
### Basic Commands ### 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)**: **Install cubes (default command)**:
```bash ```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) - [Workflow Module](#workflow-module)
- [Session Module](#session-module) - [Session Module](#session-module)
- [History Module](#history-module) - [History Module](#history-module)
- [Init Module](#init-module)
- [Config Module](#config-module) - [Config Module](#config-module)
- [Prompts Module](#prompts-module) - [Prompts Module](#prompts-module)
- [Update Module](#update-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 ## Config Module
### `NopyConfig` ### `NopyConfig`
@@ -1110,6 +1138,7 @@ do. Returns `{status, command, ran}`; `ran` is `false` for `dryRun`, and for
## CLI Usage ## CLI Usage
```bash ```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 # interactive (the default command; `nopy` alone works, as does `nopy i`)
nopy install -D # use defaults, no variable prompts nopy install -D # use defaults, no variable prompts
nopy install -K # force SSH key auth nopy install -K # force SSH key auth
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@bitsquare/nopy", "name": "@bitsquare/nopy",
"version": "1.0.1", "version": "1.0.2",
"description": "A system to simplify pyinfra script management and execution.", "description": "A system to simplify pyinfra script management and execution.",
"keywords": [ "keywords": [
"pyinfra", "pyinfra",
@@ -43,7 +43,7 @@
}, },
"scripts": { "scripts": {
"clean": "rm -rf dist .tsbuildinfo", "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 -R src/templates/. dist/templates/",
"prepack": "pnpm run build", "prepack": "pnpm run build",
"link:local": "pnpm run build && npm link", "link:local": "pnpm run build && npm link",
"nopy": "tsx src/nopy.cli.ts", "nopy": "tsx src/nopy.cli.ts",
+15
View File
@@ -22,6 +22,18 @@ export type {
} from './nopy.config.js'; } from './nopy.config.js';
// Configuration // Configuration
export { getConfigPaths, loadConfig, logConfigToFlags, saveConfig } from './nopy.config.js'; export { getConfigPaths, loadConfig, logConfigToFlags, saveConfig } from './nopy.config.js';
export type { CreateCubeOptions } from './nopy.create-cube.js';
// Cube scaffolding
export {
assertCubeIdAvailable,
createCube,
cubeDirWarning,
DEPLOY_FILENAME,
formatCreateCubeResults,
MANIFEST_FILENAME,
suggestCubeDir,
validateCubeId,
} from './nopy.create-cube.js';
// Backwards compatibility - cubes namespace // Backwards compatibility - cubes namespace
export { cubes } from './nopy.cubes.js'; export { cubes } from './nopy.cubes.js';
export type { export type {
@@ -62,6 +74,9 @@ export {
removeFromHistory, removeFromHistory,
saveHistory, saveHistory,
} from './nopy.history.js'; } 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'; export type { NopyOptions, NopyResult } from './nopy.main.js';
// Main entry point // Main entry point
export { nopy } from './nopy.main.js'; export { nopy } from './nopy.main.js';
+64
View File
@@ -7,7 +7,15 @@
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { Command } from 'commander'; import { Command } from 'commander';
import type { NopyConfig } from './nopy.config.js';
import { loadConfig } from './nopy.config.js'; import { loadConfig } from './nopy.config.js';
import {
assertCubeIdAvailable,
createCube,
cubeDirWarning,
formatCreateCubeResults,
suggestCubeDir,
} from './nopy.create-cube.js';
import { reportError } from './nopy.errors.js'; import { reportError } from './nopy.errors.js';
import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js'; import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js';
import { import {
@@ -17,7 +25,9 @@ import {
getSessionById, getSessionById,
listHistory, listHistory,
} from './nopy.history.js'; } from './nopy.history.js';
import { formatInitResults, initProject } from './nopy.init.js';
import { nopy } from './nopy.main.js'; import { nopy } from './nopy.main.js';
import { CubeScaffoldPrompts } from './nopy.prompts.js';
import type { Channel } from './nopy.update.js'; import type { Channel } from './nopy.update.js';
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js'; import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
@@ -58,6 +68,8 @@ program
'after', 'after',
` `
Examples: Examples:
$ nopy init Set up this directory (.nopyrc.json + NOPY.LLM.md)
$ nopy create-cube Scaffold a new cube (manifest.mjs + deploy.py)
$ nopy Interactive cube selection and deployment $ nopy Interactive cube selection and deployment
$ nopy -R Repeat the last deployment session $ nopy -R Repeat the last deployment session
$ nopy -H <id> Run a specific session from history $ nopy -H <id> Run a specific session from history
@@ -162,6 +174,58 @@ 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('create-cube [dir]')
.description('Scaffold a new cube (manifest.mjs + deploy.py) from the bundled templates')
.option('--id <id>', 'Cube id, e.g. net:tailscale')
.option('--name <name>', 'Human-readable cube name')
.option('-f, --force', 'Overwrite existing cube files')
.action(async (dirArg: string | undefined, options) => {
try {
// Config is optional here, unlike `install`: create-cube works in a bare
// directory too; the config only improves the suggested location.
let config: NopyConfig | undefined;
try {
config = loadConfig();
} catch {
config = undefined;
}
const answers = await CubeScaffoldPrompts(
{ id: options.id, name: options.name, dir: dirArg },
(id) => suggestCubeDir(id, config)
);
await assertCubeIdAvailable(answers.id, answers.dir);
const results = createCube({ ...answers, force: options.force });
console.log(
formatCreateCubeResults(results, {
id: answers.id,
warning: cubeDirWarning(answers.dir),
})
);
} catch (error) {
if (isCancellation(error)) exitWithFarewell();
reportError(error);
process.exit(1);
}
});
program program
.command('history') .command('history')
.description('List session history') .description('List session history')
+1 -1
View File
@@ -129,7 +129,7 @@ const DEFAULT_CONFIG: NopyConfig = {
env: {}, env: {},
}; };
const CONFIG_FILENAME = '.nopyrc.json'; export const CONFIG_FILENAME = '.nopyrc.json';
/** /**
* Finds all config files by traversing upwards from cwd to root * Finds all config files by traversing upwards from cwd to root
+210
View File
@@ -0,0 +1,210 @@
/**
* Cube scaffolding — `nopy create-cube`
* @module nopy.create-cube
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { findCubeDirectories, loadCubes } from './cubes/index.js';
import type { NopyConfig } from './nopy.config.js';
import { NopyUsageError } from './nopy.errors.js';
import { type InitFileResult, writeGuarded } from './nopy.init.js';
/** What the scaffold writes — the loader's two exact-name candidates. */
export const MANIFEST_FILENAME = 'manifest.mjs';
export const DEPLOY_FILENAME = 'deploy.py';
/**
* Templates resolved relative to this module, so the same path works from
* `src/` (tsx, vitest) and from `dist/` (the build copies `src/templates`
* alongside). Named `*.example.*` because the loader declares any directory
* holding a manifest **and** a deploy script a cube — under their real names
* the template directory itself would be one, and a `cubeDirs` entry sweeping
* this package would deploy the template.
*/
const TEMPLATES: Record<string, URL> = {
[MANIFEST_FILENAME]: new URL('./templates/cube/manifest.example.mjs', import.meta.url),
[DEPLOY_FILENAME]: new URL('./templates/cube/deploy.example.py', import.meta.url),
};
const CUBE_ID_PATTERN = /^[a-z0-9][a-z0-9_.:-]*$/i;
/**
* Why `id` cannot name a cube, or `undefined` when it can. Returns the message
* rather than throwing so a prompt can use it as an inline `validate` while
* {@link createCube} turns it into the error it is.
*/
export function validateCubeId(id: string): string | undefined {
if (!id.trim()) return 'Cube id is required';
if (!CUBE_ID_PATTERN.test(id)) {
return `Cube id may hold letters, digits and ":-_." — like "net:tailscale" or "apt"`;
}
return undefined;
}
/**
* Where the prompt suggests putting a cube: the first configured cube
* directory (falling back to `./cubes`) plus the id with each `:` segment as a
* subdirectory, so `net:tailscale` lands in `cubes/net/tailscale`. Ids are
* flat and need not mirror the path — this is a suggestion, not a rule.
* Returned relative to the working directory when it is under it, because
* that is the form a prompt default should show.
*/
export function suggestCubeDir(id: string, config?: Pick<NopyConfig, 'cubeDirs'>): string {
const base = config?.cubeDirs?.[0] ?? path.resolve(process.cwd(), 'cubes');
const target = path.join(base, ...id.split(':').filter(Boolean));
const relative = path.relative(process.cwd(), target);
return relative.startsWith('..') ? target : relative;
}
/**
* Files that already make `dir` a cube, by the loader's own patterns — not
* just the two exact names the scaffold writes. A `foo.manifest.mjs` already
* present would leave the directory with two manifests and the loader picking
* whichever `readdir` returns first, so it has to block the scaffold too.
*/
function existingCubeFiles(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter(
(name) =>
name === MANIFEST_FILENAME ||
name.endsWith('.manifest.mjs') ||
name === DEPLOY_FILENAME ||
name.endsWith('.deploy.py')
)
.sort();
}
/**
* Escapes a value for splicing into a single-quoted string literal in the
* manifest template — the cube name is free text, and an apostrophe in it
* must not produce a manifest that does not parse.
*/
function jsEscape(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
export interface CreateCubeOptions {
/** Cube id, e.g. `net:tailscale`. */
id: string;
/** Human-readable name, shown in the cube list. */
name: string;
/** Target directory; created if missing. Relative paths resolve against cwd. */
dir: string;
/** Overwrite existing cube files. */
force?: boolean;
}
/**
* Refuses an id another cube already claims — at creation time, rather than
* as the loader's hard duplicate error on the next run. Best-effort: no
* config, or a loader that cannot run, skips the check (the loader still
* catches the collision later). A claim by the target directory itself is the
* `--force` re-scaffold case, not a collision.
*/
export async function assertCubeIdAvailable(id: string, dir: string): Promise<void> {
let cubes: Awaited<ReturnType<typeof loadCubes>>['cubes'];
try {
({ cubes } = await loadCubes());
} catch {
return;
}
const claimant = cubes[id];
if (!claimant || path.resolve(claimant.dir) === path.resolve(dir)) return;
const from =
claimant.source.type === 'package' ? `package ${claimant.source.packageName}` : claimant.dir;
throw new NopyUsageError(`Cube id "${id}" is already claimed by ${from}.`);
}
/**
* The hint when a cube lands where the loader will never look, or `undefined`
* when it is discoverable (or there is no config to consult — a bare
* directory gets the next-steps line about `.nopyrc.json` instead of a
* warning about one that does not exist).
*/
export function cubeDirWarning(dir: string): string | undefined {
let roots: string[];
try {
roots = findCubeDirectories();
} catch {
return undefined;
}
const target = path.resolve(dir);
const inside = roots.some((root) => {
const relative = path.relative(path.resolve(root), target);
return !relative.startsWith('..') && !path.isAbsolute(relative);
});
if (inside) return undefined;
return (
`Note: ${dir} is outside every configured cube directory — ` +
'add it to "cubeDirs" in .nopyrc.json or nopy will not find it.'
);
}
/**
* Scaffolds a cube directory from the bundled templates: `manifest.mjs` with
* the id and name spliced in, plus a minimal `deploy.py`. The result is
* loadable as-is; the schema is an example to replace.
*/
export function createCube(options: CreateCubeOptions): InitFileResult[] {
const idError = validateCubeId(options.id);
if (idError) throw new NopyUsageError(idError);
if (!options.name.trim()) throw new NopyUsageError('Cube name is required');
const dir = path.resolve(options.dir);
const force = options.force ?? false;
const existing = existingCubeFiles(dir);
if (existing.length > 0 && !force) {
throw new NopyUsageError(
`${dir} already holds cube files (${existing.join(', ')}). ` +
`Use --force to overwrite ${MANIFEST_FILENAME} and ${DEPLOY_FILENAME}.`
);
}
fs.mkdirSync(dir, { recursive: true });
return Object.entries(TEMPLATES).map(([filename, url]) => {
// Function replacements, so a `$` in a cube name is never expanded as a
// replacement pattern.
const content = fs
.readFileSync(fileURLToPath(url), 'utf-8')
.replace(/__CUBE_ID__/g, () => jsEscape(options.id))
.replace(/__CUBE_NAME__/g, () => jsEscape(options.name));
return writeGuarded(path.join(dir, filename), content, force);
});
}
/**
* The report `create-cube` prints. Lives here rather than in the CLI because
* the CLI is excluded from coverage.
*/
export function formatCreateCubeResults(
results: InitFileResult[],
options: { id: string; warning?: string }
): 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. Declare the cube's variables in ${MANIFEST_FILENAME} — the schema is an example`,
` 2. Write the deployment in ${DEPLOY_FILENAME}; every schema key arrives on host.data`,
` 3. Run \`nopy\` and select ${options.id}`
);
if (options.warning) lines.push('', options.warning);
return lines.join('\n');
}
+111
View File
@@ -0,0 +1,111 @@
/**
* 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;
}
/**
* Writes `filePath` unless it already exists and `force` is unset, and says
* which of the three it was. Shared with `create-cube`, which scaffolds under
* the same skip/overwrite rules.
*/
export 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');
}
+50
View File
@@ -9,6 +9,7 @@ import inquirer from 'inquirer';
import type { z } from 'zod'; import type { z } from 'zod';
import { type AnyObjectSchema, type Cube, zodInner, zodKind } from './cubes/index.js'; import { type AnyObjectSchema, type Cube, zodInner, zodKind } from './cubes/index.js';
import type { Variables } from './nopy.common.js'; import type { Variables } from './nopy.common.js';
import { validateCubeId } from './nopy.create-cube.js';
interface CubeChoice { interface CubeChoice {
/** Submitted value — enquirer returns the `name` of each selected choice. */ /** Submitted value — enquirer returns the `name` of each selected choice. */
@@ -202,6 +203,55 @@ export async function HostSelection(hosts: string[]): Promise<string> {
return selectedHost.customHost ?? selectedHost.host; return selectedHost.customHost ?? selectedHost.host;
} }
/** What `create-cube` needs to know before it can scaffold. */
export interface CubeScaffoldAnswers {
id: string;
name: string;
dir: string;
}
/**
* Asks for whatever `create-cube` was not already told on the command line —
* a flag that was given is never re-asked. The directory default is derived
* from the id, which may itself have just been typed, hence the function
* rather than a precomputed value.
*/
export async function CubeScaffoldPrompts(
given: Partial<CubeScaffoldAnswers>,
suggestDir: (id: string) => string
): Promise<CubeScaffoldAnswers> {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'id',
message: 'Cube id (flat, e.g. net:tailscale):',
when: () => !given.id,
validate: (value: string) => validateCubeId(value) ?? true,
},
{
type: 'input',
name: 'name',
message: 'Cube name (the label shown in the cube list):',
when: () => !given.name,
validate: (value: string) => value.trim().length > 0 || 'Required',
},
{
type: 'input',
name: 'dir',
message: 'Directory to scaffold:',
when: () => !given.dir,
default: (soFar: { id?: string }) => suggestDir(given.id ?? soFar.id ?? ''),
validate: (value: string) => value.trim().length > 0 || 'Required',
},
]);
return {
id: given.id ?? answers.id,
name: given.name ?? answers.name,
dir: given.dir ?? answers.dir,
};
}
/** /**
* Turns a form answer — always a string — back into what the schema declares. * Turns a form answer — always a string — back into what the schema declares.
* *
+398
View File
@@ -0,0 +1,398 @@
# 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 create-cube [dir] scaffold a cube (manifest.mjs + deploy.py); prompts for
what --id and --name do not supply (-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
`nopy create-cube --id myapp:caddy-site --name "Serve the app behind Caddy"`
scaffolds the layout below with a loadable example schema to replace — fully
non-interactive when both flags and the directory argument are given.
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,14 @@
# __CUBE_ID__ — __CUBE_NAME__
#
# Runs with the cube directory as its working directory. Every key in the
# manifest's schema arrives on host.data, already parsed by pyinfra — a
# boolean is a bool and a numeric string an int, not a string.
from pyinfra import host
from pyinfra.operations import server
GREETING = host.data.GREETING
server.shell(
name="Print the greeting",
commands=[f"echo '{GREETING}'"],
)
@@ -0,0 +1,15 @@
import { Manifest } from '@bitsquare/nopy-cubes';
import { z } from 'zod';
export default Manifest({
id: '__CUBE_ID__',
name: '__CUBE_NAME__',
// dependencies: () => ['apt:essentials'], // cubes to deploy first
// secrets: ['API_TOKEN'], // schema keys to mask and never persist
schema: z.object({
GREETING: z
.string()
.describe('Message the deploy prints on the host')
.default('hello from __CUBE_ID__'),
}),
});
+235
View File
@@ -0,0 +1,235 @@
/**
* Tests for nopy.create-cube.
*
* The contract under test is not "two files appear" but "the loader accepts
* what the scaffold wrote": the round-trip through `loadCubes()` is what
* proves the templates and the loader agree.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { loadCubes } from '../src/cubes/index.js';
import {
assertCubeIdAvailable,
createCube,
cubeDirWarning,
DEPLOY_FILENAME,
formatCreateCubeResults,
MANIFEST_FILENAME,
suggestCubeDir,
validateCubeId,
} from '../src/nopy.create-cube.js';
import { NopyUsageError } from '../src/nopy.errors.js';
let tmpDir: string;
let originalCwd: string;
let originalHome: string | undefined;
beforeEach(() => {
originalCwd = process.cwd();
// realpath: os.tmpdir() is a symlink on macOS, and paths reported back by
// process.cwd() after a chdir are resolved — comparisons need one form.
tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'nopy-create-cube-'));
process.chdir(tmpDir);
// Point HOME at an empty directory so a developer's ~/.nopyrc.json cannot
// leak extra cube roots into the "no config anywhere" assertions.
originalHome = process.env.HOME;
process.env.HOME = tmpDir;
});
afterEach(() => {
process.chdir(originalCwd);
process.env.HOME = originalHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeConfig(config: Record<string, unknown>, dir = tmpDir): void {
fs.writeFileSync(path.join(dir, '.nopyrc.json'), JSON.stringify(config));
}
describe('validateCubeId', () => {
it('accepts the shapes the core bundle uses', () => {
for (const id of ['apt', 'net:tailscale', 'user:add', 'a1-b_c.d']) {
expect(validateCubeId(id)).toBeUndefined();
}
});
it('names the problem for ids the loader or shell would choke on', () => {
for (const id of ['', ' ', ':leading', 'has space', 'net/tailscale', '[bracketed]']) {
expect(validateCubeId(id)).toBeTypeOf('string');
}
});
});
describe('createCube', () => {
it('writes a manifest and deploy script with every token replaced', () => {
const dir = path.join(tmpDir, 'cubes', 'net', 'hello');
const results = createCube({ id: 'net:hello', name: 'Say hello', dir });
expect(results.map((r) => r.status)).toEqual(['created', 'created']);
expect(results.map((r) => r.file)).toEqual([MANIFEST_FILENAME, DEPLOY_FILENAME]);
for (const file of [MANIFEST_FILENAME, DEPLOY_FILENAME]) {
const content = fs.readFileSync(path.join(dir, file), 'utf-8');
expect(content).not.toContain('__CUBE_ID__');
expect(content).not.toContain('__CUBE_NAME__');
expect(content).toContain('net:hello');
}
expect(fs.readFileSync(path.join(dir, MANIFEST_FILENAME), 'utf-8')).toContain('Say hello');
});
it('rejects an invalid id and an empty name as usage errors', () => {
expect(() => createCube({ id: 'has space', name: 'x', dir: tmpDir })).toThrow(NopyUsageError);
expect(() => createCube({ id: 'ok', name: ' ', dir: tmpDir })).toThrow(NopyUsageError);
});
it('refuses a directory that is already a cube, naming the files', () => {
const dir = path.join(tmpDir, 'occupied');
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, 'my.manifest.mjs'), 'export default {}');
fs.writeFileSync(path.join(dir, 'my.deploy.py'), '# deploy');
expect(() => createCube({ id: 'x', name: 'X', dir })).toThrow(/my\.manifest\.mjs/);
// A lone deploy script blocks too — scaffolding next to it would leave the
// loader with two deploy candidates and readdir order picking one.
const half = path.join(tmpDir, 'half');
fs.mkdirSync(half);
fs.writeFileSync(path.join(half, DEPLOY_FILENAME), '# deploy');
expect(() => createCube({ id: 'x', name: 'X', dir: half })).toThrow(NopyUsageError);
});
it('overwrites with force and reports it', () => {
const dir = path.join(tmpDir, 'again');
createCube({ id: 'again', name: 'First', dir });
const results = createCube({ id: 'again', name: 'Second', dir, force: true });
expect(results.map((r) => r.status)).toEqual(['overwritten', 'overwritten']);
expect(fs.readFileSync(path.join(dir, MANIFEST_FILENAME), 'utf-8')).toContain('Second');
});
});
describe('scaffolded cube', () => {
it('is discovered by the loader with the declared id, name and schema', async () => {
writeConfig({ cubeDirs: ['./cubes'] });
createCube({
id: 'net:hello',
// The apostrophe is the point: free text spliced into a single-quoted
// string literal must still parse.
name: "Bob's greeting",
dir: path.join(tmpDir, 'cubes', 'net', 'hello'),
});
const { cubes, errors } = await loadCubes();
expect(errors).toHaveLength(0);
const cube = cubes['net:hello'];
expect(cube).toBeDefined();
expect(cube.name).toBe("Bob's greeting");
expect(cube.schemaKeys()).toContain('GREETING');
expect(cube.getDefaults().GREETING).toContain('net:hello');
});
});
describe('suggestCubeDir', () => {
it('derives a path under ./cubes from the id when there is no config', () => {
expect(suggestCubeDir('net:tailscale')).toBe(path.join('cubes', 'net', 'tailscale'));
});
it('uses the first configured cube directory, relative to cwd when under it', () => {
const config = { cubeDirs: [path.join(tmpDir, 'deploy', 'cubes')] };
expect(suggestCubeDir('apt', config)).toBe(path.join('deploy', 'cubes', 'apt'));
});
it('stays absolute when the cube directory is outside cwd', () => {
const elsewhere = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'nopy-elsewhere-'));
try {
const suggested = suggestCubeDir('apt', { cubeDirs: [elsewhere] });
expect(path.isAbsolute(suggested)).toBe(true);
expect(suggested).toBe(path.join(elsewhere, 'apt'));
} finally {
fs.rmSync(elsewhere, { recursive: true, force: true });
}
});
});
describe('cubeDirWarning', () => {
it('is silent without a config to consult', () => {
expect(cubeDirWarning(path.join(tmpDir, 'cubes', 'x'))).toBeUndefined();
});
it('is silent for a directory the loader will scan', () => {
writeConfig({ cubeDirs: ['./cubes'] });
expect(cubeDirWarning(path.join(tmpDir, 'cubes', 'net', 'x'))).toBeUndefined();
});
it('warns when the loader will never look there', () => {
writeConfig({ cubeDirs: ['./cubes'] });
const outside = path.join(tmpDir, 'elsewhere', 'x');
expect(cubeDirWarning(outside)).toContain('cubeDirs');
});
});
describe('assertCubeIdAvailable', () => {
it('resolves when there is no config to check against', async () => {
await expect(assertCubeIdAvailable('x', path.join(tmpDir, 'x'))).resolves.toBeUndefined();
});
it('resolves for an unclaimed id', async () => {
writeConfig({ cubeDirs: ['./cubes'] });
await expect(
assertCubeIdAvailable('free', path.join(tmpDir, 'cubes', 'free'))
).resolves.toBeUndefined();
});
it('rejects an id another directory already claims', async () => {
writeConfig({ cubeDirs: ['./cubes'] });
createCube({ id: 'taken', name: 'Taken', dir: path.join(tmpDir, 'cubes', 'taken') });
await expect(
assertCubeIdAvailable('taken', path.join(tmpDir, 'cubes', 'other'))
).rejects.toThrow(/already claimed/);
});
it('tolerates the claim coming from the target directory itself', async () => {
writeConfig({ cubeDirs: ['./cubes'] });
const dir = path.join(tmpDir, 'cubes', 'mine');
createCube({ id: 'mine', name: 'Mine', dir });
// The --force re-scaffold case: the id is "claimed", but by the very cube
// being recreated.
await expect(assertCubeIdAvailable('mine', dir)).resolves.toBeUndefined();
});
});
describe('formatCreateCubeResults', () => {
const results = [
{ file: MANIFEST_FILENAME, path: `/x/${MANIFEST_FILENAME}`, status: 'created' as const },
{ file: DEPLOY_FILENAME, path: `/x/${DEPLOY_FILENAME}`, status: 'created' as const },
];
it('reports the files and the next steps', () => {
const output = formatCreateCubeResults(results, { id: 'net:hello' });
expect(output).toContain(MANIFEST_FILENAME);
expect(output).toContain(DEPLOY_FILENAME);
expect(output).toContain('Next steps:');
expect(output).toContain('net:hello');
expect(output).not.toContain('Note:');
});
it('appends the discoverability warning when there is one', () => {
const warning = 'Note: /x is outside every configured cube directory';
expect(formatCreateCubeResults(results, { id: 'x', warning })).toContain(warning);
});
it('points skipped files at --force', () => {
const output = formatCreateCubeResults(
[{ file: MANIFEST_FILENAME, path: `/x/${MANIFEST_FILENAME}`, status: 'skipped' as const }],
{ id: 'x' }
);
expect(output).toContain('exists, skipped');
expect(output).toContain('--force');
});
});
+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');
});
});
+48
View File
@@ -41,6 +41,7 @@ import { Cube, Manifest } from '@bitsquare/nopy-cubes';
import { Variables } from '../src/nopy.common.js'; import { Variables } from '../src/nopy.common.js';
import { import {
AuthSelection, AuthSelection,
CubeScaffoldPrompts,
CubeSelection, CubeSelection,
HostSelection, HostSelection,
PasswordSelection, PasswordSelection,
@@ -532,3 +533,50 @@ describe('VariableAssignment', () => {
expect(variables.get('svc')).toEqual({ port: 9090, enabled: true, maybe: null }); expect(variables.get('svc')).toEqual({ port: 9090, enabled: true, maybe: null });
}); });
}); });
describe('CubeScaffoldPrompts', () => {
const suggestDir = vi.fn((id: string) => `cubes/${id}`);
it('passes fully-given answers through without asking anything', async () => {
inquirerPrompt.mockResolvedValue({});
const given = { id: 'net:x', name: 'X', dir: 'cubes/net/x' };
await expect(CubeScaffoldPrompts(given, suggestDir)).resolves.toEqual(given);
for (const q of questions()) {
expect(q.when()).toBe(false);
}
});
it('asks only for what is missing and merges the answers', async () => {
inquirerPrompt.mockResolvedValue({ name: 'Typed name', dir: 'typed/dir' });
const result = await CubeScaffoldPrompts({ id: 'net:x' }, suggestDir);
expect(result).toEqual({ id: 'net:x', name: 'Typed name', dir: 'typed/dir' });
expect(question('id')?.when()).toBe(false);
expect(question('name')?.when()).toBe(true);
expect(question('dir')?.when()).toBe(true);
});
it('wires the id validation into the prompt', async () => {
inquirerPrompt.mockResolvedValue({ id: 'ok', name: 'n', dir: 'd' });
await CubeScaffoldPrompts({}, suggestDir);
const validate = question('id')?.validate;
expect(validate('net:x')).toBe(true);
expect(validate('has space')).toBeTypeOf('string');
expect(question('name')?.validate(' ')).toBeTypeOf('string');
});
it('derives the directory default from the id, typed or given', async () => {
inquirerPrompt.mockResolvedValue({ id: 'typed:id', name: 'n', dir: 'd' });
await CubeScaffoldPrompts({}, suggestDir);
expect(question('dir')?.default({ id: 'typed:id' })).toBe('cubes/typed:id');
await CubeScaffoldPrompts({ id: 'given:id' }, suggestDir);
expect(question('dir')?.default({})).toBe('cubes/given:id');
});
});