Compare commits
8
Commits
nopy-v1.0.1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37566873cc | ||
|
|
70c3d1e36e | ||
|
|
8973ff7113 | ||
|
|
568d4c83ff | ||
|
|
643d7379ba | ||
|
|
f1cc9effa0 | ||
|
|
4fa69cce0d | ||
|
|
2810d4491f |
@@ -20,6 +20,17 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
snapshot:
|
snapshot:
|
||||||
|
# Not on a release commit. `scripts/release.mjs` pushes the branch and then
|
||||||
|
# the tags seconds apart, and this workflow's concurrency group is keyed on
|
||||||
|
# the branch while release.yml's is keyed on the tag — so the two never gate
|
||||||
|
# each other, they race for the runner, and the branch push always gets
|
||||||
|
# there first. Measured: a snapshot job that wedged pulling the runner image
|
||||||
|
# held the runner long enough for release.mjs to give up waiting on npmjs,
|
||||||
|
# leaving two of three tags unpushed.
|
||||||
|
#
|
||||||
|
# Skipping costs nothing. A snapshot of a release commit is the same tree
|
||||||
|
# the tag is about to publish properly, under a version nobody installs.
|
||||||
|
if: ${{ !startsWith(github.event.head_commit.message, 'release:') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -342,12 +342,15 @@ regardless of `--tag`; on Gitea it did not exist at all. Note that `npm view
|
|||||||
<name>` against a registry with no `latest` tag prints nothing and exits **0**,
|
<name>` against a registry with no `latest` tag prints nothing and exits **0**,
|
||||||
which is why this looked like a working lookup. (`npm view <name>@<version>`
|
which is why this looked like a working lookup. (`npm view <name>@<version>`
|
||||||
does exit 1 for a missing version, so the workflows' idempotency guards are
|
does exit 1 for a missing version, so the workflows' idempotency guards are
|
||||||
fine.) All four packages were reset to `0.5.0`; `1.0.0-alpha5` stays the
|
fine.) All four packages were reset to `0.5.0`, and `nopy`, `nopy-cubes` and
|
||||||
numerically highest version on npmjs, so install with an explicit `@latest`.
|
`nopy-cubes-core` have since gone out as `1.0.1` — the first release where
|
||||||
|
`latest` actually moved on both registries. `keyman` is still `0.7.0` and on
|
||||||
|
neither.
|
||||||
|
|
||||||
- Push to `main` → `publish-snapshot.yml` publishes every package to the Gitea
|
- Push to `main` → `publish-snapshot.yml` publishes every package to the Gitea
|
||||||
registry as `<version>-main.<run>.g<sha>` under the `main` dist-tag. The
|
registry as `<version>-main.<run>.g<sha>` under the `main` dist-tag. The
|
||||||
version is set on the runner with `npm pkg set` and never committed.
|
version is set on the runner with `npm pkg set` and never committed. It skips
|
||||||
|
commits whose message starts with `release:` — see *Runner contention* below.
|
||||||
- `git tag <dir>-v<version>` (e.g. `nopy-v1.2.0` — the directory under
|
- `git tag <dir>-v<version>` (e.g. `nopy-v1.2.0` — the directory under
|
||||||
`packages/`, not the npm name) → `release.yml` publishes to Gitea *and* npmjs.
|
`packages/`, not the npm name) → `release.yml` publishes to Gitea *and* npmjs.
|
||||||
The tag chooses the package, `package.json` supplies the version, and the run
|
The tag chooses the package, `package.json` supplies the version, and the run
|
||||||
@@ -400,12 +403,27 @@ gets `-vvv --debug`. Treat `docs/REFACTORING.md` as a plan, not a record.
|
|||||||
|
|
||||||
The publish lane has now run against the Gitea registry: all four packages are
|
The publish lane has now run against the Gitea registry: all four packages are
|
||||||
there under `@main`, and `pnpm run try:snapshot` installs them into a throwaway
|
there under `@main`, and `pnpm run try:snapshot` installs them into a throwaway
|
||||||
project with npm and runs the binary. The npmjs lane has only ever published
|
project with npm and runs the binary. The npmjs lane has published `nopy`,
|
||||||
`@bitsquare/nopy`; `keyman`, `nopy-cubes` and `nopy-cubes-core` have never been
|
`nopy-cubes` and `nopy-cubes-core` at `1.0.1`; `keyman` has never been released
|
||||||
released there. That used to be caught by the *check linked deps are released*
|
there. Ordering used to be enforced by the *check linked deps are released*
|
||||||
guard in `release.yml`; now it is `pnpm run release` that holds `nopy`'s tag back
|
guard in `release.yml`; now it is `pnpm run release` that holds `nopy`'s tag back
|
||||||
until `nopy-cubes` answers on npmjs.
|
until `nopy-cubes` answers on npmjs.
|
||||||
|
|
||||||
|
### Runner contention
|
||||||
|
|
||||||
|
`scripts/release.mjs` pushes the branch and then the tags seconds apart.
|
||||||
|
`publish-snapshot.yml` keys its concurrency group on the branch and `release.yml`
|
||||||
|
keys its own on the tag, so the two workflows never gate each other — on a
|
||||||
|
single runner they simply race for it, and the branch push always wins. The
|
||||||
|
1.0.1 release is what surfaced this: the snapshot job wedged extracting a layer
|
||||||
|
of `runner-images:ubuntu-latest`, the release job never started, `release.mjs`
|
||||||
|
gave up after its 20-minute wait, and two of the three tags were left unpushed
|
||||||
|
while the report still printed a bold **Done**. Three things changed as a
|
||||||
|
result — the snapshot job skips `release:` commits, the wait offers to keep
|
||||||
|
waiting rather than giving up (no timeout survives a wedged runner), and the
|
||||||
|
final header says **Blocked** when it is. The tags were pushed by hand
|
||||||
|
afterwards; all three packages are on npmjs.
|
||||||
|
|
||||||
Nothing checks that a bundle and the CLI reading it are compatible versions;
|
Nothing checks that a bundle and the CLI reading it are compatible versions;
|
||||||
`nopy.engines` was considered and deferred. `docs/CUBE-PACKAGES.md` is where all
|
`nopy.engines` was considered and deferred. `docs/CUBE-PACKAGES.md` is where all
|
||||||
of this came from and is now a record of what was built, including what differed
|
of this came from and is now a record of what was built, including what differed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@bitsquare/keyman",
|
"name": "@bitsquare/keyman",
|
||||||
"version": "0.7.0",
|
"version": "0.7.3",
|
||||||
"description": "A system to simplify ssh key management",
|
"description": "A system to simplify ssh key management",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ssh",
|
"ssh",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,13 +11,19 @@ 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"
|
||||||
|
|
||||||
|
# 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(
|
apt.packages(
|
||||||
name='Ensure fish shell is installed',
|
name='Ensure fish shell is installed',
|
||||||
packages=[ 'fish'],
|
packages=[ 'fish'],
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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__'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+38
-8
@@ -165,10 +165,21 @@ async function versionExists(name, version) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Polls until `name@version` resolves on npmjs, or the deadline passes. */
|
/**
|
||||||
async function waitForRelease(name, version, timeoutMs) {
|
* Polls until `name@version` resolves on npmjs, offering to keep waiting when
|
||||||
|
* the deadline passes.
|
||||||
|
*
|
||||||
|
* The offer is the point. A timeout here is far more often a runner that has
|
||||||
|
* not started the job than a release that failed, and no timeout value survives
|
||||||
|
* a runner that has wedged — so the choice is between asking and making the
|
||||||
|
* operator finish the release by hand. `--yes` and a non-interactive run give
|
||||||
|
* up instead, the latter because {@link confirm} answers with its default when
|
||||||
|
* there is no terminal, which here would extend the deadline forever.
|
||||||
|
*/
|
||||||
|
async function waitForRelease(name, version, timeoutMs, mayExtend) {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const label = `${name}@${version}`;
|
const label = `${name}@${version}`;
|
||||||
|
let deadline = started + timeoutMs;
|
||||||
process.stdout.write(` waiting for ${label} on npmjs `);
|
process.stdout.write(` waiting for ${label} on npmjs `);
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -177,9 +188,16 @@ async function waitForRelease(name, version, timeoutMs) {
|
|||||||
console.log(chalk.green(` published after ${seconds}s`));
|
console.log(chalk.green(` published after ${seconds}s`));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (Date.now() - started > timeoutMs) {
|
if (Date.now() > deadline) {
|
||||||
console.log(chalk.red(' timed out'));
|
console.log(chalk.red(' timed out'));
|
||||||
return false;
|
if (!mayExtend || !process.stdin.isTTY) return false;
|
||||||
|
console.log(
|
||||||
|
chalk.dim(' A queued or wedged runner looks exactly like this — check the run.')
|
||||||
|
);
|
||||||
|
if (!(await confirm(`Keep waiting for ${label}?`, true))) return false;
|
||||||
|
deadline = Date.now() + timeoutMs;
|
||||||
|
process.stdout.write(` waiting for ${label} on npmjs `);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
process.stdout.write('.');
|
process.stdout.write('.');
|
||||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
@@ -664,7 +682,7 @@ async function release(options) {
|
|||||||
console.log(` push ${options.remote} ${branch}, then tags in the order above`);
|
console.log(` push ${options.remote} ${branch}, then tags in the order above`);
|
||||||
console.log(
|
console.log(
|
||||||
options.wait
|
options.wait
|
||||||
? ` wait for each version on npmjs (up to ${options.waitTimeout}s each)\n`
|
? ` wait for each version on npmjs (asks to continue after ${options.waitTimeout}s)\n`
|
||||||
: ' wait no — tags are pushed back to back\n'
|
: ' wait no — tags are pushed back to back\n'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -751,7 +769,8 @@ async function release(options) {
|
|||||||
const landed = await waitForRelease(
|
const landed = await waitForRelease(
|
||||||
entry.pkg.manifest.name,
|
entry.pkg.manifest.name,
|
||||||
entry.version,
|
entry.version,
|
||||||
options.waitTimeout * 1000
|
options.waitTimeout * 1000,
|
||||||
|
!options.yes
|
||||||
);
|
);
|
||||||
if (!landed) {
|
if (!landed) {
|
||||||
pending.push(entry);
|
pending.push(entry);
|
||||||
@@ -772,7 +791,13 @@ async function release(options) {
|
|||||||
const blocked = pending[0];
|
const blocked = pending[0];
|
||||||
const shipped = blocked ? plan.slice(0, plan.indexOf(blocked)) : plan;
|
const shipped = blocked ? plan.slice(0, plan.indexOf(blocked)) : plan;
|
||||||
|
|
||||||
console.log(chalk.bold('\n Done\n'));
|
// The header has to know whether this worked. It was an unconditional 'Done'
|
||||||
|
// over a `shipped` list that is *empty* when the package that blocked is the
|
||||||
|
// first one — a success banner above a release that shipped nothing, with the
|
||||||
|
// diagnosis a screen further down. It read as success and was believed.
|
||||||
|
console.log(chalk.bold(blocked ? '\n Blocked\n' : '\n Done\n'));
|
||||||
|
|
||||||
|
if (blocked && shipped.length > 0) console.log(chalk.dim(' Released before the blockage:\n'));
|
||||||
for (const entry of shipped) {
|
for (const entry of shipped) {
|
||||||
console.log(` ${entry.pkg.manifest.name}@${entry.version} (${distTag(entry.version)})`);
|
console.log(` ${entry.pkg.manifest.name}@${entry.version} (${distTag(entry.version)})`);
|
||||||
console.log(chalk.dim(` npm install -g ${entry.pkg.manifest.name}@${entry.version}`));
|
console.log(chalk.dim(` npm install -g ${entry.pkg.manifest.name}@${entry.version}`));
|
||||||
@@ -817,7 +842,12 @@ program
|
|||||||
.option('--no-verify', 'Skip the lint/typecheck/test/build gate')
|
.option('--no-verify', 'Skip the lint/typecheck/test/build gate')
|
||||||
.option('--no-changelog', 'Do not prompt for release notes')
|
.option('--no-changelog', 'Do not prompt for release notes')
|
||||||
.option('--no-wait', 'Do not poll npmjs between tag pushes')
|
.option('--no-wait', 'Do not poll npmjs between tag pushes')
|
||||||
.option('--wait-timeout <seconds>', 'How long to wait for each version', Number, 1200)
|
.option(
|
||||||
|
'--wait-timeout <seconds>',
|
||||||
|
'How long to wait before asking to keep waiting',
|
||||||
|
Number,
|
||||||
|
2400
|
||||||
|
)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
'after',
|
'after',
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user