8 Commits

Author SHA1 Message Date
Benjamin Diedrichsen 1ba1c2a32a [chore] commit id display on version
Publish snapshot / snapshot (push) Successful in 1m26s
2026-07-29 13:00:10 +02:00
Benjamin Diedrichsen ea08e76a2f [feat] nopy update command and auto-update pipeline 2026-07-29 11:16:52 +02:00
Benjamin Diedrichsen 6ecb2c366f [refactor] moving cubes into own package"
Publish snapshot / snapshot (push) Successful in 1m2s
[fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes
2026-07-28 12:18:10 +02:00
Benjamin Diedrichsen ac050c4459 [wip] cubes packaging and distribution via registry
Publish snapshot / snapshot (push) Successful in 1m21s
2026-07-28 09:37:42 +02:00
Benjamin Diedrichsen 30d93dddc5 implementing --use-defaults 2026-07-28 09:27:05 +02:00
Benjamin Diedrichsen 5ed68c0065 improving documentation consistency. auditing documentation drifts. planning cube packaging 2026-07-27 21:58:54 +02:00
Benjamin Diedrichsen fcc181700e test release nopy-alpha5
Release / release (push) Successful in 1m0s
2026-07-27 17:07:18 +02:00
Benjamin Diedrichsen a4ce4879a4 test release nopy-alpha4
Release / release (push) Failing after 48s
2026-07-27 17:05:29 +02:00
152 changed files with 10863 additions and 1058 deletions
+7
View File
@@ -81,6 +81,13 @@ jobs:
echo "::endgroup::"
done
- name: Verify the packed manifests
# `workspace:*` is mandatory in the manifests but meaningless to npm, so
# a range that survives into a tarball is an install failure for every
# consumer. The publish workflows run this too; running it here is what
# puts the failure on the pull request instead of on the release.
run: node scripts/verify-pack.mjs
- name: Upload coverage reports
if: always()
continue-on-error: true
+44 -13
View File
@@ -1,7 +1,7 @@
# Every commit that lands on `main` publishes a prerelease of both packages to
# the Gitea npm registry under the `main` dist-tag:
# Every commit that lands on `main` publishes a prerelease of every publishable
# package to the Gitea npm registry under the `main` dist-tag:
#
# pnpm add @bitstack/nopy@main
# pnpm add @bitsquare/nopy@main
#
# The verification gate runs here rather than in ci.yml so a snapshot can never
# be published from a red `main`. Versions are derived, never committed —
@@ -35,6 +35,14 @@ jobs:
- name: Check out
uses: actions/checkout@v4
- name: Drop the repo's Gitea scope mapping
# See the same step in release.yml. This job only ever targets Gitea, so
# the committed file happens to agree with it — but it agrees by
# accident, and a project-level `@bitsquare:registry` silently outranks
# the userconfig written below. Removing it keeps the registry a
# property of the step rather than of the checkout.
run: rm -f .npmrc
- name: Set up pnpm
# Version comes from `packageManager` in the root package.json.
uses: pnpm/action-setup@v4
@@ -78,6 +86,11 @@ jobs:
# Explicit, so the publish step can skip lifecycle scripts entirely.
run: pnpm run build
- name: Verify the packed manifests
# Packages link to each other with `workspace:*`, which npm cannot
# install. Proves on the tarball that pack rewrote it.
run: node scripts/verify-pack.mjs
- name: Authenticate against the Gitea registry
run: |
set -euo pipefail
@@ -87,7 +100,7 @@ jobs:
fi
install -m 600 /dev/null "$NPMRC"
{
printf '@bitstack:registry=%s\n' "$REGISTRY"
printf '@bitsquare:registry=%s\n' "$REGISTRY"
printf '//%s:_authToken=%s\n' "${REGISTRY#*://}" "$REGISTRY_TOKEN"
} >> "$NPMRC"
@@ -97,23 +110,41 @@ jobs:
export npm_config_userconfig="$NPMRC"
: "${GITHUB_STEP_SUMMARY:=/dev/null}"
short_sha=$(git rev-parse --short=7 HEAD)
# Dependencies first, so the registry never briefly holds a package
# whose dependency has not landed yet.
dirs=$(node scripts/publish-order.mjs)
for dir in packages/*/; do
name=$(node -p "require('./${dir}package.json').name")
base=$(node -p "require('./${dir}package.json').version")
# Pass 1: stamp every manifest before anything is packed. `pnpm
# publish` substitutes `workspace:*` with the version the linked
# package declares at pack time, so nopy-cube has to be carrying its
# snapshot version by the time nopy is packed.
for dir in $dirs; do
base=$(node -p "require('./${dir}/package.json').version")
# `g` prefix keeps the identifier a valid semver one even when the
# abbreviated sha happens to be all digits.
version="${base}-main.${{ github.run_number }}.g${short_sha}"
# `buildInfo.commit` is what `nopy --version` annotates itself with.
# An unknown top-level key is ignored by npm and package.json is
# always in the tarball, so it ships without any `files` change.
(cd "$dir" && npm pkg set "version=${version}" "buildInfo.commit=${short_sha}")
done
# Pass 2: publish.
for dir in $dirs; do
name=$(node -p "require('./${dir}/package.json').name")
version=$(node -p "require('./${dir}/package.json').version")
echo "::group::${name}@${version}"
if npm view "${name}@${version}" version --registry "$REGISTRY" >/dev/null 2>&1; then
# Scoped, not `--registry`: for a scoped package npm resolves
# `@scope:registry` first, so a bare flag loses to any project
# .npmrc that sets the scoped key.
if npm view "${name}@${version}" version --@bitsquare:registry="$REGISTRY" >/dev/null 2>&1; then
echo "Already published — skipping (this is a re-run of the same workflow)."
else
(
cd "$dir"
npm pkg set "version=${version}"
npm publish --ignore-scripts --tag main --registry "$REGISTRY"
)
# pnpm, not npm: npm ships `workspace:*` verbatim and the install
# then fails with EUNSUPPORTEDPROTOCOL. --no-git-checks because
# stamping the versions above left the tree dirty.
(cd "$dir" && pnpm publish --ignore-scripts --no-git-checks --tag main --@bitsquare:registry="$REGISTRY")
fi
echo "::endgroup::"
+87 -8
View File
@@ -1,15 +1,20 @@
# Tag-driven release of a single package.
#
# git tag nopy-v1.2.0 && git push origin nopy-v1.2.0
# git tag nopy-cube-v1.2.0 && git push origin nopy-cube-v1.2.0
# git tag keyman-v1.2.0 && git push origin keyman-v1.2.0
#
# The tag is the source of truth for *which* package ships; package.json is the
# source of truth for the version, and the two must agree or the run fails.
#
# Packages that link to each other release dependency-first — `nopy-cube` before
# `nopy` — because the linked version is resolved at pack time. The run refuses
# to publish otherwise.
# A version with a prerelease part (1.2.0-rc.1) publishes under `next` instead
# of `latest`.
#
# Required secrets:
# NPM_TOKEN npmjs granular token, read-and-write on @bitstack/*, 2FA
# NPM_TOKEN npmjs granular token, read-and-write on @bitsquare/*, 2FA
# not required. Expires after 90 days — rotate it.
# MYGITEA_NPM_TOKEN Gitea PAT with write:package. The automatic GITEA_TOKEN is
# a repo-scoped task token and the package registry rejects it.
@@ -40,6 +45,18 @@ jobs:
- name: Check out
uses: actions/checkout@v4
- name: Drop the repo's Gitea scope mapping
# The committed .npmrc points @bitsquare at Gitea so local work resolves
# snapshots. It must not survive into a publish job: it is a *project*
# config, which outranks both the userconfig the steps below write and a
# `--registry` flag, because `@scope:registry` is more specific than
# `registry`. Left in place, `pnpm publish --registry <npmjs>` uploads to
# Gitea and `npm view --registry <npmjs>` answers from Gitea — so the
# npmjs release silently publishes nowhere and then skips itself.
# Measured, not assumed. The checkout is disposable; each step below
# names its registry explicitly anyway.
run: rm -f .npmrc
- name: Resolve the release from the tag
id: target
run: |
@@ -108,6 +125,33 @@ jobs:
- name: Install
run: pnpm install --frozen-lockfile
- name: Check the linked workspace packages are already released
env:
NAME: ${{ steps.target.outputs.name }}
DIR: ${{ steps.target.outputs.dir }}
run: |
set -euo pipefail
# `pnpm publish` turns `workspace:*` into the version the linked
# package declares at this commit. If that version is not on the
# registry yet, the release installs to a broken tree — and npmjs
# only lets you unpublish for 72 hours. Release the dependency first:
# nopy-cube, then nopy, then any bundle.
#
# npmjs only: it is the irreversible one, and it needs no credentials
# to read, which this step does not have yet.
missing=0
for spec in $(node scripts/linked-deps.mjs "$DIR" | tr ' ' '@'); do
# Scoped, not `--registry`: `@scope:registry` outranks it, so a bare
# flag can be silently overridden by any project-level .npmrc.
if npm view "$spec" version --@bitsquare:registry="$NPMJS_REGISTRY" >/dev/null 2>&1; then
echo "${spec} is published"
else
echo "::error::${NAME} depends on ${spec}, which is not on npmjs. Release it first."
missing=1
fi
done
exit "$missing"
- name: Lint
run: pnpm run lint:ci
@@ -121,6 +165,25 @@ jobs:
# Explicit, so the publish steps can skip lifecycle scripts entirely.
run: pnpm run build
- name: Stamp the commit into the manifest
# What `nopy --version` annotates itself with. The version is untouched:
# this only adds a `buildInfo.commit` key, which npm ignores and which
# ships regardless of `files` because package.json is always packed.
# Before the pack below, so the artefact under test is the one publish
# ships. The tree is left dirty, which is why both publish steps pass
# --no-git-checks — they already did, for the detached HEAD.
env:
DIR: ${{ steps.target.outputs.dir }}
run: |
set -euo pipefail
short_sha=$(git rev-parse --short=7 HEAD)
(cd "$DIR" && npm pkg set "buildInfo.commit=${short_sha}")
- name: Verify the packed manifests
# Packages link to each other with `workspace:*`, which npm cannot
# install. Proves on the tarball that pack rewrote it.
run: node scripts/verify-pack.mjs
- name: Publish to the Gitea registry
env:
NAME: ${{ steps.target.outputs.name }}
@@ -131,15 +194,23 @@ jobs:
set -euo pipefail
install -m 600 /dev/null "$NPMRC"
{
printf '@bitstack:registry=%s\n' "$GITEA_REGISTRY"
printf '@bitsquare:registry=%s\n' "$GITEA_REGISTRY"
printf '//%s:_authToken=%s\n' "${GITEA_REGISTRY#*://}" "$GITEA_REGISTRY_TOKEN"
} >> "$NPMRC"
export npm_config_userconfig="$NPMRC"
if npm view "${NAME}@${VERSION}" version --registry "$GITEA_REGISTRY" >/dev/null 2>&1; then
if npm view "${NAME}@${VERSION}" version --@bitsquare:registry="$GITEA_REGISTRY" >/dev/null 2>&1; then
echo "${NAME}@${VERSION} is already on Gitea — skipping."
else
(cd "$DIR" && npm publish --ignore-scripts --tag "$DIST_TAG" --registry "$GITEA_REGISTRY")
# pnpm, not npm: npm ships `workspace:*` verbatim and the install
# then fails with EUNSUPPORTEDPROTOCOL. --no-git-checks because a
# tag build is a detached HEAD.
#
# The registry is named as `--@bitsquare:registry`, not `--registry`.
# Every package here is scoped, and for a scoped package npm resolves
# `@scope:registry` ahead of `registry` — so a bare flag loses to any
# project .npmrc that sets the scoped key.
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --@bitsquare:registry="$GITEA_REGISTRY")
fi
- name: Publish to npmjs
@@ -152,17 +223,22 @@ jobs:
set -euo pipefail
install -m 600 /dev/null "$NPMRC"
{
printf '@bitstack:registry=%s\n' "$NPMJS_REGISTRY"
printf '@bitsquare:registry=%s\n' "$NPMJS_REGISTRY"
printf '//%s:_authToken=%s\n' "${NPMJS_REGISTRY#*://}" "$NPMJS_TOKEN"
} >> "$NPMRC"
export npm_config_userconfig="$NPMRC"
if npm view "${NAME}@${VERSION}" version --registry "$NPMJS_REGISTRY" >/dev/null 2>&1; then
if npm view "${NAME}@${VERSION}" version --@bitsquare:registry="$NPMJS_REGISTRY" >/dev/null 2>&1; then
echo "${NAME}@${VERSION} is already on npmjs — skipping."
else
# No --provenance: that needs GitHub Actions OIDC, which Gitea has no
# equivalent for.
(cd "$DIR" && npm publish --ignore-scripts --tag "$DIST_TAG" --access public --registry "$NPMJS_REGISTRY")
#
# Scoped flag, as above — and it matters most here. With a bare
# `--registry` this line was measured uploading to Gitea whenever a
# project .npmrc mapped the scope, which is the one mistake npmjs
# will not let you take back.
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --access public --@bitsquare:registry="$NPMJS_REGISTRY")
fi
- name: Remove the registry credentials
@@ -241,5 +317,8 @@ jobs:
echo "### Released \`${NAME}@${VERSION}\` (\`${DIST_TAG}\`)"
echo ""
echo "- npmjs: \`npm install -g ${NAME}@${VERSION}\`"
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --registry ${GITEA_REGISTRY}\`"
# Scoped, never a bare `--registry`: Gitea serves @bitsquare only and
# does not proxy npmjs, so a bare flag sends every transitive
# dependency to a registry that has never heard of them.
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --@bitsquare:registry=${GITEA_REGISTRY}\`"
} >> "$GITHUB_STEP_SUMMARY"
+5
View File
@@ -22,3 +22,8 @@ tsconfig.tsbuildinfo
.npmrc
.npmrc-*
release.json
# ...except the repo-root .npmrc, which is checked in on purpose: it holds the
# @bitsquare -> Gitea scope mapping and nothing else. Credentials live in the
# .npmrc-* files above, which stay ignored.
!/.npmrc
+2 -1
View File
@@ -1,6 +1,7 @@
{
"hosts": [],
"cubeDirs": ["./cubes"],
"cubeDirs": [],
"cubePackages": ["@bitsquare/cubes-core"],
"env": {},
"log": {
"verbosity": "info",
+20
View File
@@ -0,0 +1,20 @@
# The @bitsquare scope resolves from the Gitea registry rather than npmjs.
#
# Gitea is a strict superset: release.yml publishes there *and* to npmjs, while
# publish-snapshot.yml pushes a `main` snapshot on every push. So this is not a
# trade — it is every released version plus the ones npmjs has never seen, which
# is what makes a snapshot testable before it is released.
#
# Scoped deliberately. A bare `registry=` would send all ~55 transitive
# dependencies to Gitea too, and Gitea serves this scope only — it does not
# proxy npmjs, so they would all 404. Everything outside @bitsquare keeps going
# to the default registry.
#
# Reading is anonymous; no token belongs in this file. The publish workflows
# write their credentials to a throwaway .npmrc-gitea / .npmrc-release, both of
# which stay gitignored.
#
# Note this maps the *scope*, not a channel: Gitea currently publishes no
# `latest` dist-tag, so an untagged `npm i @bitsquare/nopy` resolves to nothing.
# Ask for a tag — @main for the newest snapshot. See README.PUBLISH.md.
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
+368
View File
@@ -0,0 +1,368 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
A pnpm workspace holding two independently published CLIs, the authoring package
their deployment units are written against, and one bundle of those units:
| Path | Package | Binary | Role |
| --------------------- | ---------------------- | -------- | -------------------------------------------------------- |
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | interactive pyinfra script management and execution |
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | SSH key management, shelling out to `age` / `ssh-keygen` |
| `packages/nopy-cube` | `@bitsquare/nopy-cube` | — | the authoring surface a `manifest.mjs` imports |
| `packages/cubes-core` | `@bitsquare/cubes-core`| — | the core cube bundle (22 cubes), no TypeScript |
The root package is private; everything under `packages/` ships. `keyman` stands
alone, but `nopy` and `cubes-core` both depend on `nopy-cube` (`workspace:*`), so
publish order matters — see *Releasing*.
`cubes-core` is consumed the way a third party would consume it: the root
`.nopyrc.json` names it in `cubePackages`, and the loader reads it out of
`node_modules`. There is no `cubes/` directory at the repo root any more.
## Documenting
Be modest. Size the write-up to the change: most work needs none, and a small
module never earns a section in `docs/API.md`. Where a reason is genuinely
non-obvious, one comment next to the code beats three paragraphs in a document
nobody re-reads. Document the surprising, not the obvious.
## Commands
```sh
pnpm install # also installs the git hooks via simple-git-hooks
pnpm run build # tsc --build across the TS packages (project references)
pnpm run typecheck # tsc --build (see below — it really does emit)
pnpm run lint # biome check . (lint:fix / lint:ci variants)
pnpm test # vitest run, every package with tests
pnpm run test:coverage # vitest with the coverage gate
pnpm run coverage:summary # renders the last coverage run as a Markdown table
pnpm run registry:status # what is on Gitea vs npmjs, and what is Gitea-only
pnpm run try:snapshot # install a published snapshot into a temp project and run it
```
Single package / single test:
```sh
pnpm --filter @bitsquare/nopy run test tests/config.test.ts # one file
pnpm --filter @bitsquare/nopy run test -t "merges configs" # by test name
pnpm --filter @bitsquare/nopy run test:watch
pnpm --filter @bitsquare/nopy run nopy # run the CLI from source via tsx
pnpm --filter @bitsquare/keyman run keyman
```
`typescript` is the 7.x native compiler, so `tsc` *is* the fast one — there is no
separate `tsgo` binary.
`typecheck` is plain `tsc --build`, not `--noEmit`. Once a project has
`references`, `--noEmit` is rejected outright (TS6310: *referenced project may
not disable emit*) — a composite project has to emit the declarations its
dependents read. So the typecheck writes `dist` as a side effect; it is
gitignored, and the upside is that the gate now also proves the build works.
## Verification gate
`lint:ci``typecheck``test:coverage` is one gate, run in three places: the
`pre-push` hook, `ci.yml` (non-`main`), and `publish-snapshot.yml` (`main`).
`pre-commit` runs Biome with fixes on staged files only. Bypass with
`SKIP_SIMPLE_GIT_HOOKS=1`; re-install after editing the hook config with
`pnpm exec simple-git-hooks`.
Coverage thresholds live in each package's `vitest.config.ts` (85 % branches and
functions, 80 % lines and statements), not in a CI flag — they fail identically
locally and on the runner. Barrel files (`src/index.ts`, `src/cubes/index.ts`,
`src/nopy.cubes.ts`) and the Commander argv wiring (`src/*.cli.ts`) are excluded;
adding logic to those files means moving it somewhere covered.
nopy's vitest config aliases `@bitsquare/nopy-cube` to that package's **source**,
not to the workspace link (which points at a `dist` that only exists after a
build), so the gate does not depend on build ordering and can never run against a
stale artefact. The same config excludes `**/nopy-cube/**` from coverage — without
it nopy's numbers absorb another package's files. `cubes-core` has no tests of its
own; the loader tests in nopy cover the contract it implements.
The three TS packages set `pool: 'forks'` because tests use `process.chdir()` — most
loader/config tests build a throwaway tree under `os.tmpdir()` and chdir into it,
since discovery is driven entirely by the working directory.
## nopy architecture
One pass per invocation, `nopy.main.ts` orchestrating:
1. **`nopy.config.ts`** — `loadConfig()` walks up from `process.cwd()` collecting
every `.nopyrc.json` plus `~/.nopyrc.json`, then merges them root-first.
Per-property strategy comes from the child's `resolution` block (`merge` is
the default: arrays concatenate and dedupe, objects deep-merge; `override`
replaces). Only properties listed in `PATH_PROPERTIES` (`cubeDirs`) get
relative paths resolved against their own config file's directory;
`cubePackages` needs the same origin for a different reason, so each entry is
normalised into a `CubePackageRef {spec, from}``from` is the directory of
the config that named it, which is where the package gets resolved from.
**Throws**
if no config file exists anywhere — which is why `nopy.cli.ts` calls it lazily
inside the action, so `--help`/`--version` work outside a project.
2. **`cubes/packages.ts`** — `resolveCubePackages()` turns each `CubePackageRef`
into a package root plus its cube directories. The location is a **convention**:
`<root>/cubes`, so a bundle needs no nopy-specific `package.json` field at all.
`nopy.cubes` survives only as an override, for the bundle whose cubes are
elsewhere (`dist/cubes` after a build, say) — absent means the default, but
present-and-malformed is an error rather than a fall back, since saying
something that does not parse is not the same as saying nothing.
Resolution goes through `createRequire(...).resolve.paths()` + `existsSync`,
deliberately bypassing the `exports` map: a bundle ships directories and has
no entry point to declare. `existsSync` also follows the symlink pnpm plants
at `node_modules/<name>`, which a `readdir` scan skips outright (it reports
`isSymbolicLink()`, not `isDirectory()`). A missing package, an unreadable
manifest, no cube directory found, and an entry pointing outside the package
root are all errors, never silent skips.
Duplicate refs are deduped here, last-wins, because `mergeValue` only dedupes
arrays of primitives and these are objects.
3. **`cubes/loader.ts`** — `findCubeRoots()` unions `config.cubeDirs`, the
directories from `cubePackages`, and every ancestor directory holding a
`.npcubes` marker, then scans each recursively (skipping dotted dirs and
`node_modules`). A directory is a cube
when it holds both a manifest (`manifest.mjs` or `*.manifest.mjs`) and a
deploy script (`deploy.py` or `*.deploy.py`); manifests are loaded by dynamic
`import()`. Cube id = `manifest.id` → a `[id]` prefix in `manifest.name`
the directory basename. Ids are flat and need not mirror the path
(`cubes/network/tailscale` declares `net:tailscale`), and they are claimed
**globally**, not per source: a duplicate is a hard error naming every
claimant, with no precedence rule and no shadowing. Each cube carries a
`source``{type: 'dir', dir}` or `{type: 'package', packageName, dir}`
which is what makes that error legible when the collision is between a local
tree and an installed bundle. Duplicate ids and bad manifests become entries
in `errors`, which aborts the run.
4. **`nopy.workflow.ts`** — picks interactive, file-replay, or history-replay and
normalises all three into a `WorkflowResult`. Replays never re-prompt except
for passwords (never persisted) and a missing host.
5. **`cubes/dependencies.ts``BuildContext.resolveCube()`** — the core.
Recursive, per (cube, host): assign params and schema defaults → collect
variables (prompt, or read them back from the session on replay) → run
`before` hooks → resolve `manifest.dependencies(vars)` (dynamic: it receives
the *collected* variables) → emit the deploy call → run `after` hooks. There
is no separate topological sort; ordering falls out of the recursion, and a
`${cubeId}:${host}` set makes emission idempotent. Hooks get a `HookContext`
whose `exec(id, vars)` re-enters `resolveCube`, so a hook can pull in a cube
that is not a declared dependency.
6. **`nopy.executor.ts`** — runs the built `pyinfra <host> -y --data K=V ... --chdir <cubeDir> <script>`
commands through execa with inherited stdio, sequentially, stopping at the
first failure unless `continueOnError`.
### Variables
`Variables` (`nopy.common.ts`) holds one `Variable` per (cube, key). A `Variable`
is a list of `Assignment {value, origin}`, and precedence is the `Origin` rank:
`default(0) < env(1) < session(2) < prompt(3) < param(4)`. There are no scope
bags — config `env` is seeded per cube as a real assignment, so the old
`get('global')` (a cube id that was never a cube) is gone, and a replay assigns at
`session` instead of being smuggled into the prompts bag.
`assignments` is the true history, newest first, never reordered. `ordered` is a
**stable** sort of it by rank; `value` / `origin` read its head. The stability is
load-bearing: it is what makes same-origin ties resolve to the newest while the
displaced value stays visible in the trace. The trace is never persisted.
`get(id)` returns the effective values (→ the pyinfra command line);
`persistable(id)` returns the same minus declared secrets (→ session and history).
Every schema key is guaranteed present on the pyinfra side; pyinfra parses
`--data` values itself, so `"true"` arrives as a bool and numeric strings as ints.
A manifest's `secrets: string[]` names schema keys holding sensitive values —
validated at load (an entry that is not a schema key aborts the run). Secrets are
excluded from `persistable()`, re-prompted on replay via `fillSessionGaps`
(`requiredKeys() secrets`), and masked by `maskCommand()` / `maskVariables()`
wherever a command is printed. Deliberately a plain array rather than zod
metadata: `.meta()` and `.describe()` live in the per-copy `z.globalRegistry`, so
a manifest built by a different zod copy would look up empty — fail-open is
tolerable for a prompt label and not for a secret marker. See `docs/REFACTORING.md`
items 6 and 7.
### Cube contract
A cube directory holds `manifest.mjs` + `deploy.py`; anything else in it is
ignored by the loader but reachable from the script, which runs with the cube
directory as its cwd. Manifests are ESM, import `Manifest` from
`@bitsquare/nopy-cube`, and declare `id`, `name`, a Zod `schema` (each field
`.describe()`d — the description is the prompt label — and `.default()`ed), plus
optional `secrets`/`dependencies`/`before`/`after`.
Import from **`@bitsquare/nopy-cube`**, not `@bitsquare/nopy`. The authoring
surface is types and a factory, with zod as its only peer — no CLI, no prompts,
no process spawning — so a bundle can depend on it without dragging the CLI in.
`@bitsquare/nopy` re-exports all of it (`cubes.Manifest`, `cubes.uniqid`, …), so
the older form still works; every cube in `packages/cubes-core` has been moved to
the new one.
Manifests are resolved by ordinary Node resolution **from the manifest's own
directory**, which used to mean a hand-written local cube failed with
`ERR_MODULE_NOT_FOUND` unless you linked the package. `cubes/resolve-hook.mjs`
retires that: `loadCubes()` registers a `module.register()` resolve hook that
tries normal resolution *first* and only on failure falls back to resolving
`@bitsquare/nopy-cube`, `@bitsquare/nopy` and `zod` from the running CLI's own
`node_modules`. Ordinary-resolution-first is the load-bearing part — a cube that
ships its own zod keeps it. The hook is a convenience, never load-bearing:
registration is wrapped in a `try`, and a bundle installed properly never reaches
it. `dist/cubes/*.mjs` is copied by the build, not compiled — hence
`"build": "tsc && cp src/cubes/*.mjs dist/cubes/"`.
Its tests must spawn a real `node` child process. Written inside the vitest
worker they prove nothing: vite resolves the dynamic import itself, so they pass
whether or not the hook is installed — verified by commenting the registration
out and watching them stay green.
## keyman architecture
Much smaller: `keyman.cli.ts` (argv, plus a `--print-config` escape hatch) →
`keyman.main.ts`, an inquirer menu loop dispatching to one module per operation
(`list`/`copy`/`generate`/`encrypt`/`decrypt`). `keyman.config.ts` mirrors nopy's
upward-traversal + `resolution` merge for `.keymanrc.json`, but validates the
result with Zod and falls back to defaults instead of throwing. `VAULT_ROOT` in
the environment beats the config file. Encryption shells out to `age` /
`age-keygen` / `ssh-keygen`, which must be on `PATH`.
### Updating
`nopy.update.ts` and `keyman.update.ts` are two near-identical copies of one
module: derive the channel from the running version (`-main.``main`, any
other prerelease → `next`, clean → `latest`), resolve the registry from
`npm config get @bitsquare:registry`, read `dist-tags` off the packument with a
plain `fetch`, compare with semver. Nothing about the channel is stored — the
version you are running is the one piece of state that is always right, so an
upgrade cannot silently move you to a different channel.
They back a `self-update` subcommand and a once-a-day startup check whose hint
goes to **stderr**, so `--json` and `--print-only` stay machine-readable. The
cache is `~/.nopy/update-check.json` / `~/.keyman/update-check.json`; a
mismatched channel or registry in the cache is never treated as fresh. The
check is disabled whenever `CI` is set.
The install command uses `--@bitsquare:registry=<url>`, never `--registry`:
Gitea serves the `@bitsquare` scope and does **not** proxy npmjs, so a global
`--registry` would send every transitive dependency to a registry that has never
heard of them. Verified — `npm i -g @bitsquare/nopy@main --@bitsquare:registry=…`
pulls `nopy-cube` from Gitea and the other 55 packages from npmjs. pnpm accepts
the same flag; the `npm_config_@bitsquare:registry` env var does not work with
pnpm and is not used.
The duplication between the two modules is deliberate: keyman shares no internal
library with nopy, and a fifth workspace package for ~250 lines would add
another edge to the publish order. Extract it if a third CLI appears.
## Releasing
Tag-driven, one package at a time; see `README.PUBLISH.md`.
### Registry resolution
The repo commits a root `.npmrc` mapping `@bitsquare:registry` to the Gitea
registry, so every npm/pnpm command run from the repo — global installs
included, since npm reads the project file for those too — resolves the scope
from Gitea. `.gitignore` ignores `.npmrc` generally (the workflows write
credentials to `.npmrc-gitea` / `.npmrc-release`) and carries a `!/.npmrc`
negation for the root file, which holds the mapping and no token.
Not a trade against npmjs: Gitea is a strict superset for this scope, since
`release.yml` publishes to both and `publish-snapshot.yml` adds a `main`
snapshot per push. It cannot affect `pnpm install` either — every `@bitsquare`
range in the workspace is `workspace:*` resolving to `link:`, so nothing in the
tree is fetched from that scope.
Two consequences: a bare `npm view @bitsquare/…` from the repo now answers for
**Gitea**, and an *untagged* install resolves to nothing, because Gitea
publishes no `latest` tag yet — always name `@main` or `@next`.
`pnpm run registry:status` prints both registries side by side and marks the
versions Gitea has that npmjs does not.
The sharp edge is in CI. `@scope:registry` is resolved *before* `registry` for a
scoped package, so the scoped key beats a `--registry` flag; and a project
`.npmrc` outranks the userconfig the workflows write. With the committed file in
place, `pnpm publish --registry <npmjs>` was measured uploading to **Gitea**, and
the `npm view --registry <npmjs>` guard answered from Gitea and skipped the npmjs
publish. Both workflows now `rm -f .npmrc` after checkout *and* pass
`--@bitsquare:registry=<url>` on every publish and lookup; either alone is
sufficient, and both were verified with `pnpm publish --dry-run`. This is the
same reason `self-update` never emits a bare `--registry`.
**Versions are `0.x.y`, not `1.0.0-alphaN`.** The dist-tag rule in `release.yml`
is mechanical — anything with a `-` goes out as `next` — so while every package
carried an `alphaN` suffix, `latest` never moved. `latest` on npmjs pointed at
`1.0.0-alpha5` only because npmjs sets it on a package's *first* publish
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**,
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
fine.) All four packages were reset to `0.5.0`; `1.0.0-alpha5` stays the
numerically highest version on npmjs, so install with an explicit `@latest`.
- 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
version is set on the runner with `npm pkg set` and never committed.
- `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.
The tag chooses the package, `package.json` supplies the version, and the run
fails if they disagree. A prerelease version goes out as `next`, otherwise
`latest`.
So: bump `packages/<pkg>/package.json`, land it on `main`, then tag that commit.
Both workflows also stamp `buildInfo.commit` (the 7-char sha) into the manifest
with the same `npm pkg set`, never committed either — the snapshot loop stamps
every package, and the release step stamps whichever one the tag named. Both
CLIs append it to `--version` in parentheses — `0.5.0 (ab12cd7)` — and print the
bare version when the field is absent, which is every run from source. The
version string itself is untouched: `nopy.cli.ts` and `keyman.cli.ts` decorate
only the string they print, while `updateNotice()` and `selfUpdate()` keep
reading the raw `version`, so channel derivation never sees the annotation. An
unknown top-level key is ignored by npm and `package.json` is always packed, so
nothing in `files` had to change. The two CLIs are kept in step here for the
same reason their update modules are duplicated rather than shared.
Three things the `workspace:*` links added, all of them non-obvious:
- **`pnpm publish`, never `npm publish`.** `link-workspace-packages` is unset and
pnpm 10+ defaults it to `false`, so `workspace:*` is mandatory in the manifests
— and npm does not understand it. `npm pack` ships the literal string and the
install fails with `EUNSUPPORTEDPROTOCOL`; `pnpm pack`/`pnpm publish` substitute
the real version at pack time. Both directions were measured, not assumed.
- **`scripts/verify-pack.mjs`** packs every non-private package and fails if any
`workspace:` range survived into a tarball. It runs in both publish workflows.
Note `pnpm pack` has no `--ignore-scripts` flag, so `prepack` does rebuild —
which means the artefact under test is the one publish ships.
- **Order.** `packages/*/` sorts `nopy` before `nopy-cube`, which is backwards.
`scripts/publish-order.mjs` topologically sorts over the `workspace:` edges;
the snapshot workflow stamps *every* version first and only then publishes in
that order, because `pnpm publish` reads the linked package's version at pack
time. `release.yml` additionally refuses to ship a package whose linked
dependency is not yet on npmjs (`scripts/linked-deps.mjs`) — npmjs is the
registry you cannot take a mistake back from.
## Known drift
`logConfigToFlags()` is exported and tested but nothing feeds its output into the
built pyinfra command, so `log.verbosity` / `log.debug` in `.nopyrc.json`
currently have no effect. Treat `docs/REFACTORING.md` as a plan, not a record.
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
project with npm and runs the binary. The npmjs lane has only ever published
`@bitsquare/nopy`; `keyman`, `nopy-cube` and `cubes-core` have never been
released there, so the *check linked deps are released* guard in `release.yml`
will stop the first `nopy` release until `nopy-cube` ships.
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
of this came from and is now a record of what was built, including what differed
from the plan.
`docs/API.md` was regenerated against the source and now covers every export in
`src/index.ts` plus the authoring package; its *Known gaps* section is the short
list of behaviour that surprises a reader (`--json` printing nothing on success,
`DeployCall.dependencies` always empty, `ExecutionResult.stdout` never populated,
no cycle detection, and `self-update` reporting an empty dist-tag as an
unreachable registry). `CubePackageRef` is referenced by the exported
`NopyConfig` but is not itself re-exported, so a consumer cannot name the type —
one line, not yet fixed. `DOCS-AUDIT.md` tracks the drift in the remaining
documents; §2.9 (the nopy README shipping yarn-workspace instructions to npmjs)
is closed, so the keyman README (§2.10) is now the worst of them.
+847
View File
@@ -0,0 +1,847 @@
# Documentation audit
Every claim in the repository's Markdown was checked against the source it
describes. Findings are grouped by *kind of divergence*, because the fix differs:
a phantom feature needs a decision (build it or delete the docs), a wrong claim
needs an edit, a gap needs prose.
Severity is about what it costs a reader:
- **🔴 broken** — following the documentation produces a wrong result or a crash.
- **🟠 misleading** — the documentation states something the code does not do.
- **🟡 gap** — the code does something real that no document mentions.
Verified against the working tree at commit `fcc1817`. Line numbers are from that
state.
Findings closed since are marked **✅ … fixed** and keep their original text as
the record of what was wrong. So far: §1.1 (`--use-defaults`), §2.2
(`getDefaults()`), §2.1 (precedence — the second half closed differently than
proposed), §3 in full (`docs/API.md`, regenerated), §4.2 (password on stdout —
points 1 and 2 of 3), §4.3 (what a session records), §2.9 (the nopy README's
yarn install instructions), and one bullet of §6.4.
Closing §3 also settled the documentation half of several findings elsewhere
without touching their underlying cause: §1.2, §1.3, §1.5, §2.3, §2.7, §4.4 and
§6.5 are each now stated accurately in `docs/API.md`, but the code still behaves
as those findings describe and they stay open.
---
## Contents
- [1. Documented features that do not exist](#1-documented-features-that-do-not-exist)
- [2. Documented behaviour that differs from the code](#2-documented-behaviour-that-differs-from-the-code)
- [3. ✅ `docs/API.md` — systematic drift — fixed](#3--docsapimd--systematic-drift--fixed)
- [4. Undocumented behaviour](#4-undocumented-behaviour)
- [5. Cube documentation](#5-cube-documentation)
- [6. Defects found while verifying](#6-defects-found-while-verifying)
- [7. Checked and accurate](#7-checked-and-accurate)
- [Suggested order of attack](#suggested-order-of-attack)
---
## 1. Documented features that do not exist
These are the same class of problem as the `--parallel` flag that was removed
earlier: documented in detail, absent from the source.
### 1.1 ✅ `-D, --use-defaults` does nothing — **fixed**
> **Resolved.** The flag is now implemented; see `docs/REFACTORING.md` item 5.
> `BuildContext.resolveCube` skips the prompts, `env` in `.nopyrc.json` outranks
> the schema default so a non-interactive run can be configured, and a cube with
> a variable nothing can fill aborts the run by name instead of deploying it
> blank. The finding below is kept as the record of what was wrong.
| | |
|---|---|
| **Docs say** | `README.md:291` "Install with defaults (no prompts for customization)"; `docs/API.md:39` "Skip variable prompts, use defaults"; `nopy.cli.ts:54` "Run cubes with default values without prompts" |
| **Code does** | Nothing. |
The option is threaded through four layers and then dropped. `nopy.cli.ts:95`
`nopy.main.ts:125``nopy.main.ts:174``BuildContext.options.useDefaults`
(`cubes/dependencies.ts:35`), where it is **never read**. The only branch that
skips prompting is `isSessionReplay` (`cubes/dependencies.ts:62`).
`runInteractiveWorkflow` destructures only `useAuthKey` and ignores its
`useDefaults` too (`nopy.workflow.ts:50`).
Every documented `-D` invocation — including
`nopy install -D --save-session automated-deployment.nopysession.json`
(`README.md:226`), which is presented as the way to do an unattended run —
prompts for every variable of every cube.
```
$ grep -rn "useDefaults" packages/nopy/src/
nopy.workflow.ts:19 useDefaults?: boolean; # declared
nopy.main.ts:94 useDefaults?: boolean; # declared
nopy.main.ts:125 useDefaults = false, # defaulted
nopy.main.ts:158 { useDefaults, useAuthKey },# passed
nopy.main.ts:174 useDefaults, # passed
nopy.cli.ts:95 useDefaults: options... # passed
cubes/dependencies.ts:35 useDefaults?: boolean; # declared — and that is all
```
### 1.2 🔴 `-j, --json` produces no output on success
| | |
|---|---|
| **Docs say** | `README.md:18` "**JSON output** for CI/CD integration"; `README.md:360-367` "Machine-readable JSON output for scripting and CI/CD integration"; `docs/API.md:573` |
| **Code does** | Emits JSON **only** on failure. |
`jsonOutput` reaches three places in `nopy.main.ts` (140, 150, 219) and none of
them prints a result. It suppresses the config banner, prints
`{success: false, errors}` when cube *loading* fails, and suppresses progress
lines. The success path at `nopy.main.ts:226-236` returns the `NopyResult` object
to the caller, and `nopy.cli.ts:107-110` inspects `result.success` without
printing it.
A CI job running `nopy install --json` gets pyinfra's inherited stdio and nothing
machine-readable. The exit code is the only usable signal.
Related: `--dry-run --json` prints the **text** plan, not JSON.
`executeDeployCalls` calls `outputExecutionPlan(calls)` without the `asJson`
argument (`nopy.executor.ts:172`), even though the function supports it
(`nopy.executor.ts:110`).
### 1.3 🟠 `log.verbosity` and `log.debug` have no effect
Pre-existing known drift, recorded in `CLAUDE.md`, but the README still presents
it as a working feature — two tables, a recommendation paragraph, and a slot in
the config example (`README.md:127-130`, `143-163`).
`logConfigToFlags()` (`nopy.config.ts:352`) is exported and has 8 unit tests, but
nothing calls it. `buildDeployCall` (`cubes/dependencies.ts:105-124`) constructs
the pyinfra argv without consulting `config.log` at all.
This is live in the repo's own config: `packages/nopy/.nopyrc.json` sets
`"verbosity": "trace", "debug": true` and gets neither.
### 1.4 🟠 Manifest `env` property
`README.md:14` lists "**Default values** with optional customization via manifest
`env`". `env` was removed from `Manifest` — see `docs/REFACTORING.md` item 3, and
the current interface at `cubes/types.ts:43-56`, which has `id`, `name`,
`schema`, `dependencies`, `before`, `after` and nothing else.
### 1.5 🟠 Topological sorting
`README.md:11` ("Dependency resolution with **topological sorting**"),
`README.md:25` ("Topologically sorts cubes based on dependencies") and
`README.md:381` ("because cubes are topologically sorted") describe an algorithm
that does not exist.
There is no sort. `BuildContext.resolveCube` recurses depth-first and pushes each
cube after its dependencies, with a `${cubeId}:${host}` set for idempotence
(`cubes/dependencies.ts:43-100`, `105-143`). The ordering is a side effect of the
recursion order.
This matters beyond vocabulary: a topological sort detects cycles, and this does
not. Two cubes that depend on each other recurse until the stack overflows —
`resolvedCubes` is only consulted in `buildDeployCall`, which runs *after* the
recursive call. `docs/API.md:160` still promises `Error` "if ... circular
dependency detected".
> The `API.md` promise is gone (§3): the regenerated file states that ordering
> falls out of the recursion and that there is no cycle detection. The README
> claims and the missing detection itself both stand.
---
## 2. Documented behaviour that differs from the code
### 2.1 ✅ Variable precedence is wrong in both directions — **fixed**
> **Resolved, though the second pair closed the opposite way to the README's
> original claim.** `env` now outranks the Zod defaults, as documented — that was
> a prerequisite for `--use-defaults` being configurable at all.
>
> Dependency/hook params still outrank prompts, deliberately. They do not compete
> in practice: `VariableAssignment` leaves out any key a dependency supplied, so
> the operator is never asked about it and there is no typed value to override.
> The README documents the real order rather than the old promise.
>
> The underlying complaint — that precedence was the field order of an object
> literal and so could be neither named nor questioned — is what
> `docs/REFACTORING.md` item 6 addresses. `Origin` now ranks
> `default < env < session < prompt < param` as data, and every value carries the
> origin it came from.
`README.md:107-113` stated:
> **Priority order (lowest to highest):**
> 1. Zod schema `.default()` values
> 2. Global `env` from `.nopyrc.json`
> 3. Accumulated variables from dependencies
> 4. User prompts / session replay
`Variables.get()` (`nopy.common.ts:33-39`) does:
```typescript
return {
...this.global, // 1. config env (lowest)
...this.defaults[id], // 2. Zod defaults
...this.prompts[id], // 3. what the user typed
...this.params[id], // 4. dependency / hook (highest)
};
```
Two pairs are inverted, and both have consequences:
- **Zod defaults beat `env`, not the other way round.** So `README.md:114`
"allowing users to override them globally via `.nopyrc.json`" — is backwards.
Setting `"env": {"UPDATE": false}` cannot override a cube declaring
`.default(true)`; the `env` value is only visible for keys the schema does not
define. This is why `KEY_DIR` works in `packages/nopy/.nopyrc.json` (no cube
declares it) and why anything else would not.
- **Dependency/hook parameters beat user prompts.** A value passed as
`[['user:add', {USER: 'deploy'}]]` silently overrides what the operator just
typed at the form. The docs promise the opposite.
### 2.2 ✅ "Every key ... is guaranteed to be present on `host.data`" — not when a field lacks `.default()` — **fixed**
> **Resolved.** `getDefaults()` falls back to a per-field read, so one required
> field no longer wipes out the rest; `VariableAssignment` prompts for every
> schema key rather than only the defaulted ones; and `--use-defaults` refuses
> to deploy a cube whose required key nothing supplied. Verified against all 22
> cubes in `cubes/`: 19 build a complete `-D` run, the 3 below abort by name.
>
> Re-measured against the 25 cubes now in `packages/cubes-core/cubes`: 20 build a
> complete `-D` run and 5 abort by name. Two of the additions are deliberate —
> `user:add` lost its `PUBKEY` default (it was a specific personal key), and
> `ssh:keygen` inherits that failure because it declares `dependencies: () =>
> ['user:add']` and passes no parameters. See §6.6.
`README.md:99` states it outright; `README.md:105` claims defaults ensure "every
cube has a predictable starting state".
`Cube.getDefaults()` (`cubes/types.ts:106-112`) is:
```typescript
try {
return this.manifest.schema.parse({});
} catch {
return {} as z.infer<Schema>;
}
```
One field without a `.default()` makes `parse({})` throw, and the `catch`
discards the defaults of **every other field in the cube**. `VariableAssignment`
then iterates over that empty object and returns before prompting
(`nopy.prompts.ts:175-181`), so the user is never asked. The cube deploys with no
`--data` flags at all and every `host.data.X` is `None`.
Three cubes in this repo are in that state today:
| Cube | Field(s) without `.default()` | Result |
|---|---|---|
| `net:wifi:connection` | `SSID`, `PASSWORD` | no prompt, no `--data`, all 4 vars lost |
| `service:autostart` | `APP` | no prompt, no `--data`, all 3 vars lost |
| `user:edit` | `USER` | no prompt, no `--data`, all 4 vars lost |
The failure is silent — no error, no warning, just a pyinfra run with an empty
data set.
### 2.3 🔴 `.describe()` before `.default()` loses the prompt label
`CLAUDE.md` and the cube contract state that each schema field is `.describe()`d
and "the description is the prompt label". `nopy.prompts.ts:184-185` reads it as:
```typescript
const zodType = schema[key];
const description = zodType?.description || key;
```
In zod 4, `.default()` returns a `ZodDefault` **wrapper** that does not inherit
`.description` from the type it wraps. Ordering therefore decides whether the
label survives:
```
z.boolean().describe('Update package cache').default(false) → description undefined
z.boolean().default(false).describe('Update package cache') → description preserved
```
The README's own manifest example (`README.md:67-68`) uses the losing order, so
anyone copying it gets bare `UPDATE` / `PACKAGES` keys as prompt labels instead
of the sentences they wrote. `docs/API.md:610` happens to use the working order —
the two documents disagree, and neither mentions that it matters.
> `docs/API.md` now says so explicitly, next to its manifest example, with the
> zod 4.4.3 measurement (§3). The README example and the 15 affected manifests
> are untouched, and the one-line fix in `nopy.prompts.ts` — read through the
> `ZodDefault` wrapper — is still the better answer.
15 of the 22 cubes in `cubes/` are affected; among them
`net:tailscale` (all 4 fields), `runtime:nodevm` (all 4), `user:add` (all 4),
`ssh:keygen` (all 4) and `admin:locale` (all 4).
### 2.4 🟠 Session files claim `version` and `timestamp` fields
`README.md:179-181` shows a session with `"version": "1.0.0"` and
`"timestamp": "2025-10-13T10:30:00Z"`, and `docs/SESSION_FORMAT.md:305-306`
declares both **required** in the `NopySession` interface. Every MJS example in
that file sets them.
`NopySession` (`nopy.session.ts:44-55`) has neither. `createSession`
(`nopy.session.ts:183-197`) does not add them, `saveSession` writes the object
verbatim (`nopy.session.ts:68-79`), and `loadSession`'s validation
(`nopy.session.ts:146-155`) checks only `cubes`, `hosts` and `auth`. The
repository's own `packages/nopy/example.nopysession.json` omits both — it does
not match the format its own documentation prescribes.
Consequence: a `version` field implies a compatibility check that does not exist.
Nothing reads it, so an incompatible old session fails later and more obscurely
than a version check would.
### 2.5 🟠 Session filename convention does not match `listSessions()`
The READMEs consistently use `*.nopysession.json` (`README.md:223`, `330`, `338`;
`docs/DOCKER.md:54`; the shipped `example.nopysession.json`).
`docs/SESSION_FORMAT.md` consistently uses `*.session.json` / `*.session.mjs`.
`listSessions()` (`nopy.session.ts:173`) matches only the second form:
```typescript
.filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs'))
```
`"my-deployment.nopysession.json"` does not end in `".session.json"`, so the
file naming the README recommends is invisible to the function documented at
`docs/API.md:430`. `loadSession` is unaffected (it switches on `.json`/`.mjs`),
so this only bites the listing API.
### 2.6 🟠 `docs/DOCKER.md` container name contradicts the file it points at
`docs/DOCKER.md:35` and `:45`:
> We explicitly name it `nopy-test-container` because the
> `example.nopysession.json` is configured to target this specific container name.
`packages/nopy/example.nopysession.json` targets `@docker/nopy-test-ubuntu`
the *image* tag from the build step, not the container name. Following the guide
exactly produces a pyinfra run against a container that does not exist.
(`packages/nopy/.nopyrc.json` does list `@docker/nopy-test-container` in `hosts`,
so the guide was probably written against the config rather than the session.)
### 2.7 🟠 `docs/HOOKS.md` — hook parameters are not validated
`docs/HOOKS.md:48` describes the hook's second argument as "The final,
**validated** variables for the current cube".
`cubes/dependencies.ts:71` passes `this.variables.get(cubeId)` — a plain merge of
the four scopes. `schema.parse()` is called in exactly one place,
`Cube.getDefaults()` on an empty object, to extract defaults. Values from
prompts, `env`, dependencies and hooks are never validated against the schema at
any point in the pipeline. `coerceValue` (`nopy.prompts.ts:145-159`) type-coerces
prompt input, which is not the same as validation and does not apply to the other
three scopes.
### 2.8 🟠 `docs/HOOKS.md` — dependencies can pass variables too
The comparison table at `docs/HOOKS.md:83` says variable passing is
"Inherited from env" for dependencies versus "Explicitly passed via `exec()`" for
hooks, presenting explicit parameters as a hook-only capability.
`DependencySpec` is `string | [id, variables?]` (`cubes/types.ts:23`) and
`cubes/dependencies.ts:86-88` unpacks the tuple and forwards it into the same
`params` scope that `exec()` writes to. The two mechanisms are identical in this
respect; per §2.1 both outrank user prompts.
### 2.9 ✅ nopy README installation section describes the wrong package manager — **fixed**
> **Resolved.** The yarn-workspace block is gone. The section now opens with
> `npm install -g @bitsquare/nopy` (and the pnpm equivalent), documents the
> `latest` / `next` / `main` channels, shows the `@bitsquare` scope mapping
> needed to install from Gitea, and gains an *Upgrading* section covering
> `nopy self-update` and the `NOPY_*` env vars. The finding below is kept as the
> record of what was wrong.
`README.md:248-279` says "This package is part of a **yarn** workspace monorepo",
then gives `yarn install`, `yarn workspace @bitsquare/nopy build`,
`yarn workspace @bitsquare/nopy nopy`, and `yarn nopy`.
The repo is a **pnpm** workspace: `packageManager: "pnpm@11.17.0"` in the root
manifest, `pnpm-workspace.yaml`, a `pnpm-lock.yaml`, and every other document
(root `README.md`, `README.PUBLISH.md`, `CLAUDE.md`) uses pnpm. There is no
`yarn.lock`.
The section is also the wrong content for the file. `README.md` is one of three
files shipped in the npm tarball (`files: ["dist", "README.md", "LICENSE"]`), so
this is what a reader sees on npmjs.com — build-from-monorepo instructions
instead of `npm install -g @bitsquare/nopy`, which is what the root README and
`README.PUBLISH.md:314` correctly tell people to run.
### 2.10 🟠 keyman README: two operations missing, one operation invented
`packages/keyman/README.md:90-96` lists four menu entries: List, Encrypt,
Decrypt, Quit. The menu (`keyman.main.ts:54-61`) has six:
```
📋 List keys 📝 Copy public key 🆕 Generate key
🔒 Encrypt keys 🔓 Decrypt keys ❌ Quit
```
`Copy public key` and `Generate key` are undocumented — the latter being the only
way to create a key inside the tool, which is why the Quick Start
(`packages/keyman/README.md:33`) tells the user to shell out to `ssh-keygen`
manually.
Conversely `packages/keyman/README.md:11` advertises "🔄 Support for key
rotation". There is no rotation anywhere: `grep -rn "rotat" packages/keyman/src/`
returns nothing.
`packages/keyman/README.md:93` also says encrypt takes keys "from `vault/tmp/`".
`encryptKeys` (`keyman.encrypt.ts:12-22`) unions `~/.ssh` and `vault/tmp`, and
offers both in the checkbox.
### 2.11 🟡 Root README understates the coverage gate
Root `README.md:57-58` describes "a hard **85 % branch** floor". Both
`vitest.config.ts` files set four thresholds: branches 85, functions 85, lines
80, statements 80. `README.PUBLISH.md:135` and `CLAUDE.md` both state all four —
the root README is the odd one out, and it is the file a new contributor reads
first.
### 2.12 🟡 `docs/DOCKER.md` relative link is broken
`docs/DOCKER.md:8` links `[README.md](./README.md)`, which resolves to
`packages/nopy/docs/README.md` — nonexistent. It should be `../README.md`.
---
## 3. ✅ `docs/API.md` — systematic drift — **fixed**
> **Resolved by regenerating the file**, which is what §3's own recommendation
> asked for — the drift was structural rather than a set of stale lines, so
> patching would have left the shape wrong. Every export in `src/index.ts` was
> re-read against its source and the file now covers all of them: the authoring
> package as its own section, `BuildContext` in place of the phantom Builder
> Module, and the variables, history and prompts modules that had no entry at
> all. The findings below are kept as the record of what was wrong.
>
> Three things were deliberately added rather than merely corrected. A
> **Known gaps** section states the behaviour a reader would otherwise take on
> trust — `logConfigToFlags` being unconsumed (§1.3), `--json` printing nothing
> on success (§1.2), the absent cycle detection (§1.5, §6.5), `DeployCall.dependencies`
> always being `[]`, `ExecutionResult.stdout`/`stderr` never being populated, and
> hook variables not being schema-validated (§2.7). The `.describe()`/`.default()`
> ordering hazard (§2.3) is called out where the manifest example lives, with the
> zod 4.4.3 measurement. And `-P` is documented alongside the rest of the CLI
> (§4.4 — the README half of that finding stands).
>
> One thing surfaced while writing it and is **not** fixed: `CubePackageRef` is
> referenced by the exported `NopyConfig` but is not itself re-exported from
> `src/index.ts`, so a consumer cannot name the type. Recorded in the file as a
> note.
`docs/API.md` documents an earlier architecture. It is not a matter of
individual stale lines: the two central type definitions, one whole module, and
two of the documented functions describe code that no longer exists. Anyone
building against this file writes code that will not compile.
Recommendation: regenerate rather than patch.
### 3.1 🔴 Functions that do not exist
| Documented | Reality |
|---|---|
| `resolveDependencies(cubes, selectedCubeNames)` (`API.md:142-160`) | No such export. Resolution is `BuildContext.resolveCube` and returns nothing — it accumulates into `deployCalls`. |
| `buildDeployCalls(cubeNames, hosts, context)` (`API.md:286-313`) | No such export. The entire "Builder Module" section, and its `BuildResult` interface, describes code replaced by `BuildContext` (`docs/REFACTORING.md` item 2). Still listed in the table of contents at `API.md:12`. |
### 3.2 🔴 `Cube<Schema>` — wrong shape entirely
`API.md:75-84` documents an interface with `key`, `dependencies: string[]`,
`schema`, `defaults()`, `before`, `after`.
`Cube` (`cubes/types.ts:88-113`) is a **class**: constructor `(manifest, dir,
deployScript)`, getters `id` and `name`, method `getDefaults()`. Everything else
lives behind `.manifest`. Not one documented member name is correct — `key` is
`id`, `defaults()` is `getDefaults()`, and `dependencies`/`schema`/`before`/
`after` are on `cube.manifest`, not on `cube`.
### 3.3 🔴 `Manifest<Schema>` — wrong shape
`API.md:92-100` documents `key`, `dependencies: string[]`, `defaults: () => ...`.
`Manifest` (`cubes/types.ts:43-56`) has `id` (not `key`), no `defaults` member at
all, and `dependencies` is a **function of the collected variables**:
```typescript
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
```
That signature change is the headline of `docs/REFACTORING.md` item 2. The
`API.md` example at `:171` does use the function form, so the file contradicts
itself two paragraphs apart. Both `Cube` and `Manifest` are additionally shown
as generic over `z.AnyZodObject`, which zod 4 removed; the codebase defines
`AnyObjectSchema` for exactly this reason (`cubes/types.ts:13`).
### 3.4 🟠 Incorrect signatures and examples
| Location | Documented | Actual |
|---|---|---|
| `API.md:486-492` | `saveConfig(data, local?)`, example passes `false` | `saveConfig(data, configPath?: string)` (`nopy.config.ts:323`). Passing `false` writes nothing useful. |
| `API.md:481-484` | Search order `./nopyrc.json` then `~/.nopyrc.json` | Filename is `.nopyrc.json` (leading dot). Home is applied **first** (lowest priority, `nopy.config.ts:117-120`), all ancestors are collected and merged root-first, and the function **throws** when none is found (`nopy.config.ts:285-289`). The `resolution` merge strategy is not mentioned. |
| `API.md:534-540` | `VariableAssignment(cube, env)` returning vars | `VariableAssignment(cube, variables: Variables)` returns `Promise<void>` and mutates the `Variables` instance (`nopy.prompts.ts:167`). The example's return value is always `undefined`. |
| `API.md:321-331` | `runWorkflow(sessionPath, cubes, config, options?)` | Takes a fifth parameter `replaySession?: NopySession` (`nopy.workflow.ts:206-212`) — the entire history-replay path. |
| `API.md:337-344` | `WorkflowResult.cubesWithDependencies` | Field is `selectedCubes` (`nopy.workflow.ts:31`). |
| `API.md:202-209` | `DeployCall.dependencies: string[]` | `DependencySpec[]` (`nopy.executor.ts:27`) — and always `[]` in practice (`cubes/dependencies.ts:132`). |
| `API.md:452-457` | `NopyConfig` with 4 fields | Missing `history` and `execution` (`nopy.config.ts:68-81`). |
| `API.md:37-45` | 7 `NopyOptions` parameters | Missing `printOnly`, `replaySession`, `saveToHistory` (`nopy.main.ts:93-104`). |
| `API.md:169-175` | `cubes.Manifest` example | Omits `id`, the field that determines the cube's identity. |
### 3.5 🟡 Exported and undocumented
Public API in `src/index.ts` with no `API.md` entry: the entire history module
(`addToHistory`, `listHistory`, `getLastSession`, `getSessionById`,
`clearHistory`, `removeFromHistory`, `loadHistory`, `saveHistory`,
`getHistoryPath`, `formatHistoryList`, `HISTORY_FILE`, `DEFAULT_HISTORY_SIZE`,
plus `HistoryEntry` / `SessionHistory`), `BuildContext`,
`runSessionReplayWorkflow`, `getConfigPaths`, `findCubeDirectories`, `getCube`,
~~`filterInternalVariables`, `separateEnvAndCubeVariables`~~ — those last two were
dead on arrival (nothing called them once the session recorder stopped splitting
`env` out) and have since been deleted rather than documented. `maskCommand`,
`maskVariables`, `Variable`, `Variables`, `MASK` and the `Origin` / `Assignment`
types are newly exported and also have no `API.md` entry.
The CLI cheat-sheet (`API.md:554-578`) omits `-R`, `-H`, `-P`, `--no-history`,
and the `history` / `clear-history` commands.
`ManifestFactory` (`cubes/factories.ts:28`) is marked `@deprecated` but is not
re-exported from `cubes/index.ts`, so it is unreachable dead code.
---
## 4. Undocumented behaviour
### 4.1 🔴 Cubes in this repo cannot be loaded
`CLAUDE.md` records the `@bitsquare/nopy` linking gotcha; the package README does
not mention it at all, and the gotcha is incomplete.
Cube manifests are loaded by dynamic `import()` from their own directory
(`cubes/loader.ts:75`), so they resolve their imports through ordinary Node
resolution from `cubes/…`. Nothing in the tree provides either dependency:
```
node_modules/@bitsquare/ → absent
packages/nopy/node_modules/@bitsquare/ → absent
cubes/node_modules/ → absent
```
`@bitsquare/nopy` is the documented half. **`zod` is the other half** — 20 of 22
manifests `import { z } from 'zod'`, and that fails independently of the nopy
link. Verified by loading every manifest with a resolver hook: with only
`@bitsquare/nopy` mapped, 20 of 22 fail `ERR_MODULE_NOT_FOUND: zod`.
Because `loadCubes` turns each failure into an `errors` entry and `nopy.main.ts:147-152`
aborts when `errors.length > 0`, a fresh clone cannot run a single cube. Neither
README mentions a setup step.
### 4.2 🟠 The SSH password is printed in plaintext — **mostly fixed**
> **Points 1 and 2 resolved; point 3 stands.** `maskCommand()`
> (`nopy.executor.ts`) rewrites the SSH `--password` and every `--data` value the
> manifest declared a secret, and it is applied at all three places the command
> string is printed: the debug log, the dry-run plan, and `--print-only`. The
> name heuristic described in point 2 is gone — the manifest's `secrets` array
> says which keys are sensitive, so `TOKEN`, `PSK` and `AUTH_KEY` are covered
> too, and it no longer matters that a key merely *looks* like a password.
>
> Point 3 is unchanged and now documented instead: the value still reaches
> pyinfra on its command line, so it is visible in `ps`. That is inherent to
> pyinfra's `--data` interface, not something nopy can mask. The shell-quoting
> concern in the same point is also still open. See `docs/REFACTORING.md` item 7.
Not stated in any document, and it sits directly against the security notes at
`README.md:217` and `:325` (which are narrowly about *storage*, and are correct
as far as they go).
`buildDeployCall` embeds the password in the command string
(`cubes/dependencies.ts:112-113`):
```typescript
parts.push(`--user ${this.auth.username} --password ${this.auth.password}`);
```
That string is then:
1. logged at debug level — `log.debug(\`Command: ${commandStr}\`)`
(`nopy.executor.ts:76`) — and the `nopy` logger is configured with
`lowestLevel: 'debug'` (`nopy.main.ts:41-44`), so it **prints to the console
on every deployment**;
2. printed unmasked by `--dry-run``outputExecutionPlan` masks values whose key
contains "password" in the `--data` section (`nopy.executor.ts:134`) but prints
`call.command.join(' ')` verbatim one line earlier (`nopy.executor.ts:127`);
3. passed through `execa({shell: true})` (`nopy.executor.ts:79`), making it
visible in the process list and, without quoting, vulnerable to shell
metacharacters in the password.
### 4.3 ✅ History and session files record only prompted values — **fixed**
> **Resolved.** `buildDeployCall` records `Variables.persistable(cubeId)` — every
> value the cube settled on, whatever its origin — so a `--use-defaults` run no
> longer records an empty object, and a replay reproduces the run rather than
> re-deriving it from whatever the defaults and `env` say at replay time. The one
> deliberate exclusion is a key the manifest declared a secret; those are prompted
> for again on replay, and a `-D` replay that would need one fails by name.
>
> The divergence noted at the end of this finding narrows but does not vanish: a
> dependency graph that resolves differently can still produce a different
> command, because `param` outranks `session` by design.
`README.md:414` says an entry records "the variable values that were answered at
the prompts" — accurate, but the consequence is not drawn out.
`buildDeployCall` records `this.variables.get(cubeId, 'prompts')`
(`cubes/dependencies.ts:138`) — the prompts scope alone. Values that came from a
dependency spec, a hook's `exec()`, `env`, or a schema default are **not** in the
entry.
Combined with §2.1 (dependency params outrank prompts) this means a replay can
legitimately produce a different command than the run it replays, if the
dependency graph resolved differently.
### 4.4 🟡 `-P, --print-only` is undocumented
Implemented (`nopy.cli.ts:61`, `nopy.main.ts:202-213`), listed in the CLI's own
help examples (`nopy.cli.ts:38`), and absent from `README.md` and `docs/API.md`.
It prints the built pyinfra commands grouped by cube and exits.
Worth documenting alongside `--dry-run`, since the difference is not obvious:
`--print-only` returns a `NopyResult` with `successful: 0` and skips execution
entirely, while `--dry-run` goes through the executor.
### 4.5 🟡 `--save-session` is ignored during a replay
`nopy.main.ts:191` guards with `saveSessionPath && !workflow.isReplay`, so
`nopy install -R -s out.json` writes nothing and says nothing. The
"Recording a Session" section (`README.md:219-227`) does not mention it.
### 4.6 🟡 `.npcubes` is documented but unused in this repo
`README.md:43` shows a `cubes/.npcubes` marker in the layout diagram and
`README.md:244` documents the mechanism. `find . -name .npcubes` returns nothing
— discovery here runs entirely off `cubeDirs` in `.nopyrc.json`. The feature
exists in `findCubeDirectories` (`cubes/loader.ts:26-30`); the diagram just shows
a file that no reader will find if they go looking.
### 4.7 🟡 `ssh:keyman` depends on a global `env` value
`cubes/ssh/keyman/deploy.py` reads `host.data.get('KEY_DIR')`, which no manifest
declares — it comes from `env` in `.nopyrc.json`. This works (§2.1: `env` is
visible for keys the schema does not define) and it is the only cube relying on
the mechanism, but nothing documents the coupling. Anyone running that cube from
a project without `KEY_DIR` in their config gets `None`.
---
## 5. Cube documentation
Two cubes have **no README at all**: `cubes/admin/hostname` and `cubes/git/clone`
(20 of 22 have one).
### 5.1 🔴 `cubes/service/autostart/README.md` documents a different cube
The file is titled **"TypeStack Install Cube"** and describes cloning a git
repository, `yarn install`, `yarn build`, `docker compose up -d`, and PM2 process
management. The manifest (`service:autostart`, "Manage systemd service
autostart") does none of that — it has three fields and calls `systemd.service`.
| README documents | In the schema? |
|---|---|
| `USER` | ❌ |
| `REPO` | ❌ |
| `ENV` | ❌ |
| `NODE_PATH` | ❌ |
| `APP` | ✅ |
| `AUTOSTART` | ✅ |
| — | `SERVICE_NAME` (undocumented) |
Every "Requirements" entry (Git, Yarn, Docker, PM2, NVM, SSH keys) is inapplicable.
It reads as a leftover from a cube that was split or renamed.
### 5.2 🟠 `cubes/network/wifi/access-point/README.md` — four wrong parameters
| README | Manifest |
|---|---|
| `NETWORK_DEVICE` (default `wlan0`) | does not exist |
| `CHANNEL` | does not exist — **but `deploy.py` reads it** (see §6.3) |
| `IP_ADDRESS` (default `192.168.50.1`) | field is `AP_IP`, default `192.168.4.1` |
| `CONNECTION_NAME` default `net:wifi:ap` | default is `pi-point` |
| SSID / PASSWORD listed as "Required" | both have defaults (`PiPoint` / `1223334444`) |
Both worked examples set keys that will be ignored.
### 5.3 🟠 Two cubes claim to have no parameters
| Cube | README says | Schema has |
|---|---|---|
| `cubes/runtime/docker` | "This cube currently has no configurable parameters." | `DISTRO` (`ubuntu` \| `debian`) |
| `cubes/runtime/nodevm` | "This cube currently has no configurable parameters." | `VERSION`, `USER`, `ALIAS`, `GLOBAL_PACKAGES` |
`nodevm`'s README also describes installing "the latest LTS via the official
NodeSource setup script", while the manifest is named "Install nvm and nodejs
with global packages" and takes an explicit `VERSION`.
(`admin/cockpit` and `armor/fail2ban` make the same claim and are correct — both
have empty schemas.)
### 5.4 🟡 Cube id conventions are inconsistent
- `cubes/caddy/base` declares `id: 'caddy'` while its sibling declares
`caddy:spa`. Every other nested cube uses the `group:name` form.
- `net:wifi:connection` sets `name: 'network:wifi:connection - Connect to a WiFi
network'` and `user:edit` sets `name: 'user:edit - Modify an existing user
account'` — the id is baked into the display name. Since the picker renders
`${cube.id} - ${cube.name}` (`nopy.prompts.ts:43`), these show as
`net:wifi:connection - network:wifi:connection - Connect to a WiFi network`.
`README.md:54` documents `[id]`-in-name as a *fallback* for a missing `id`
field, not as a prefix to carry alongside one.
---
## 6. Defects found while verifying
Not documentation issues, but found while checking the docs and worth recording.
### 6.1 🔴 `cubes/service/autostart/deploy.py` cannot run
```python
from pyinfra.operations import systemd # `server` is never imported
from pyinfra import host
APP = host.data.APP # AUTOSTART and SERVICE_NAME never read
if AUTOSTART: # NameError
...
server.shell(...) # NameError, in the else branch
```
`AUTOSTART` and `SERVICE_NAME` are declared in the manifest and never pulled off
`host.data`; `server` is used but not imported. The script raises `NameError` on
the `if`. Per §2.2 this cube also gets no `--data` at all, so it fails twice over.
### 6.2 🔴 `-H <id>` and `--no-history` share one destination
Both options write to `options.history` (`nopy.cli.ts:57` and `:64`). Verified
with Commander:
```
argv=[] -> {} # undefined → saves
argv=["--no-history"] -> {"history":false} # correct
argv=["-H","abc123"] -> {"history":"abc123"} # correct
argv=["-H","abc123","--no-history"] -> {"history":false} # id destroyed
```
In the last case the `-H` argument is silently discarded and nopy falls through
to a full interactive run instead of replaying. `saveToHistory: options.history
!== false` (`nopy.cli.ts:104`) works only because the two meanings happen not to
collide in the common cases.
### 6.3 🟠 `access-point/deploy.py` reads an undeclared variable
`host.data.get('CHANNEL')` — no manifest declares `CHANNEL`, so it is always
`None`. The README documents it as a supported optional parameter (§5.2). One of
the three has to give.
### 6.4 🟡 Debug output left in the shipped code
- ~~`nopy.common.ts:22` — `console.log('Assigning', artefactId, scope, values)`
fires on every variable assignment, printing values to the console. Combined
with §4.2 this is a second path by which secrets reach stdout.~~ **Removed**
alongside the `--use-defaults` work; it would have made an unattended run
unreadable. The two other paths in §4.2 are untouched.
- `keyman.encrypt.ts:19-20` — `console.log(tmpKeys); console.log(sshKeys);`
before the prompt.
### 6.5 🟡 No cycle detection
Covered under §1.5. `docs/API.md:160` documents the error; there is no code that
raises it. Mutually dependent cubes recurse until the stack overflows.
### 6.6 🟠 `ssh:keygen` depends on `user:add` but shares nothing with it
`ssh:keygen` declares `dependencies: () => ['user:add']` with no parameters, so
the two cubes resolve their `USER` independently:
- `ssh:keygen` defaults `USER` to `vagrant`;
- `user:add` defaults `USER` to `` user${uniqid(5)} `` — a fresh random name.
So the dependency creates an account the dependent then ignores, and generates a
key for a `vagrant` user it never created. Passing the value through
(`[['user:add', {USER}]]`) is what the dependency spec exists for; `param`
outranks `default`, so it would take effect.
Surfaced by removing `user:add`'s `PUBKEY` default: `ssh:keygen` now fails a `-D`
run with `Cube "user:add" cannot run with --use-defaults: PUBKEY has no default
value`, naming a cube the operator did not select. The underlying mismatch is
older than that change and is not fixed here.
Related: `user:add`'s `USER` default is generated (`` user${uniqid(5)} ``), the
same shape as the `PASSWORD` default that was removed. It is recorded in the
session, so replays are stable, but each fresh `-D` run still creates a
differently-named account.
---
## 7. Checked and accurate
Recording what was verified and found correct, so a future pass need not redo it.
- **`README.PUBLISH.md`** — checked against `.gitea/workflows/*.yml` and both
manifests. Workflow triggers, the `files` array, dist-tag rules, the snapshot
version format, `upload-artifact@v3`, `cache@v4`, the `npm pack --dry-run`
step, `retention-days: 7`, and all four coverage thresholds are right. The
only file that states the coverage gate completely.
- **Root `README.md`** — pnpm/corepack, Node ≥ 22 with `.nvmrc` pinning 24, the
script table, and both git hooks match the root `package.json`. Only the
coverage line is incomplete (§2.11).
- **`CLAUDE.md`** — accurate throughout, including the `logConfigToFlags` drift
note and the resolution/merge description. Two additions worth making: `zod` is
missing from the cube-linking gotcha (§4.1), and `-D` being a no-op (§1.1)
belongs under "Known drift".
- **`README.md` history section** (`:392-429`) — the recording rules, the
fail-then-`-R` flow, replay-does-not-re-record, the four suppression cases, the
`defaults`-layer replay semantics, and the `Cube not found` failure mode all
check out against `nopy.history.ts`, `nopy.main.ts:195-200` and
`cubes/dependencies.ts:62-69`.
- **`README.md` continue-on-error section** (`:369-390`) — fail-fast, no
rollback, skipped-cubes-absent-from-results, exit code 1, and CLI-over-config
precedence match `nopy.executor.ts:180-193` and `nopy.cli.ts:67-68`.
- **`README.md` cube layout and discovery** — the directory-pair rule, recursive
scan, dotted/`node_modules` skipping, the prefixed `*.manifest.mjs` fallback,
and the three-step id resolution match `cubes/loader.ts` exactly.
- **pyinfra `--data` type coercion** (`README.md:101`) — correct.
- **keyman config** — priority (`VAULT_ROOT` > file > defaults), the four default
values, and the vault layout match `keyman.config.ts` and `keyman.encrypt.ts`.
---
## Suggested order of attack
**1 — ~~Decide on the three phantom features.~~ Two left.** §1.1 (`-D`) is
**done** — implemented, tested, and verified against every cube in `cubes/`.
That closed §2.2 and half of §2.1 with it, since neither could be left standing
under a run that never prompts. §1.2 (`--json`) and §1.3 (`log.*`) are still
"documented, wired up, never read": each is a small implementation or a small
deletion, but neither can stay documented as working.
**4 — Decide the `.describe()`/`.default()` ordering (§2.3).** Either read
through the `ZodDefault` wrapper in `nopy.prompts.ts`, or fix the ordering in all
14 manifests and the README example. The first is one line and cannot regress.
**5 — ~~Regenerate `docs/API.md` (§3).~~ Done.** Rewritten against the source
rather than patched, and extended to the exports that never had an entry
(variables, history, prompts, the authoring package). One new finding came out of
it: `CubePackageRef` is not re-exported from `src/index.ts` although `NopyConfig`
refers to it — a one-line fix, left for whoever next touches the export list.
**6 — Cube docs (§5) and the two missing READMEs.** `service/autostart` is the
worst — its README belongs to a different cube, and its `deploy.py` does not run
at all (§6.1).
**7 — Secrets on stdout (§4.2, §6.4).** The `console.log` in `Variables.assign`
is gone. Still open: mask the password in the executor's debug line and in the
dry-run plan, and pass `--user`/`--password` as argv rather than interpolating
into a shell string.
+375 -44
View File
@@ -14,6 +14,9 @@ shipped. If you only want to cut a release, jump to
- [Secrets](#secrets)
- [Registry authentication in the workflows](#registry-authentication-in-the-workflows)
- [Installing the packages](#installing-the-packages)
- [Resolving from Gitea in this repo](#resolving-from-gitea-in-this-repo)
- [Testing a snapshot before you release](#testing-a-snapshot-before-you-release)
- [Upgrading an installed CLI](#upgrading-an-installed-cli)
- [Design decisions](#design-decisions)
- [Checking things locally](#checking-things-locally)
- [Troubleshooting](#troubleshooting)
@@ -21,19 +24,44 @@ shipped. If you only want to cut a release, jump to
## What ships
| Directory | Package | Binary |
| ----------------- | ------------------ | -------- |
| `packages/nopy` | `@bitstack/nopy` | `nopy` |
| `packages/keyman` | `@bitstack/keyman` | `keyman` |
| Directory | Package | Binary | Kind |
| --------------------- | ----------------------- | -------- | ------------------------ |
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | CLI |
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | CLI |
| `packages/nopy-cube` | `@bitsquare/nopy-cube` | — | library (cube authoring) |
| `packages/cubes-core` | `@bitsquare/cubes-core` | — | cube bundle (no build) |
Both are ESM, both declare `engines.node >= 22`, and both expose a single
All are ESM and declare `engines.node >= 22`. The two CLIs expose a single
executable through `bin`, so `npm install -g` puts `nopy` / `keyman` on the
`PATH`. `cubes/` is not a package and is never published.
`PATH`; the other two are libraries you add to a project.
The tarball contents are pinned by `files: ["dist", "README.md", "LICENSE"]`
sources and tests are not shipped. `publishConfig.access: "public"` is what makes
a scoped package publishable to npmjs without an extra flag; the workflows pass
`--access public` anyway.
The tarball contents are pinned by `files` — for the three TypeScript packages
that is `["dist", "README.md", "LICENSE"]`, so sources and tests are not shipped.
`cubes-core` ships `["cubes", "!cubes/**/*.log", "README.md", "LICENSE"]`: the
negation matters, because a cube that has been run leaves a `pyinfra-debug.log`
next to its `deploy.py`, and `.gitignore` does not filter an npm tarball.
`publishConfig.access: "public"` is what makes a scoped package publishable to
npmjs without an extra flag; the workflows pass `--access public` anyway.
### Dependencies between them
`keyman` stands alone. `nopy` and `cubes-core` both depend on `nopy-cube` through
`workspace:*`, which drives three rules the rest of this document keeps coming
back to:
1. **Publish with `pnpm`, not `npm`.** `link-workspace-packages` is unset and
pnpm 10+ defaults it to `false`, so `workspace:*` is mandatory in the
manifests. npm has no idea what that protocol is: `npm pack` copies the string
through verbatim and the install fails with `EUNSUPPORTEDPROTOCOL`. `pnpm
pack` and `pnpm publish` substitute the concrete version at pack time. Both
workflows use `pnpm publish --ignore-scripts --no-git-checks`.
2. **`nopy-cube` publishes before anything that depends on it.**
`node scripts/publish-order.mjs` prints the publishable directories in
dependency order — note that plain alphabetical `packages/*/` gets this
backwards, putting `nopy` first.
3. **Every packed manifest is checked.** `node scripts/verify-pack.mjs` packs
each non-private package and fails if any `workspace:` range survived into the
tarball. It runs in both publish workflows, after the build.
Versions and changelogs are maintained **by hand**. Nothing in CI commits a
version bump, opens a release PR, or pushes a tag. A release happens because you
@@ -47,7 +75,7 @@ All three live in [`.gitea/workflows`](.gitea/workflows) and run on the
| Workflow | Trigger | Publishes |
| ---------------------- | -------------------------------- | ------------------------------------------ |
| `ci.yml` | pull requests, non-`main` pushes | nothing |
| `publish-snapshot.yml` | pushes to `main` | **both** packages → Gitea, tag `main` |
| `publish-snapshot.yml` | pushes to `main` | **every** package → Gitea, tag `main` |
| `release.yml` | tags matching `*-v*` | **one** package → Gitea **and** npmjs |
`ci.yml` explicitly excludes `main` and all tags (`branches-ignore` +
@@ -64,13 +92,17 @@ half-finished publish is worse than a slow queue.
```
checkout → pnpm → node → pnpm store cache → install
→ lint:ci → typecheck → test:coverage → coverage summary
→ build → npm pack --dry-run (per package) → upload coverage
→ build → npm pack --dry-run (per package) → verify-pack
→ upload coverage
```
The `npm pack --dry-run --ignore-scripts` step prints the exact file list that
would be published. It is there to catch a `files` or `bin` entry pointing at
something the build no longer produces — a failure that would otherwise only
surface after the version is already on a registry and immutable.
surface after the version is already on a registry and immutable. `verify-pack`
then packs for real and checks no `workspace:` range survived; both publish
workflows run it too, but running it here is what puts the failure on the pull
request rather than on the release.
Coverage HTML/JSON reports are uploaded as a `coverage` artifact with a 7-day
retention. Both the artifact upload and the store cache are
@@ -82,18 +114,25 @@ slower CI rather than broken CI.
```
checkout → pnpm → node → cache → install
→ lint:ci → typecheck → test:coverage → coverage summary → build
→ write .npmrc → publish both packages → delete .npmrc
verify-pack → write .npmrc → publish every package → delete .npmrc
```
One job, no `needs:` barrier, so install and build happen exactly once and
nothing has to be passed between jobs as an artifact.
The publish step is **two passes** over `node scripts/publish-order.mjs`: the
first stamps the snapshot version into every manifest with `npm pkg set`, the
second publishes. They cannot be one loop — `pnpm publish` reads a linked
package's version out of its manifest at pack time, so stamping and publishing
one package at a time would bake the *old* `nopy-cube` version into `nopy`'s
tarball.
### `release.yml`
```
checkout → resolve tag → check secrets
→ pnpm → node → cache → install
→ lint:ci → typecheck → test:coverage → build
→ pnpm → node → cache → install → check linked deps are released
→ lint:ci → typecheck → test:coverage → build → verify-pack
→ publish to Gitea → publish to npmjs → delete .npmrc
→ create the Gitea release → step summary
```
@@ -102,6 +141,14 @@ Tag resolution and the secret check run **before** anything is installed or
built, so a malformed tag or a missing token fails in seconds instead of after
the whole gate.
*Check linked deps are released* asks npmjs whether every `workspace:` dependency
of the package being released already exists at the version pnpm is about to
bake in (`scripts/linked-deps.mjs``npm view`). Tagging `nopy-v1.3.0` while
`@bitsquare/nopy-cube@1.1.0` is still unpublished would otherwise ship a tarball
nobody can install, and npmjs only lets you unpublish for 72 hours. The check is
npmjs-only: it runs before any credentials are written, and npmjs is the registry
where the mistake is permanent.
## The verification gate
The same three commands guard every path into a registry:
@@ -132,18 +179,43 @@ and `continue-on-error: true` — and can never be the reason a run goes red.
| Source | Version | Registry | dist-tag |
| ----------------------------------------- | ----------------------------- | ------------ | -------- |
| push to `main` | `1.0.0-main.42.g736c012` | Gitea | `main` |
| tag `nopy-v1.2.0` | `1.2.0` | Gitea, npmjs | `latest` |
| tag `nopy-v1.2.0-rc.1` | `1.2.0-rc.1` | Gitea, npmjs | `next` |
| push to `main` | `0.5.0-main.42.g736c012` | Gitea | `main` |
| tag `nopy-v0.6.0` | `0.6.0` | Gitea, npmjs | `latest` |
| tag `nopy-v0.6.0-rc.1` | `0.6.0-rc.1` | Gitea, npmjs | `next` |
The rule for the dist-tag is mechanical: a version containing a prerelease part
(anything with a `-` in it) goes out as `next` and is marked as a prerelease on
the Gitea release; anything else goes out as `latest`. There is no way to publish
a prerelease over `latest` by accident.
### Why 0.x and not 1.0.0-alphaN
The packages used to be numbered `1.0.0-alpha5`, `1.0.0-alpha0` and so on. Every
one of those is a prerelease, so the rule above sent every release to `next` and
**`latest` never moved**. That is a quiet failure rather than a loud one: on
npmjs `latest` happened to point at `1.0.0-alpha5` only because npmjs sets
`latest` on a package's *first* publish whatever `--tag` says, and it would have
stayed pinned there through every subsequent alpha. On Gitea, which has no such
fallback, `latest` did not exist at all — and `npm view @bitsquare/nopy` against
a registry with no `latest` prints nothing and exits **0**, so it looks like a
successful lookup of a package with no data.
`0.x.y` says the same thing about stability that `1.0.0-alphaN` was trying to
say, while leaving the prerelease slot free for actual release candidates. So
`latest` rolls on every release, `next` means what it says, and no dist-tag has
to be moved by hand.
> **One-off consequence of the switch.** `1.0.0-alpha5` is semver-*greater* than
> any `0.x`, and it is already on npmjs. Publishing `0.5.0` moves the `latest`
> tag to it correctly, but the alpha remains the numerically highest version on
> the registry. Install with an explicit tag (`npm i -g @bitsquare/nopy@latest`,
> which follows the tag and will downgrade), not with `npm update -g`. Consider
> `npm deprecate '@bitsquare/nopy@1.0.0-alpha5' 'Superseded by the 0.x line'` so
> nobody lands on it by pinning.
## Snapshots
Every commit that lands on `main` publishes both packages to the Gitea registry,
Every commit that lands on `main` publishes every package to the Gitea registry,
versioned as:
```
@@ -155,7 +227,7 @@ semver even when the abbreviated sha happens to be all digits. The run number is
monotonic, so every push produces a version that has never existed before.
```sh
pnpm add @bitstack/nopy@main
pnpm add @bitsquare/nopy@main
```
Snapshots never reach npmjs and never move `latest`. The version is written into
@@ -175,7 +247,34 @@ edit is discarded with the workspace and is never committed.
```
The tag name is `<directory>-v<version>` — the directory under `packages/`, not
the npm name. `nopy-v1.2.0`, not `@bitstack/nopy-v1.2.0`.
the npm name. `nopy-v1.2.0`, not `@bitsquare/nopy-v1.2.0`. All four prefixes work
the same way:
```sh
git tag nopy-v1.2.0
git tag keyman-v1.2.0
git tag nopy-cube-v1.2.0
git tag cubes-core-v1.2.0
```
### Ordering when more than one package changed
Tags are independent, but the dependency graph is not. If a release touches
`nopy-cube` *and* something that depends on it, release them in this order,
waiting for each run to go green:
```
nopy-cube → nopy, cubes-core (these two are independent of each other)
```
Release `nopy` first and the run stops at the *check linked deps* step, telling
you the `nopy-cube` version it wanted is not on npmjs. That is the guard working;
release `nopy-cube`, then re-tag. `node scripts/publish-order.mjs` prints the
order if you would rather not reason about it.
Bumping `nopy-cube` means bumping the packages that depend on it in the same
change — the `workspace:*` range resolves to whatever version is in the workspace
at pack time, so their next release picks it up whether or not you meant it to.
The tag decides **which** package ships; `package.json` decides the **version**.
The workflow re-reads the manifest and refuses to continue if the two disagree:
@@ -193,7 +292,7 @@ a coincidence, not a requirement.
What a successful run leaves behind:
- `@bitstack/<pkg>@<version>` on the Gitea registry
- `@bitsquare/<pkg>@<version>` on the Gitea registry
- the same tarball on npmjs, public, under `latest` or `next`
- a Gitea release on the tag, with notes and an install snippet
- a step summary with both install commands
@@ -228,7 +327,7 @@ organisation to share across repos.
| Secret | Required | Purpose |
| ----------------- | -------- | --------------------------------------------------------- |
| `NPM_TOKEN` | yes | npmjs granular token, read-and-write on `@bitstack/*` |
| `NPM_TOKEN` | yes | npmjs granular token, read-and-write on `@bitsquare/*` |
| `MYGITEA_NPM_TOKEN` | yes | Gitea PAT with `write:package` |
`GITEA_TOKEN` is injected into every run by Gitea itself, and the workflows fall
@@ -241,12 +340,12 @@ practice. Create it under **Settings → Applications → Access Tokens** with t
`package` scope set to read-and-write; its owner needs package-write on the
`BitSquare` organisation, since the registry path is org-owned.
For npmjs, create a **granular access token** scoped to `@bitstack/*` with
For npmjs, create a **granular access token** scoped to `@bitsquare/*` with
read-and-write permission, and set 2FA to not-required so it works
unattended. npm warns against that combination and points at Trusted Publishing
instead — but Trusted Publishing federates only GitHub Actions and GitLab CI/CD
over OIDC, and Gitea is not a provider it accepts. A token is the only route
from this runner. Scoping the token to `@bitstack/*` is what keeps the exposure
from this runner. Scoping the token to `@bitsquare/*` is what keeps the exposure
small: a leak lets someone publish to that scope, not touch the account.
> npm caps granular token lifetime at 90 days, so `NPM_TOKEN` needs rotating
@@ -264,7 +363,7 @@ small: a leak lets someone publish to that scope, not touch the account.
## Registry authentication in the workflows
`release.yml` has to talk to two different registries about the same `@bitstack`
`release.yml` has to talk to two different registries about the same `@bitsquare`
scope inside one job. It does that without ever mutating `~/.npmrc`:
- each publish step writes its own credentials file, created with
@@ -281,23 +380,32 @@ file written into the workspace can never be committed by accident.
## Installing the packages
From npmjs — public, no configuration:
From npmjs — public, no configuration. The CLIs go on the `PATH`:
```sh
npm install -g @bitstack/nopy @bitstack/keyman
npm install -g @bitsquare/nopy @bitsquare/keyman
```
The other two go into a project. A cube bundle is a dev dependency of whatever
repo describes your infrastructure; `nopy-cube` is only needed if you are writing
cubes of your own:
```sh
pnpm add -D @bitsquare/cubes-core # then name it in .nopyrc.json cubePackages
pnpm add -D @bitsquare/nopy-cube zod # authoring your own manifests
```
From the Gitea registry, which holds every snapshot plus a mirror of every
release. Per-project, in the repo's `.npmrc`:
```ini
@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
```
Globally with credentials, in `~/.npmrc`:
```ini
@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
//gitea.bitsquare.dev/api/packages/BitSquare/npm/:_authToken=<your gitea token>
```
@@ -308,9 +416,173 @@ instance and organisation automatically.
To track snapshots in another project:
```sh
pnpm add @bitstack/nopy@main
pnpm add @bitsquare/nopy@main
```
> Always map the **scope**, never set a bare `registry=`. The Gitea registry
> serves `@bitsquare` packages and does not proxy npmjs, so a global
> `--registry` sends `commander`, `execa`, `zod` and everything else to a
> registry that has never heard of them. The CLI's own `self-update` builds
> `--@bitsquare:registry=<url>` for the same reason.
## Resolving from Gitea in this repo
This repository ships a root [`.npmrc`](.npmrc) that maps the scope:
```ini
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
```
So any npm or pnpm command run from inside the repo resolves `@bitsquare/*` from
Gitea, with no flags — including a global install, since npm reads the project
`.npmrc` for those too:
```sh
npm install -g @bitsquare/nopy@main # the newest snapshot, no flags needed
```
This is not a trade against npmjs. Gitea is a strict **superset** of it for this
scope: `release.yml` publishes to both, `publish-snapshot.yml` pushes a `main`
snapshot to Gitea on every push, and today three of the four packages exist
*only* there. Pointing the scope at Gitea gains the snapshots and loses nothing.
`.npmrc` is otherwise gitignored — the publish workflows write credentials into
`.npmrc-gitea` / `.npmrc-release` — so `.gitignore` carries a `!/.npmrc`
negation for the root file specifically. **It contains the scope mapping and
nothing else.** Reads are anonymous; no token belongs in a committed file.
It cannot affect `pnpm install`: every `@bitsquare` dependency in the workspace
is a `workspace:*` range that resolves to a `link:`, so nothing in the tree is
ever fetched from that scope. Verified with `pnpm install --frozen-lockfile`.
Two consequences worth knowing:
- **An untagged install resolves to nothing.** Gitea currently publishes no
`latest` dist-tag, so `npm i -g @bitsquare/nopy` finds no version — and npm
reports that by printing nothing and exiting 0. Always name a tag (`@main`,
`@next`) until the first `0.x` release lands. See
[Why 0.x and not 1.0.0-alphaN](#why-0x-and-not-100-alphan).
- **Bare lookups now answer for Gitea.** `npm view @bitsquare/nopy` run from
the repo queries Gitea. Pass `--registry https://registry.npmjs.org/` when you
specifically mean npmjs.
To see both registries at once — which versions exist where, and which are on
Gitea only and therefore still testable and still un-published:
```sh
pnpm run registry:status
pnpm run registry:status -- --json
```
Working **outside** the repo, set the same mapping globally once:
```sh
npm config set @bitsquare:registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
npm config delete @bitsquare:registry # back to npmjs
```
`nopy self-update` reads that key too (`npm config get @bitsquare:registry`), so
a CLI installed from Gitea keeps checking Gitea for its own updates with nothing
else configured.
### Why the publish jobs delete it
A scoped mapping is not just another way to say `--registry`. For a **scoped**
package npm resolves `@scope:registry` *before* `registry`, so the scoped key
wins no matter how the plain one was set — including on the command line. And a
project `.npmrc` outranks the userconfig the workflows write.
Left in place, that combination silently redirects the npmjs release lane:
```console
$ pnpm publish --tag latest --access public --registry https://registry.npmjs.org/ --dry-run
📦 @bitsquare/nopy@0.5.0 → https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
```
Not hypothetical — that is the workflow's own command, measured. The
`npm view … --registry <npmjs>` idempotency guard inverts the same way: it
answers from Gitea, finds the version already there, and **skips the npmjs
publish entirely**. A release that reports success and shipped nothing.
Both publish workflows therefore `rm -f .npmrc` right after checkout, and every
publish and lookup names its registry as `--@bitsquare:registry=<url>`. Either
fix alone is sufficient — both are verified independently — and the pair means a
command added later cannot quietly inherit the wrong registry. Nothing else in
the job is affected: every `@bitsquare` range in the workspace is `workspace:*`,
so no install resolves through that scope.
## Testing a snapshot before you release
Every push to `main` publishes a snapshot, so the rehearsal for a release is to
install one the way a stranger would:
```sh
pnpm run try:snapshot # @main from Gitea
pnpm run try:snapshot -- --tag latest # a release, from Gitea
pnpm run try:snapshot -- --registry https://registry.npmjs.org/
pnpm run try:snapshot -- --keep # keep the directory
```
`scripts/try-snapshot.mjs` builds a throwaway project in a temp directory,
points the `@bitsquare` scope at the registry, installs `@bitsquare/nopy` and
`@bitsquare/cubes-core` at that tag, and then:
- asserts the installed `nopy` declares a **concrete** `nopy-cube` version
rather than a leaked `workspace:*` range;
- prints the three resolved versions, so you can see which commit you are on;
- runs `nopy --version`;
- runs `nopy install -P -D` with stdin closed and asserts the cube-selection
prompt listed cubes from the bundle — which only happens if the loader
resolved the package out of `node_modules` and imported every manifest.
It uses **npm**, not pnpm, on purpose: npm is the client that rejects a leaked
`workspace:` range, so a clean install here is the stronger proof. This is the
check `verify-pack.mjs` cannot be — that one inspects a local tarball, this one
goes to the real registry and runs the real binary.
The directory is deleted on success and left behind on failure, with its path
printed.
## Upgrading an installed CLI
Both CLIs can update themselves:
```sh
nopy self-update
keyman self-update
```
Each derives its channel from the version it is running — a `-main.` prerelease
came from the snapshot workflow, any other prerelease from `next`, a clean
version from `latest` — so an upgrade keeps you on the channel you installed
from instead of quietly moving you to another one. The registry comes from
`npm config get @bitsquare:registry`, so an install from Gitea checks Gitea
without any further configuration. The package manager is detected from the
install path (npm, pnpm, yarn or bun), so the update does not leave two copies
on the `PATH`.
```sh
nopy self-update --dry-run # print the command, change nothing
nopy self-update --force # reinstall even when up to date
nopy self-update --channel next # switch channel
nopy self-update --registry <url> # check somewhere else
```
Once a day each CLI checks its channel at startup and prints a one-line hint to
**stderr** when something newer exists — never stdout, so `--json` and
`--print-only` stay machine-readable. Results are cached in
`~/.nopy/update-check.json` and `~/.keyman/update-check.json`; an unreachable
registry gets 1.5 seconds and is then ignored. The check is off whenever `CI` is
set, and `NOPY_NO_UPDATE_CHECK=1` / `KEYMAN_NO_UPDATE_CHECK=1` turn it off
explicitly. `NOPY_REGISTRY`, `NOPY_REGISTRY_TOKEN` and `NOPY_PACKAGE_MANAGER`
(and the `KEYMAN_` equivalents) override the three things it detects.
The logic lives in `packages/nopy/src/nopy.update.ts` and
`packages/keyman/src/keyman.update.ts` — two near-identical copies. keyman
shares no internal library with nopy by design, and a fifth workspace package
for ~250 lines would add another edge to the publish order for nothing. If a
third CLI appears, extract it then.
## Design decisions
**Every publish is idempotent.** Each step asks the registry whether that exact
@@ -321,9 +593,17 @@ refuses to overwrite an existing version — without the check, a re-run would f
on the first registry and never reach the second.
**The build is explicit, publishes are `--ignore-scripts`.** `prepack` exists for
humans running `npm pack` locally; in CI the build has already run as its own
step, and repeating it inside `npm publish` would only cost time and add a way
for a lifecycle script to change what ships after the gate looked at it.
humans packing locally; in CI the build has already run as its own step, and
repeating it inside `pnpm publish` would only cost time and add a way for a
lifecycle script to change what ships after the gate looked at it.
**`verify-pack.mjs` checks the artefact, not the source.** Reading `package.json`
in the repo would only tell you what you already know — every one of them says
`workspace:*`. The question is what pnpm wrote into the tarball, so the script
packs, extracts `package/package.json`, and reads the ranges back out. It cannot
pass `--ignore-scripts`, because `pnpm pack` has no such flag (only `pnpm publish`
does), so `prepack` rebuilds — which at least means the tarball under test is
byte-for-byte the one publish would ship.
**One job per workflow.** No artifact hand-off, no second install, no risk of
publishing a tree that a different job built.
@@ -352,7 +632,44 @@ See what would actually be in the tarball:
```sh
pnpm run build
cd packages/nopy && npm pack --dry-run --ignore-scripts
cd packages/nopy && pnpm pack --dry-run
```
Check that no `workspace:` range leaks into a published manifest — the same
check CI runs:
```sh
node scripts/verify-pack.mjs
node scripts/publish-order.mjs # the order to release in
node scripts/linked-deps.mjs packages/nopy # what must be on the registry first
```
See what is on each registry, and which versions Gitea has that npmjs does not:
```sh
pnpm run registry:status
```
Rehearse an install against a registry that has actually been published to —
see [Testing a snapshot](#testing-a-snapshot-before-you-release):
```sh
pnpm run try:snapshot
```
Rehearse an install the way a stranger gets one, without publishing anything.
Use **npm**, not pnpm: npm is the one that rejects a leaked `workspace:` range,
so a clean install here is the real proof.
```sh
pnpm --filter @bitsquare/nopy-cube pack --pack-destination /tmp/tgz
pnpm --filter @bitsquare/nopy pack --pack-destination /tmp/tgz
pnpm --filter @bitsquare/cubes-core pack --pack-destination /tmp/tgz
mkdir /tmp/try && cd /tmp/try && npm init -y
npm install /tmp/tgz/*.tgz
echo '{"hosts":["h"],"cubePackages":["@bitsquare/cubes-core"]}' > .nopyrc.json
./node_modules/.bin/nopy install -l session.json -P -D
```
Try the binary as an end user would get it, without publishing:
@@ -360,17 +677,28 @@ Try the binary as an end user would get it, without publishing:
```sh
cd packages/nopy && pnpm run link:local # build + npm link
nopy --help
npm unlink -g @bitstack/nopy
npm unlink -g @bitsquare/nopy
```
Check that a version is not already taken before you tag:
Check that a version is not already taken before you tag. The repo's `.npmrc`
points the scope at Gitea, so the bare lookup answers for Gitea and npmjs is the
one that needs the explicit flag:
```sh
npm view @bitstack/nopy@1.2.0 version # npmjs
npm view @bitstack/nopy@1.2.0 version \
--registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
npm view @bitsquare/nopy@1.2.0 version # Gitea
npm view @bitsquare/nopy@1.2.0 version --registry https://registry.npmjs.org/ # npmjs
```
Or both registries, every package, in one table:
```sh
pnpm run registry:status
```
> `npm view <name>@<version>` exits 1 for a version that does not exist, so it is
> a sound check. `npm view <name>` — no version — is **not**: against a registry
> with no `latest` tag it prints nothing and exits 0.
## Troubleshooting
| Symptom | Cause and fix |
@@ -384,6 +712,9 @@ npm view @bitstack/nopy@1.2.0 version \
| `npm pack --dry-run` step fails | A `files` or `bin` path no longer exists after the build. Fix before it reaches a registry. |
| Snapshot workflow green, nothing installable | Snapshots are only on Gitea and only under `@main`. Point the scope at the Gitea registry. |
| The release workflow did not trigger | The tag must match `*-v*` and must be pushed (`git push origin <tag>`), not just created. |
| `EUNSUPPORTEDPROTOCOL` / `Unsupported URL Type "workspace:"` on install | A `workspace:` range reached a tarball — something published with `npm publish` instead of `pnpm publish`. `verify-pack.mjs` exists to catch this before it ships. |
| `... is not published yet on npmjs` before the gate runs | Releasing a package before its `nopy-cube` dependency. Tag and release `nopy-cube` first, then re-tag. |
| `verify-pack.mjs` fails locally with a build error | `pnpm pack` runs `prepack`, so a broken build fails the check. Fix the build; there is no skip flag. |
## Recovering from a bad publish
@@ -391,14 +722,14 @@ npm view @bitstack/nopy@1.2.0 version \
Meanwhile:
```sh
npm dist-tag add @bitstack/nopy@1.1.9 latest # point users back
npm deprecate @bitstack/nopy@1.2.0 "Broken build, use 1.2.1"
npm dist-tag add @bitsquare/nopy@1.1.9 latest # point users back
npm deprecate @bitsquare/nopy@1.2.0 "Broken build, use 1.2.1"
```
`npm unpublish` is only possible within 72 hours and burns the version number
forever; a deprecation with a working `latest` is almost always the better move.
**On Gitea**, delete the version under **Packages → @bitstack/… → Settings**
**On Gitea**, delete the version under **Packages → @bitsquare/… → Settings**
before that exact version can be published again.
**A bad tag** can be moved, but only before the release workflow has published
+4 -4
View File
@@ -5,12 +5,12 @@ they deploy.
| Path | Package | Binary | What it is |
| ----------------- | ------------------ | -------- | --------------------------------------------------- |
| `packages/nopy` | `@bitstack/nopy` | `nopy` | interactive pyinfra script management and execution |
| `packages/keyman` | `@bitstack/keyman` | `keyman` | SSH key management with `age` encryption |
| `packages/nopy` | `@bitsquare/nopy` | `nopy` | interactive pyinfra script management and execution |
| `packages/keyman` | `@bitsquare/keyman` | `keyman` | SSH key management with `age` encryption |
| `cubes/` | — | — | the deployment units `nopy` runs |
```sh
npm install -g @bitstack/nopy @bitstack/keyman
npm install -g @bitsquare/nopy @bitsquare/keyman
```
See each package's README for usage, and
@@ -37,7 +37,7 @@ pnpm install
`typescript` is on the 7.x native compiler, so `tsc` *is* the fast one — there is
no separate `tsgo` binary to keep in sync. Each package also has a dev-run script
(`pnpm --filter @bitstack/nopy run nopy`) that executes the TypeScript sources
(`pnpm --filter @bitsquare/nopy run nopy`) that executes the TypeScript sources
directly through `tsx`.
## Git hooks
-25
View File
@@ -1,25 +0,0 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'user:add',
name: 'Add a user with fish shell and tools',
dependencies: () => ['apt:essentials'],
schema: z.object({
USER: z
.string()
.describe('Username for the new user account')
.default(() => `user${cubes.uniqid(5)}`),
PASSWORD: z.string().describe('Password for the new user account').default(cubes.uniqid),
GROUPS: z
.string()
.describe('Comma-separated list of additional groups (e.g., "docker,sudo")')
.default(''),
PUBKEY: z
.string()
.describe('SSH public key to authorize for the user')
.default(
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan'
),
}),
});
+4 -1
View File
@@ -13,7 +13,9 @@
"test": "pnpm -r run test",
"test:coverage": "pnpm -r run test:coverage",
"coverage:summary": "node scripts/coverage-summary.mjs",
"typecheck": "tsc --build --noEmit",
"registry:status": "node scripts/registry-status.mjs",
"try:snapshot": "node scripts/try-snapshot.mjs",
"typecheck": "tsc --build",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"lint:ci": "biome ci .",
@@ -26,6 +28,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@bitsquare/cubes-core": "workspace:*",
"@logtape/logtape": "^2.2.4",
"@types/node": "^26.1.1",
"simple-git-hooks": "^2.13.1",
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bitsquare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+59
View File
@@ -0,0 +1,59 @@
# @bitsquare/cubes-core
The core cube bundle for [nopy](https://www.npmjs.com/package/@bitsquare/nopy):
base packages, users, SSH, firewalling, networking, web serving and runtimes.
## Install
```sh
pnpm add -D @bitsquare/cubes-core
```
Then name it in `.nopyrc.json`:
```json
{
"hosts": ["web-1"],
"cubePackages": ["@bitsquare/cubes-core"]
}
```
`nopy` resolves the package from the directory of the config file that named it
and scans its `cubes/` directory exactly as it scans a `cubeDirs` entry. Nothing
has to be linked or copied.
## What is in it
| Area | Cube ids |
| ---------- | ------------------------------------------------------------------- |
| admin | `admin:cockpit`, `admin:hostname`, `admin:locale` |
| packages | `apt:essentials`, `apt:install` |
| hardening | `armor:fail2ban`, `armor:ssh`, `armor:ufw` |
| web | `caddy`, `caddy:spa` |
| source | `git:clone` |
| networking | `net:tailscale`, `net:wifi:access-point`, `net:wifi:connection` |
| runtimes | `runtime:docker`, `runtime:nodevm` |
| services | `service:autostart` |
| ssh | `ssh:authorize`, `ssh:keygen`, `ssh:keyman` |
| users | `user:add`, `user:edit` |
Run `nopy` and pick from the list, or `nopy -P` to print the pyinfra commands
without executing them. Each cube directory has its own `README.md`.
## Cube ids are global
An id such as `apt:essentials` is claimed repo-wide, not per bundle: two cubes
with the same id — whichever sources they came from — abort the run with an
error naming both. Prefix your own cubes distinctly if you also point
`cubeDirs` at a local tree.
## The bundle is read-only
Under pnpm the installed files are hardlinked into the global store, so a cube
that writes next to its own `deploy.py` corrupts that store for every project on
the machine. Cubes here write to `/tmp` or to the remote host, never to their
own directory.
## License
MIT
@@ -1,6 +1,6 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
export default cubes.Manifest({
export default Manifest({
id: 'admin:cockpit',
name: 'Install cockpit and utils',
dependencies: () => [],
@@ -1,11 +1,11 @@
import { cubes, uniqid } from '@bitstack/nopy';
import { Manifest, uniqid } from '@bitsquare/nopy-cube';
import { z } from 'zod';
/**
* Manifest for the admin:hostname cube.
* This cube allows for setting and persistently changing the system's hostname.
*/
export default cubes.Manifest({
export default Manifest({
id: 'admin:hostname',
name: 'Permanently change the hostname',
dependencies: () => [],
@@ -12,9 +12,9 @@ Configures system keyboard layout permanently by updating `/etc/default/keyboard
## Usage
```javascript
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
export default cubes.Manifest({
export default Manifest({
name: 'My Host Setup',
dependencies: () => [
['admin:locale', { LAYOUT: 'de' }]
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'admin:locale',
name: 'Configure system locale and keyboard layout',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'apt:essentials',
name: 'Install essential packages',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'apt:install',
name: 'Install packages with apt',
dependencies: () => [],
@@ -1,6 +1,6 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
export default cubes.Manifest({
export default Manifest({
id: 'armor:fail2ban',
name: 'Install and enable fail2ban',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'armor:ssh',
name: 'Secure SSH server by disabling password authentication',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'armor:ufw',
name: 'Activate ufw (uncomplicated firewall)',
dependencies: () => ['apt:essentials'],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'caddy',
name: 'Install Caddy webserver',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'caddy:spa',
name: 'Install single page application',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'git:clone',
name: 'Clone a repository',
dependencies: () => [],
@@ -13,7 +13,7 @@ Installs and authenticates the Tailscale client on a Linux host.
| Variable | Default | Description |
|----------|---------|-------------|
| `AUTH_KEY` | `""` | Tailscale Auth Key (recommended to use a 'reusable' or 'ephemeral' key). |
| `AUTH_KEY` | `""` | **Secret.** Tailscale Auth Key (recommended to use a 'reusable' or 'ephemeral' key). |
| `LOGIN_SERVER` | `https://controlplane.tailscale.com` | The coordination server URL. Set this to your Headscale instance URL if applicable. |
| `EXTRA_ARGS` | `""` | Additional flags to pass to `tailscale up` (e.g., `--advertise-exit-node`). |
| `FORCE_REAUTH` | `false` | If true, forces the client to re-authenticate. |
@@ -25,3 +25,9 @@ nopy install tailscale
```
When prompted, provide your `AUTH_KEY`. If you are using Headscale, also provide the `LOGIN_SERVER` URL.
`AUTH_KEY` is declared in the manifest's `secrets`, so nopy keeps it out of session
and history files and masks it in any command it prints. It is asked for again on
replay, and a `--use-defaults` replay refuses rather than joining the tailnet with
an empty key. Prefer an ephemeral key regardless — the value is still on pyinfra's
command line while the deployment runs.
@@ -1,10 +1,11 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'net:tailscale',
name: 'Install and authenticate Tailscale',
dependencies: () => ['apt:essentials'],
secrets: ['AUTH_KEY'],
schema: z.object({
AUTH_KEY: z.string().describe('Tailscale Auth Key for headless authentication').default(''),
LOGIN_SERVER: z
@@ -19,10 +19,19 @@ Configures a Linux device as a WiFi Access Point using NetworkManager's `nmcli`
## Configuration Parameters
### Required
> **This section is out of date** — it lists parameters the manifest does not
> declare (`NETWORK_DEVICE`, `CHANNEL`, `IP_ADDRESS`) and omits `AP_IP`. Read
> `manifest.mjs` for the real list. Tracked as §5 in the repository's
> `DOCS-AUDIT.md`.
- **SSID**: WiFi network name (1-32 characters)
- **PASSWORD**: WPA2 password (8-63 characters)
### Prompted first
- **SSID**: WiFi network name (1-32 characters). Defaults to `PiPoint`.
- **PASSWORD**: WPA2 password (8-63 characters). Defaults to `1223334444` — a
placeholder that should not survive contact with a real network.
Declared in the manifest's `secrets`, so nopy keeps it out of session and
history files and masks it in printed commands, and re-prompts on replay. The
value is still on pyinfra's command line, so it is visible in `ps` during the run.
### Optional (with defaults)
@@ -1,10 +1,11 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'net:wifi:access-point',
name: 'Configure WiFi Access Point (NetworkManager)',
dependencies: () => [],
secrets: ['PASSWORD'],
schema: z.object({
SSID: z.string().min(1).max(32).default('PiPoint').describe('WiFi network name (SSID)'),
PASSWORD: z
@@ -42,6 +42,11 @@ nopy install network:wifi:connection --env SSID="OfficeWiFi" --env PASSWORD="pas
## Security Notes
- `PASSWORD` is declared in the manifest's `secrets`: nopy keeps it out of session
and history files and masks it in every command it prints. It is prompted for
again on replay.
- That covers what nopy writes, not everything. The value is still on pyinfra's
command line, so it is visible in `ps` while the deployment runs.
- WiFi passwords will be stored in `/etc/NetworkManager/system-connections/` on the target host.
- Passing passwords via `--env` may leave them in your local shell history.
@@ -1,4 +1,4 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
// [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
@@ -7,9 +7,10 @@ import { z } from 'zod';
* Manifest for the network:wifi:connection cube.
* Configures a WiFi client connection using NetworkManager (nmcli).
*/
export default cubes.Manifest({
export default Manifest({
id: 'net:wifi:connection',
name: 'network:wifi:connection - Connect to a WiFi network',
secrets: ['PASSWORD'],
schema: z.object({
SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'),
PASSWORD: z.string().min(8).describe('The password for the WiFi network'),
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'runtime:docker',
name: 'Install docker and tools',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'runtime:nodevm',
name: 'Install nvm and nodejs with global packages',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'service:autostart',
name: 'Manage systemd service autostart',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'ssh:authorize',
name: 'Authorize SSH public key for a user',
dependencies: () => [],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'ssh:keygen',
name: 'Generate SSH key for a given $USER',
dependencies: () => ['user:add'],
@@ -1,7 +1,7 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default cubes.Manifest({
export default Manifest({
id: 'ssh:keyman',
name: 'Deploy an ssh key managed by keyman',
dependencies: () => [],
@@ -36,9 +36,15 @@ This cube creates a new user account with a modern shell environment (Fish), SSH
- Username for the new user account
- Default: `userXXXXX` (randomly generated 5-character suffix)
- **PASSWORD** (string, auto-generated)
- **PASSWORD** (string, **secret**)
- Password for the new user account
- Default: randomly generated secure password
- Default: the literal `changeme` — a placeholder, not a credential. Change it
on first login, or pass a real one.
- Declared in the manifest's `secrets`, so it is never written to a session or
history file and is masked in printed commands. A replay asks for it again.
- It used to default to a randomly generated password. That was removed: since
the value is not recorded, an unattended run created an account with a
credential nobody had seen, and replaying that run produced a different one.
- **GROUPS** (string, default: `''`)
- Comma-separated list of additional groups (e.g., `"docker,sudo"`)
@@ -47,9 +53,16 @@ This cube creates a new user account with a modern shell environment (Fish), SSH
- `sudo` - Administrative privileges
- `www-data` - Web server file access
- **PUBKEY** (string, has default)
- **PUBKEY** (string, **required** — no default)
- SSH public key to authorize for the user
- Should be your public key for passwordless SSH access
- There is deliberately no default. It used to be a specific personal key, so
accepting the default authorized *someone else's* key on the new account.
No key would be a sensible guess, so the cube asks instead.
- Because it is required, `--use-defaults` refuses to run this cube unless
`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
@@ -7,7 +7,10 @@ USER = host.data.USER
HOME_DIR = f"/home/{USER}"
TMP_DIR = f"{HOME_DIR}/tmp"
PASSWORD = host.data.PASSWORD
# An empty submission at the prompt must not become an empty authorized_keys
# line, so an absent key means no key rather than a blank one.
PUBKEY = host.data.PUBKEY
PUBKEYS = [PUBKEY] if PUBKEY and str(PUBKEY).strip() else []
GROUPS = list(filter(None, map(str.strip, str(host.data.GROUPS).split())))
FISH_PATH = "/usr/bin/fish"
FISH_CONFIG_DIR = f"{HOME_DIR}/.config/fish"
@@ -31,7 +34,7 @@ server.user(
create_home=True,
groups=GROUPS,
shell=FISH_PATH,
public_keys=[PUBKEY],
public_keys=PUBKEYS,
_sudo=True
)
@@ -0,0 +1,28 @@
import { Manifest, uniqid } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default Manifest({
id: 'user:add',
name: 'Add a user with fish shell and tools',
dependencies: () => ['apt:essentials'],
secrets: ['PASSWORD'],
schema: z.object({
USER: z
.string()
.describe('Username for the new user account')
.default(() => `user${uniqid(5)}`),
// A fixed placeholder, not a generated one: the password is never recorded
// in a session, so a generated default meant every run produced credentials
// nobody had seen and a replay produced different ones again.
PASSWORD: z.string().describe('Password for the new user account').default('changeme'),
GROUPS: z
.string()
.describe('Comma-separated list of additional groups (e.g., "docker,sudo")')
.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'),
}),
});
@@ -18,7 +18,7 @@ This cube allows you to update existing user accounts on the target system. It c
| Variable | Type | Description | Required |
| :--- | :--- | :--- | :--- |
| `USER` | `string` | The username of the account to modify | Yes |
| `PASSWORD` | `string` | New password for the user | No |
| `PASSWORD` | `string` | New password for the user. **Secret**: never recorded in a session or history file, masked in printed commands, re-prompted on replay. | No |
| `GROUPS` | `string` | Comma-separated list of groups to ADD (e.g., `docker,sudo`) | No |
| `GROUPS_ABSENT` | `string` | Comma-separated list of groups to REMOVE | No |
@@ -1,4 +1,4 @@
import { cubes } from '@bitstack/nopy';
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
// [agnt://cogen/cogen/user-edit-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
@@ -7,10 +7,11 @@ import { z } from 'zod';
* Manifest for the user:edit cube.
* Allows modifying existing user accounts (password, groups).
*/
export default cubes.Manifest({
export default Manifest({
id: 'user:edit',
name: 'user:edit - Modify an existing user account',
dependencies: () => [],
secrets: ['PASSWORD'],
schema: z.object({
USER: z.string().describe('The username of the account to modify'),
PASSWORD: z.string().optional().describe('New password for the user (optional)'),
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@bitsquare/cubes-core",
"version": "0.5.0",
"description": "The core nopy cube bundle: apt, users, ssh, networking, services and runtimes.",
"keywords": [
"nopy",
"nopy-cubes",
"pyinfra",
"deployment",
"infrastructure"
],
"license": "MIT",
"author": "bitsquare",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
"directory": "packages/cubes-core"
},
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/cubes-core",
"bugs": {
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
},
"engines": {
"node": ">=22"
},
"nopy": {
"cubes": [
"./cubes"
]
},
"files": [
"cubes",
"!cubes/**/*.log",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
"@bitsquare/nopy-cube": "workspace:*",
"zod": "^4.4.3"
}
}
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@bitstack/keyman",
"version": "1.0.0",
"name": "@bitsquare/keyman",
"version": "0.5.0",
"description": "A system to simplify ssh key management",
"keywords": [
"ssh",
@@ -55,10 +55,12 @@
"dependencies": {
"execa": "^10.0.0",
"inquirer": "^14.0.2",
"semver": "^7.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/semver": "^7.7.1",
"@vitest/coverage-v8": "^4.1.10",
"tsx": "^4.23.1",
"typescript": "^7.0.2",
+28
View File
@@ -1,3 +1,31 @@
#!/usr/bin/env node
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
export * from './keyman.main.js';
export type {
Channel,
CommandRunner,
PackageManager,
SelfUpdateResult,
UpdateCache,
UpdateStatus,
} from './keyman.update.js';
export {
buildSelfUpdateCommand,
channelForVersion,
checkForUpdate,
DEFAULT_CHECK_INTERVAL_MS,
detectPackageManager,
fetchChannelVersion,
formatCommand,
formatUpdateNotice,
getUpdateCachePath,
isUpdateCheckDisabled,
NPMJS_REGISTRY,
normalizeRegistry,
PACKAGE_NAME,
readUpdateCache,
resolveRegistry,
selfUpdate,
updateNotice,
writeUpdateCache,
} from './keyman.update.js';
+67
View File
@@ -1,10 +1,31 @@
#!/usr/bin/env node
import { createRequire } from 'node:module';
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { keyman } from './keyman.main.js';
import type { Channel } from './keyman.update.js';
import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js';
const { version, buildInfo } = createRequire(import.meta.url)('../package.json') as {
version: string;
buildInfo?: { commit?: string };
};
/**
* What `--version` prints. `version` itself stays untouched everywhere else —
* the commit is an annotation, stamped into `package.json` on the runner by the
* publish workflows and absent when running from source.
*/
const versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
const args = process.argv.slice(2);
/** Reads `--flag value` out of argv, or undefined when the flag is absent */
function flagValue(name: string): string | undefined {
const index = args.indexOf(name);
return index === -1 ? undefined : args[index + 1];
}
if (args.includes('--print-config')) {
const config = loadConfig();
const paths = resolveConfigPaths(config);
@@ -12,4 +33,50 @@ if (args.includes('--print-config')) {
process.exit(0);
}
if (args.includes('--version') || args.includes('-V')) {
console.log(versionLabel);
process.exit(0);
}
if (args[0] === 'self-update' || args[0] === 'upgrade' || args.includes('--self-update')) {
const dryRun = args.includes('--dry-run') || args.includes('-n');
try {
const result = await selfUpdate({
currentVersion: version,
channel: flagValue('--channel') as Channel | undefined,
registry: flagValue('--registry'),
dryRun,
force: args.includes('--force') || args.includes('-f'),
});
const { status } = result;
console.log(`Installed: ${status.current}`);
console.log(`Channel: ${status.channel}`);
console.log(`Registry: ${status.registry}`);
console.log(`Available: ${status.latest ?? 'unknown'}`);
console.log('');
if (result.ran) {
console.log(`Updated to ${status.latest}.`);
} else if (dryRun) {
console.log(`Would run: ${formatCommand(result.command)}`);
} else if (status.latest === null) {
console.error(`Could not reach ${status.registry} — nothing was changed.`);
process.exit(1);
} else {
console.log('Already up to date.');
}
} catch (error) {
console.error('Update failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
process.exit(0);
}
// Printed to stderr so it never mixes into machine-read output.
const notice = await updateNotice({ currentVersion: version });
if (notice) {
console.error(`\n${notice}\n`);
}
keyman();
+472
View File
@@ -0,0 +1,472 @@
/**
* Update checking and self-update for the keyman CLI
*
* A near-copy of nopy's `nopy.update` module, differing only in the package it
* names and the environment variables it reads. The two CLIs share no internal
* library — keyman deliberately stands alone — and a fifth workspace package
* for ~250 lines would buy another edge in the publish order for nothing. If a
* third CLI ever appears, extract it then.
*
* @module keyman.update
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execa } from 'execa';
import semver from 'semver';
/** The published package this CLI ships as */
export const PACKAGE_NAME = '@bitsquare/keyman';
/** The npm scope the package lives under, used for the registry config key */
export const SCOPE = '@bitsquare';
/** Where packages resolve from when nothing says otherwise */
export const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
/** Directory under the user's home holding the update-check cache */
export const UPDATE_CACHE_DIR = '.keyman';
/** File name of the update-check cache */
export const UPDATE_CACHE_FILE = 'update-check.json';
/** How long a cached check is considered fresh */
export const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** How long the background check may block the CLI */
export const DEFAULT_FETCH_TIMEOUT_MS = 1500;
/** How long `npm config get` may take before the registry falls back to npmjs */
export const DEFAULT_CONFIG_TIMEOUT_MS = 5000;
/**
* A dist-tag this project publishes under.
*
* `latest` is a release, `next` a prerelease (`0.6.0-rc.1`), `main` a snapshot
* built from a commit on `main` and published to Gitea only.
*/
export type Channel = 'latest' | 'next' | 'main';
/** A package manager that can install a global binary */
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
/** Runs a command and resolves with its trimmed stdout */
export type CommandRunner = (file: string, args: string[]) => Promise<string>;
/** The result of an update check */
export interface UpdateStatus {
/** The version currently running */
current: string;
/** The version the channel points at, or null if it could not be determined */
latest: string | null;
/** The channel the current version implies */
channel: Channel;
/** The registry the check went to */
registry: string;
/** Whether `latest` is strictly newer than `current` */
updateAvailable: boolean;
/** Whether the answer came from cache rather than the network */
fromCache: boolean;
}
/** The on-disk update-check cache */
export interface UpdateCache {
/** ISO timestamp of the check */
checkedAt: string;
/** The channel that was checked */
channel: Channel;
/** The registry that was checked */
registry: string;
/** The version the channel pointed at, or null if the lookup found nothing */
latest: string | null;
}
/**
* Derives the release channel from a version string.
*
* @param version - a semver version, typically this package's own
* @returns the dist-tag that version would have been published under
*/
export function channelForVersion(version: string): Channel {
const parsed = semver.parse(version, { loose: true });
if (!parsed || parsed.prerelease.length === 0) {
return 'latest';
}
return parsed.prerelease.some((part) => part === 'main') ? 'main' : 'next';
}
/** Normalises a registry URL to the trailing-slash form the packument path is appended to */
export function normalizeRegistry(url: string): string {
const trimmed = url.trim();
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
}
/** Runs a command through execa and returns its stdout */
const defaultRunner: CommandRunner = async (file, args) => {
const { stdout } = await execa(file, args, { timeout: DEFAULT_CONFIG_TIMEOUT_MS });
return stdout;
};
/**
* Resolves the registry `@bitsquare` packages come from.
*
* `KEYMAN_REGISTRY` wins, then npm's own scoped-registry config, then npmjs.
*/
export async function resolveRegistry(
options: { env?: NodeJS.ProcessEnv; run?: CommandRunner } = {}
): Promise<string> {
const env = options.env ?? process.env;
const override = env.KEYMAN_REGISTRY?.trim();
if (override) {
return normalizeRegistry(override);
}
const run = options.run ?? defaultRunner;
try {
const stdout = (await run('npm', ['config', 'get', `${SCOPE}:registry`])).trim();
// npm prints the string "undefined" for an unset key rather than nothing.
if (stdout && stdout !== 'undefined' && stdout !== 'null') {
return normalizeRegistry(stdout);
}
} catch {
// npm not on PATH, or the config is unreadable.
}
return NPMJS_REGISTRY;
}
/**
* Reads the version a dist-tag points at, straight from the registry.
*
* @returns the version, or null if the registry or the tag has nothing
*/
export async function fetchChannelVersion(options: {
registry: string;
channel: Channel;
packageName?: string;
timeoutMs?: number;
token?: string;
fetchImpl?: typeof fetch;
}): Promise<string | null> {
const doFetch = options.fetchImpl ?? globalThis.fetch;
const packageName = options.packageName ?? PACKAGE_NAME;
const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`;
const headers: Record<string, string> = {
accept: 'application/vnd.npm.install-v1+json, application/json',
};
if (options.token) {
headers.authorization = `Bearer ${options.token}`;
}
const response = await doFetch(url, {
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as { 'dist-tags'?: Record<string, string> };
return body['dist-tags']?.[options.channel] ?? null;
}
/** Path of the update-check cache file */
export function getUpdateCachePath(homedir: string = os.homedir()): string {
return path.join(homedir, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE);
}
/**
* Reads the update-check cache.
*
* @returns the cache, or null if it is missing or unreadable
*/
export function readUpdateCache(cachePath: string = getUpdateCachePath()): UpdateCache | null {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as UpdateCache;
return typeof parsed?.checkedAt === 'string' ? parsed : null;
} catch {
return null;
}
}
/** Writes the update-check cache. Best effort — a read-only home costs a check, not a failure */
export function writeUpdateCache(
cache: UpdateCache,
cachePath: string = getUpdateCachePath()
): void {
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
} catch {
// Ignored on purpose.
}
}
/** Whether the startup check should be skipped entirely */
export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
const flag = env.KEYMAN_NO_UPDATE_CHECK?.trim().toLowerCase();
if (flag && flag !== '0' && flag !== 'false') {
return true;
}
return Boolean(env.CI);
}
/**
* Checks whether a newer version exists on the current channel.
*
* Answers from cache when a check happened recently for the same channel and
* registry; a failed lookup degrades to the cached answer rather than none.
*/
export async function checkForUpdate(options: {
currentVersion: string;
channel?: Channel;
registry?: string;
force?: boolean;
intervalMs?: number;
cachePath?: string;
now?: number;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
fetchImpl?: typeof fetch;
run?: CommandRunner;
}): Promise<UpdateStatus> {
const {
currentVersion,
force = false,
intervalMs = DEFAULT_CHECK_INTERVAL_MS,
cachePath = getUpdateCachePath(),
now = Date.now(),
env = process.env,
} = options;
const channel = options.channel ?? channelForVersion(currentVersion);
const registry = normalizeRegistry(
options.registry ?? (await resolveRegistry({ env, run: options.run }))
);
const cache = readUpdateCache(cachePath);
const applicable = cache && cache.channel === channel && cache.registry === registry;
const age = cache ? now - Date.parse(cache.checkedAt) : Number.POSITIVE_INFINITY;
const fresh = applicable && Number.isFinite(age) && age >= 0 && age < intervalMs;
if (!force && fresh && cache) {
return status(currentVersion, cache.latest, channel, registry, true);
}
try {
const latest = await fetchChannelVersion({
registry,
channel,
timeoutMs: options.timeoutMs,
token: env.KEYMAN_REGISTRY_TOKEN?.trim() || undefined,
fetchImpl: options.fetchImpl,
});
writeUpdateCache(
{ checkedAt: new Date(now).toISOString(), channel, registry, latest },
cachePath
);
return status(currentVersion, latest, channel, registry, false);
} catch {
return status(
currentVersion,
applicable && cache ? cache.latest : null,
channel,
registry,
true
);
}
}
/** Assembles an {@link UpdateStatus}, deciding whether the remote version wins */
function status(
current: string,
latest: string | null,
channel: Channel,
registry: string,
fromCache: boolean
): UpdateStatus {
const updateAvailable = Boolean(
latest && semver.valid(latest) && semver.valid(current) && semver.gt(latest, current)
);
return { current, latest, channel, registry, updateAvailable, fromCache };
}
/**
* Detects which package manager installed this CLI, so `self-update` re-runs
* the same one rather than leaving two copies on the PATH.
*/
export function detectPackageManager(
options: { execPath?: string; env?: NodeJS.ProcessEnv } = {}
): PackageManager {
const env = options.env ?? process.env;
const override = env.KEYMAN_PACKAGE_MANAGER?.trim().toLowerCase();
if (override === 'npm' || override === 'pnpm' || override === 'yarn' || override === 'bun') {
return override;
}
const from = (options.execPath ?? process.argv[1] ?? '').replace(/\\/g, '/').toLowerCase();
if (from.includes('/pnpm/')) return 'pnpm';
if (from.includes('/.bun/')) return 'bun';
if (from.includes('/.yarn/') || from.includes('/yarn/')) return 'yarn';
return 'npm';
}
/**
* Builds the command that installs a given channel globally.
*
* The registry is passed as a **scoped** override rather than `--registry`,
* because the Gitea registry serves `@bitsquare` packages and does not proxy
* npmjs — a global `--registry` would send every dependency to a registry that
* has never heard of them.
*/
export function buildSelfUpdateCommand(options: {
packageManager: PackageManager;
channel: Channel;
registry: string;
packageName?: string;
}): { file: string; args: string[] } {
const packageName = options.packageName ?? PACKAGE_NAME;
const spec = `${packageName}@${options.channel}`;
const registryArgs =
normalizeRegistry(options.registry) === NPMJS_REGISTRY
? []
: [`--${SCOPE}:registry=${normalizeRegistry(options.registry)}`];
switch (options.packageManager) {
case 'pnpm':
return { file: 'pnpm', args: ['add', '--global', spec, ...registryArgs] };
case 'yarn':
return { file: 'yarn', args: ['global', 'add', spec, ...registryArgs] };
case 'bun':
return { file: 'bun', args: ['add', '--global', spec, ...registryArgs] };
default:
return { file: 'npm', args: ['install', '--global', spec, ...registryArgs] };
}
}
/** Renders a command as the shell line a user could paste */
export function formatCommand(command: { file: string; args: string[] }): string {
return [command.file, ...command.args].join(' ');
}
/**
* Renders the hint printed at startup when an update exists.
*
* @returns the notice, or null when there is nothing to say
*/
export function formatUpdateNotice(
status: UpdateStatus,
packageManager?: PackageManager
): string | null {
if (!status.updateAvailable || !status.latest) {
return null;
}
const command = buildSelfUpdateCommand({
packageManager: packageManager ?? detectPackageManager(),
channel: status.channel,
registry: status.registry,
});
const channelNote = status.channel === 'latest' ? '' : ` (${status.channel})`;
return [
`Update available: ${status.current} -> ${status.latest}${channelNote}`,
`Run "keyman self-update" or "${formatCommand(command)}"`,
].join('\n');
}
/**
* The startup path: returns the notice to print, or null.
*
* Never throws and never blocks for longer than the fetch timeout.
*/
export async function updateNotice(options: {
currentVersion: string;
env?: NodeJS.ProcessEnv;
cachePath?: string;
intervalMs?: number;
timeoutMs?: number;
now?: number;
fetchImpl?: typeof fetch;
run?: CommandRunner;
}): Promise<string | null> {
const env = options.env ?? process.env;
if (isUpdateCheckDisabled(env)) {
return null;
}
try {
const status = await checkForUpdate({ ...options, env });
return formatUpdateNotice(status, detectPackageManager({ env }));
} catch {
return null;
}
}
/** Outcome of a {@link selfUpdate} run */
export interface SelfUpdateResult {
/** The status the decision was based on */
status: UpdateStatus;
/** The command that was run, or would have been run */
command: { file: string; args: string[] };
/** Whether the install actually ran */
ran: boolean;
}
/**
* Installs the newest version on the current channel.
*
* @param options.dryRun - print the command instead of running it
* @param options.force - reinstall even when already up to date
*/
export async function selfUpdate(options: {
currentVersion: string;
channel?: Channel;
registry?: string;
packageManager?: PackageManager;
dryRun?: boolean;
force?: boolean;
env?: NodeJS.ProcessEnv;
cachePath?: string;
fetchImpl?: typeof fetch;
run?: CommandRunner;
spawn?: (file: string, args: string[]) => Promise<unknown>;
}): Promise<SelfUpdateResult> {
const env = options.env ?? process.env;
// Always ignore the cache here: the user asked, so the answer has to be current.
const status = await checkForUpdate({
currentVersion: options.currentVersion,
channel: options.channel,
registry: options.registry,
force: true,
cachePath: options.cachePath,
env,
fetchImpl: options.fetchImpl,
run: options.run,
});
const command = buildSelfUpdateCommand({
packageManager: options.packageManager ?? detectPackageManager({ env }),
channel: status.channel,
registry: status.registry,
});
if (options.dryRun || (!status.updateAvailable && !options.force)) {
return { status, command, ran: false };
}
const spawn =
options.spawn ?? ((file: string, args: string[]) => execa(file, args, { stdio: 'inherit' }));
await spawn(command.file, command.args);
return { status, command, ran: true };
}
+868
View File
@@ -0,0 +1,868 @@
/**
* Tests for keyman.update module
*
* Every network call, clock read and spawn is injected, so nothing here
* reaches a registry or the user's home directory.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildSelfUpdateCommand,
type Channel,
channelForVersion,
checkForUpdate,
detectPackageManager,
fetchChannelVersion,
formatCommand,
formatUpdateNotice,
getUpdateCachePath,
isUpdateCheckDisabled,
NPMJS_REGISTRY,
normalizeRegistry,
readUpdateCache,
resolveRegistry,
selfUpdate,
type UpdateCache,
updateNotice,
writeUpdateCache,
} from '../src/keyman.update.js';
const GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
let tmpDir: string;
let cachePath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-update-'));
cachePath = path.join(tmpDir, 'update-check.json');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
/** A fetch stand-in returning the given dist-tags */
function fakeFetch(distTags: Record<string, string>, ok = true): typeof fetch {
return (async () =>
({
ok,
json: async () => ({ 'dist-tags': distTags }),
}) as Response) as unknown as typeof fetch;
}
describe('channelForVersion', () => {
it('maps a clean release to latest', () => {
expect(channelForVersion('0.5.0')).toBe('latest');
expect(channelForVersion('1.2.3')).toBe('latest');
});
it('maps a snapshot to main', () => {
expect(channelForVersion('0.5.0-main.14.g6ecb2c3')).toBe('main');
});
it('maps any other prerelease to next', () => {
expect(channelForVersion('0.6.0-rc.1')).toBe('next');
expect(channelForVersion('1.0.0-alpha5')).toBe('next');
});
it('treats an unparseable version as latest', () => {
expect(channelForVersion('not-a-version')).toBe('latest');
expect(channelForVersion('')).toBe('latest');
});
});
describe('normalizeRegistry', () => {
it('adds a trailing slash', () => {
expect(normalizeRegistry('https://example.com/npm')).toBe('https://example.com/npm/');
});
it('leaves an existing trailing slash alone', () => {
expect(normalizeRegistry(GITEA)).toBe(GITEA);
});
it('trims surrounding whitespace', () => {
expect(normalizeRegistry(' https://example.com/npm ')).toBe('https://example.com/npm/');
});
});
describe('resolveRegistry', () => {
it('prefers the KEYMAN_REGISTRY override', async () => {
const run = vi.fn();
const registry = await resolveRegistry({
env: { KEYMAN_REGISTRY: 'https://example.com/npm' },
run,
});
expect(registry).toBe('https://example.com/npm/');
expect(run).not.toHaveBeenCalled();
});
it('falls back to npm config', async () => {
const run = vi.fn(async () => GITEA);
expect(await resolveRegistry({ env: {}, run })).toBe(GITEA);
expect(run).toHaveBeenCalledWith('npm', ['config', 'get', '@bitsquare:registry']);
});
it('treats npm printing "undefined" as unset', async () => {
const run = vi.fn(async () => 'undefined');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('treats npm printing "null" as unset', async () => {
const run = vi.fn(async () => 'null');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('treats empty output as unset', async () => {
const run = vi.fn(async () => ' ');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('falls back to npmjs when npm is missing', async () => {
const run = vi.fn(async () => {
throw new Error('ENOENT');
});
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('ignores a blank override', async () => {
const run = vi.fn(async () => GITEA);
expect(await resolveRegistry({ env: { KEYMAN_REGISTRY: ' ' }, run })).toBe(GITEA);
});
});
describe('fetchChannelVersion', () => {
it('reads the requested dist-tag', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'main',
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234', latest: '0.5.0' }),
});
expect(version).toBe('0.5.0-main.14.gabc1234');
});
it('returns null when the tag does not exist', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'latest',
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234' }),
});
expect(version).toBeNull();
});
it('returns null on a non-ok response', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'latest',
fetchImpl: fakeFetch({}, false),
});
expect(version).toBeNull();
});
it('returns null when the packument has no dist-tags at all', async () => {
const fetchImpl = (async () =>
({ ok: true, json: async () => ({}) }) as Response) as unknown as typeof fetch;
expect(await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl })).toBeNull();
});
it('url-encodes the scoped package name onto the registry', async () => {
const seen: string[] = [];
const fetchImpl = (async (url: string) => {
seen.push(url);
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
// No trailing slash on purpose: it must be normalised before joining.
await fetchChannelVersion({
registry: 'https://example.com/npm',
channel: 'latest',
fetchImpl,
});
expect(seen[0]).toBe('https://example.com/npm/%40bitsquare%2Fkeyman');
});
it('sends a bearer token when one is given', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
await fetchChannelVersion({ registry: GITEA, channel: 'latest', token: 'secret', fetchImpl });
expect(headers.authorization).toBe('Bearer secret');
});
it('omits the authorization header when no token is given', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl });
expect(headers.authorization).toBeUndefined();
});
});
describe('the update cache', () => {
it('round-trips', () => {
const cache: UpdateCache = {
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.6.0',
};
writeUpdateCache(cache, cachePath);
expect(readUpdateCache(cachePath)).toEqual(cache);
});
it('creates the containing directory', () => {
const nested = path.join(tmpDir, 'a', 'b', 'update-check.json');
writeUpdateCache(
{
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: null,
},
nested
);
expect(fs.existsSync(nested)).toBe(true);
});
it('reads a missing file as null', () => {
expect(readUpdateCache(path.join(tmpDir, 'absent.json'))).toBeNull();
});
it('reads malformed JSON as null', () => {
fs.writeFileSync(cachePath, '{ not json', 'utf-8');
expect(readUpdateCache(cachePath)).toBeNull();
});
it('rejects a file without a checkedAt stamp', () => {
fs.writeFileSync(cachePath, JSON.stringify({ latest: '9.9.9' }), 'utf-8');
expect(readUpdateCache(cachePath)).toBeNull();
});
it('swallows a write it cannot perform', () => {
// A path whose parent is a file, not a directory.
const blocked = path.join(cachePath, 'nested.json');
fs.writeFileSync(cachePath, '{}', 'utf-8');
expect(() =>
writeUpdateCache(
{
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: null,
},
blocked
)
).not.toThrow();
});
it('defaults to a path under the home directory', () => {
expect(getUpdateCachePath('/home/someone')).toBe('/home/someone/.keyman/update-check.json');
});
});
describe('isUpdateCheckDisabled', () => {
it('is off by default', () => {
expect(isUpdateCheckDisabled({})).toBe(false);
});
it('honours KEYMAN_NO_UPDATE_CHECK', () => {
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '1' })).toBe(true);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'yes' })).toBe(true);
});
it('treats 0 and false as not disabled', () => {
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '0' })).toBe(false);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'false' })).toBe(false);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '' })).toBe(false);
});
it('disables itself in CI', () => {
expect(isUpdateCheckDisabled({ CI: 'true' })).toBe(true);
});
});
describe('checkForUpdate', () => {
const base = {
currentVersion: '0.5.0',
registry: NPMJS_REGISTRY,
env: {} as NodeJS.ProcessEnv,
now: Date.parse('2026-07-29T12:00:00.000Z'),
};
it('reports a newer version on the channel', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status).toMatchObject({
current: '0.5.0',
latest: '0.6.0',
channel: 'latest',
updateAvailable: true,
fromCache: false,
});
});
it('reports no update when the channel matches', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
});
expect(status.updateAvailable).toBe(false);
});
it('does not treat an older published version as an update', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.4.0' }),
});
expect(status.updateAvailable).toBe(false);
});
it('derives the channel from the running version', async () => {
const status = await checkForUpdate({
...base,
currentVersion: '0.5.0-main.13.gabc1234',
cachePath,
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gdef5678', latest: '0.5.0' }),
});
expect(status.channel).toBe('main');
expect(status.latest).toBe('0.5.0-main.14.gdef5678');
expect(status.updateAvailable).toBe(true);
});
it('writes what it found to the cache', async () => {
await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }) });
expect(readUpdateCache(cachePath)).toEqual({
checkedAt: '2026-07-29T12:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.6.0',
});
});
it('answers from a fresh cache without touching the network', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const fetchImpl = vi.fn();
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(status.latest).toBe('0.7.0');
expect(status.fromCache).toBe(true);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('refetches once the cache goes stale', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-27T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.8.0' }),
});
expect(status.latest).toBe('0.8.0');
expect(status.fromCache).toBe(false);
});
it('ignores a cache written for a different channel', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'next',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache written for a different registry', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: GITEA,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache stamped in the future', async () => {
writeUpdateCache(
{
checkedAt: '2027-01-01T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache with an unparseable stamp', async () => {
fs.writeFileSync(
cachePath,
JSON.stringify({
checkedAt: 'whenever',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
}),
'utf-8'
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('refetches when forced, even with a fresh cache', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
force: true,
fetchImpl: fakeFetch({ latest: '0.9.0' }),
});
expect(status.latest).toBe('0.9.0');
expect(status.fromCache).toBe(false);
});
it('falls back to the cached answer when the network fails', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-20T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const fetchImpl = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
expect(status.latest).toBe('0.7.0');
expect(status.updateAvailable).toBe(true);
expect(status.fromCache).toBe(true);
});
it('reports nothing when the network fails and no cache applies', async () => {
const fetchImpl = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
expect(status.latest).toBeNull();
expect(status.updateAvailable).toBe(false);
});
it('resolves the registry when none is given', async () => {
const status = await checkForUpdate({
currentVersion: '0.5.0',
cachePath,
env: {},
run: async () => GITEA,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.registry).toBe(GITEA);
});
it('passes a registry token from the environment through', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.6.0' } }) } as Response;
}) as unknown as typeof fetch;
await checkForUpdate({
...base,
cachePath,
env: { KEYMAN_REGISTRY_TOKEN: 'tok' },
fetchImpl,
});
expect(headers.authorization).toBe('Bearer tok');
});
it('does not compare against an unparseable current version', async () => {
const status = await checkForUpdate({
...base,
currentVersion: 'dev',
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.updateAvailable).toBe(false);
});
});
describe('detectPackageManager', () => {
it('honours the environment override', () => {
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' })
).toBe('pnpm');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' })
).toBe('yarn');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' })
).toBe('bun');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' })
).toBe('npm');
});
it('ignores an unrecognised override', () => {
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'cargo' }, execPath: '/usr/lib/x' })
).toBe('npm');
});
it('recognises a pnpm global install', () => {
expect(
detectPackageManager({
env: {},
execPath: '/Users/x/Library/pnpm/global/5/node_modules/.bin/keyman',
})
).toBe('pnpm');
});
it('recognises a bun global install', () => {
expect(
detectPackageManager({
env: {},
execPath: '/Users/x/.bun/install/global/node_modules/keyman',
})
).toBe('bun');
});
it('recognises a yarn global install', () => {
expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/keyman' })).toBe('yarn');
});
it('defaults to npm', () => {
expect(
detectPackageManager({
env: {},
execPath: '/usr/local/lib/node_modules/@bitsquare/keyman/dist/keyman.cli.js',
})
).toBe('npm');
});
it('handles a windows-style path and an empty path', () => {
expect(
detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\keyman.exe' })
).toBe('pnpm');
expect(detectPackageManager({ env: {}, execPath: '' })).toBe('npm');
});
});
describe('buildSelfUpdateCommand', () => {
it('builds an npm global install without a registry flag for npmjs', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@latest');
});
it('adds a scoped registry override for a non-npmjs registry', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'main',
registry: GITEA,
});
// Scoped, not `--registry`: Gitea does not proxy npmjs, so the transitive
// dependencies have to keep resolving from npmjs.
expect(formatCommand(command)).toBe(
`npm install --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
);
expect(command.args).not.toContain('--registry');
});
it('normalises a registry given without a trailing slash', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: 'https://registry.npmjs.org',
});
expect(command.args).toEqual(['install', '--global', '@bitsquare/keyman@latest']);
});
it('builds for pnpm, yarn and bun', () => {
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'pnpm',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('pnpm add --global @bitsquare/keyman@next');
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'yarn',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('yarn global add @bitsquare/keyman@next');
expect(
formatCommand(
buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY })
)
).toBe('bun add --global @bitsquare/keyman@next');
});
it('accepts an explicit package name', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
packageName: '@bitsquare/nopy',
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/nopy@latest');
});
});
describe('formatUpdateNotice', () => {
const status = {
current: '0.5.0',
latest: '0.6.0',
channel: 'latest' as Channel,
registry: NPMJS_REGISTRY,
updateAvailable: true,
fromCache: false,
};
it('names both versions and the command', () => {
const notice = formatUpdateNotice(status, 'npm');
expect(notice).toContain('0.5.0 -> 0.6.0');
expect(notice).toContain('keyman self-update');
expect(notice).toContain('npm install --global @bitsquare/keyman@latest');
});
it('names a non-default channel', () => {
expect(formatUpdateNotice({ ...status, channel: 'main' }, 'npm')).toContain('(main)');
});
it('says nothing when there is no update', () => {
expect(formatUpdateNotice({ ...status, updateAvailable: false }, 'npm')).toBeNull();
});
it('says nothing when the latest version is unknown', () => {
expect(formatUpdateNotice({ ...status, latest: null }, 'npm')).toBeNull();
});
it('detects the package manager when none is given', () => {
expect(formatUpdateNotice(status)).toContain('@bitsquare/keyman@latest');
});
});
describe('updateNotice', () => {
it('returns a notice when an update exists', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY },
cachePath,
now: Date.parse('2026-07-29T12:00:00.000Z'),
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(notice).toContain('0.5.0 -> 0.6.0');
});
it('returns null when the check is disabled', async () => {
const fetchImpl = vi.fn();
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { KEYMAN_NO_UPDATE_CHECK: '1' },
cachePath,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(notice).toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('returns null rather than throwing when everything fails', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: {},
cachePath,
run: async () => {
throw new Error('no npm');
},
fetchImpl: (async () => {
throw new Error('offline');
}) as unknown as typeof fetch,
});
expect(notice).toBeNull();
});
});
describe('selfUpdate', () => {
const base = {
currentVersion: '0.5.0',
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY } as NodeJS.ProcessEnv,
packageManager: 'npm' as const,
};
it('runs the install when a newer version exists', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn,
});
expect(result.ran).toBe(true);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--global', '@bitsquare/keyman@latest']);
});
it('does nothing when already up to date', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
spawn,
});
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
it('reinstalls when forced', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
force: true,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
spawn,
});
expect(result.ran).toBe(true);
});
it('reports the command without running it on a dry run', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
dryRun: true,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn,
});
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
expect(formatCommand(result.command)).toBe('npm install --global @bitsquare/keyman@latest');
});
it('ignores a fresh cache, because the user asked', async () => {
writeUpdateCache(
{
checkedAt: new Date().toISOString(),
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.5.0',
},
cachePath
);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn: async () => undefined,
});
expect(result.status.latest).toBe('0.6.0');
expect(result.ran).toBe(true);
});
it('follows an explicit channel and registry', async () => {
const result = await selfUpdate({
currentVersion: '0.5.0',
env: {},
packageManager: 'pnpm',
channel: 'main',
registry: GITEA,
cachePath,
fetchImpl: fakeFetch({ main: '0.5.0-main.20.gaaaaaaa' }),
spawn: async () => undefined,
});
expect(formatCommand(result.command)).toBe(
`pnpm add --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
);
});
it('does not run when the registry could not be reached', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: (async () => {
throw new Error('offline');
}) as unknown as typeof fetch,
spawn,
});
expect(result.status.latest).toBeNull();
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
});
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bitsquare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+69
View File
@@ -0,0 +1,69 @@
# @bitsquare/nopy-cube
The authoring surface for [nopy](https://www.npmjs.com/package/@bitsquare/nopy)
cubes — the `Manifest` factory, the `Cube` class, and the types around them.
A cube manifest ships nothing but data, so it should not have to depend on a CLI
to describe itself. This package is what a **cube bundle** depends on: no
`commander`, no `inquirer`, no `execa`, no process spawning. `@bitsquare/nopy`
re-exports everything here, so a manifest that already imports from
`@bitsquare/nopy` keeps working unchanged.
## Install
```sh
pnpm add @bitsquare/nopy-cube zod
```
`zod` is a **peer dependency** on purpose: the manifest, the schema it builds and
the `Manifest` factory should all see the same copy.
## Writing a manifest
```js
// cubes/net/tailscale/manifest.mjs
import { Manifest } from '@bitsquare/nopy-cube';
import { z } from 'zod';
export default Manifest({
id: 'net:tailscale',
name: 'Tailscale',
schema: z.object({
AUTH_KEY: z.string().describe('Tailscale auth key'),
ACCEPT_ROUTES: z.boolean().describe('Accept advertised routes').default(true),
}),
secrets: ['AUTH_KEY'],
dependencies: (vars) => (vars.ACCEPT_ROUTES ? ['net:ip-forwarding'] : []),
before: [async (ctx, vars) => ctx.exec('apt:essentials', {})],
});
```
Every schema field should carry a `.describe()` — nopy uses it as the prompt
label — and a `.default()` wherever a sensible one exists, so `--use-defaults`
can run the cube without prompting.
`secrets` names the schema keys that hold sensitive values. Nopy keeps those out
of session and history files and masks them in every command it prints; it does
not infer them, so a key nothing declares is recorded and printed in the clear.
Each entry must be a key of `schema` — naming anything else is a manifest error.
Give a secret a placeholder `.default()` rather than a real credential: a default
lives in the manifest, where none of that protection reaches it.
The manifest lives next to a `deploy.py` in the same directory; together they
make a cube. See the
[nopy README](https://www.npmjs.com/package/@bitsquare/nopy) for the full cube
contract and for how to publish a directory of cubes as a bundle.
## Exports
| Export | What it is |
| ----------------------------------- | -------------------------------------------------------------- |
| `Manifest(opts)` | Builds a manifest, filling in `id`, `schema`, `secrets`, `before`, `after` |
| `createManifest` / `manifest` | Aliases of `Manifest` |
| `Cube` | A loaded manifest plus its directory; `getDefaults()`, `requiredKeys()`, `secrets`, `isSecret()` |
| `zodKind` / `zodInner` | Instance-agnostic zod introspection, safe across zod copies |
| `AnyObjectSchema`, `CubeVariables`, `DependencySpec`, `Hook`, `HookContext`, `CubeSource`, `LoadResult` | types |
## License
MIT
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@bitsquare/nopy-cube",
"version": "0.5.0",
"description": "Authoring types for nopy cubes: the Manifest factory and the Cube contract.",
"keywords": [
"nopy",
"pyinfra",
"deployment",
"infrastructure"
],
"license": "MIT",
"author": "bitsquare",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
"directory": "packages/nopy-cube"
},
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/nopy-cube",
"bugs": {
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
},
"engines": {
"node": ">=22"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"clean": "rm -rf dist .tsbuildinfo",
"build": "tsc",
"prepack": "pnpm run build",
"link:local": "pnpm run build && npm link",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest"
},
"peerDependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"typescript": "^7.0.2",
"vitest": "^4.1.10",
"zod": "^4.4.3"
}
}
@@ -1,6 +1,6 @@
/**
* Factory functions for creating cube configurations
* @module cubes/factories
* @module factories
*/
import { type AnyObjectSchema, Manifest } from './types.js';
+31
View File
@@ -0,0 +1,31 @@
/**
* @bitsquare/nopy-cube — the authoring surface for nopy cubes.
*
* Everything a `manifest.mjs` needs and nothing else: no CLI, no prompts, no
* process spawning. `@bitsquare/nopy` re-exports all of it, so a manifest can
* import from either package.
*
* @packageDocumentation
*/
export {
createManifest,
ManifestFactory,
manifest,
} from './factories.js';
export type {
AnyObjectSchema,
CubeSource,
CubeVariables,
DependencySpec,
Hook,
HookContext,
LoadResult,
} from './types.js';
export {
Cube,
Manifest,
zodInner,
zodKind,
} from './types.js';
export { uniqid } from './utils.js';
+224
View File
@@ -0,0 +1,224 @@
/**
* Type definitions for Nopy cubes
* @module types
*/
import { z } from 'zod';
/**
* Any object schema, whatever its shape.
*
* Stands in for zod 3's `z.AnyZodObject`, which zod 4 removed.
*/
export type AnyObjectSchema = z.ZodObject<Record<string, z.ZodType<any>>>;
/**
* Variables that can be passed to a cube
*/
export type CubeVariables = Record<string, string | number | boolean>;
/**
* A dependency specification
*/
export type DependencySpec = string | [id: string, variables?: CubeVariables];
/**
* Context passed to cube hooks for executing other cubes
*/
export interface HookContext {
exec: (key: string, variables: CubeVariables) => Promise<void> | void;
}
/**
* Hook function type for before/after cube execution
*/
export type Hook<Schema extends AnyObjectSchema = AnyObjectSchema> = (
ctx: HookContext,
variables: z.infer<Schema>
) => void | Promise<void>;
/**
* User-defined specification for a cube
*/
export interface Manifest<Schema extends AnyObjectSchema = AnyObjectSchema> {
/** Unique identifier for the cube (used for dependency references) */
id: string;
/** Human-readable name of the cube */
name: string;
/** Zod schema for validating cube variables */
schema: Schema;
/**
* Schema keys holding secrets. Their values are never written to a session
* file, and are masked wherever a command or a variable would be printed.
*
* A plain array rather than schema-level metadata on purpose: `.meta()` and
* `.describe()` both store into zod's global registry, which is per-copy — a
* manifest that builds its schema with its own zod writes the marker into a
* registry this process cannot read. A missed `.describe()` costs an ugly
* prompt label; a missed secret marker writes a password to disk, so this one
* cannot be allowed to fail open. See {@link zodKind} for the same hazard.
*/
secrets?: string[];
/** Dynamic dependency resolver based on collected variables */
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
/** Hooks to run before cube execution */
before?: Hook<Schema>[];
/** Hooks to run after cube execution */
after?: Hook<Schema>[];
}
/**
* Factory function and namespace for Manifest
*/
export function Manifest<Schema extends AnyObjectSchema>(
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> {
return {
id: opts.id ?? '',
name: opts.name,
schema: opts.schema ?? (z.object({}) as unknown as Schema),
secrets: opts.secrets ?? [],
dependencies: opts.dependencies,
before: opts.before ?? [],
after: opts.after ?? [],
};
}
export namespace Manifest {
/**
* Internal create helper
*/
export function create<Schema extends AnyObjectSchema>(
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> {
return Manifest(opts);
}
}
/**
* zod's runtime discriminant for a schema node, as a plain string.
*
* `instanceof z.ZodDefault` compares against the *running* copy of zod. A cube
* manifest is free to build its schema with a different copy — its own
* dependency, or one shipped inside a bundle — and then every `instanceof`
* quietly returns false and the caller falls through to a wrong answer instead
* of failing. `def.type` holds across instances, so nothing here may go back to
* `instanceof`.
*/
export function zodKind(zodType: unknown): string {
return (zodType as { def: { type: string } }).def.type;
}
/**
* The type a wrapper wraps — `.default()`, `.optional()`, `.nullable()`.
* Only call this for a node whose {@link zodKind} is one of those.
*/
export function zodInner(zodType: unknown): z.ZodType {
return (zodType as { def: { innerType: z.ZodType } }).def.innerType;
}
/**
* Reads the `.default()` off a schema field, unwrapping the wrappers that may
* sit above it (`.default().optional()`, `.default().nullable()`).
*
* Returns `undefined` for a field that declares no default — which is also how
* `requiredKeys()` recognises a field the user has to supply.
*/
function defaultValueOf(zodType: z.ZodType): unknown {
const kind = zodKind(zodType);
if (kind === 'default') {
// zod 4 exposes `defaultValue` as a getter that already invokes a lazily
// declared default; the function branch is insurance against that changing.
const { defaultValue } = (zodType as unknown as { def: { defaultValue: unknown } }).def;
return typeof defaultValue === 'function' ? defaultValue() : defaultValue;
}
if (kind === 'optional' || kind === 'nullable') {
return defaultValueOf(zodInner(zodType));
}
return undefined;
}
/**
* Where a cube was discovered.
*
* Worth carrying because a cube's own directory does not say how it got into
* the run: `/…/node_modules/@acme/cubes-net/cubes/x` could equally have come
* from a `cubeDirs` entry pointing straight at it.
*/
export type CubeSource =
/** Found under a `cubeDirs` entry or a `.npcubes` marker, at `dir`. */
| { type: 'dir'; dir: string }
/** Contributed by a package named in `cubePackages`. */
| { type: 'package'; packageName: string; dir: string };
/**
* A fully loaded cube with its filesystem location and runtime state
*/
export class Cube<Schema extends AnyObjectSchema = AnyObjectSchema> {
constructor(
public readonly manifest: Manifest<Schema>,
public readonly dir: string,
public readonly deployScript: string,
/** Defaults to the cube's own directory, for cubes built by hand. */
public readonly source: CubeSource = { type: 'dir', dir }
) {}
get id(): string {
return this.manifest.id;
}
get name(): string {
return this.manifest.name;
}
/**
* Returns default values for the cube's schema.
*
* Parsing an empty object resolves every default in one go, but it fails
* outright as soon as one field has no `.default()`. Falling back to a
* per-field read keeps the defaults that *are* declared instead of dropping
* the whole set — a single required field used to leave the cube with no
* variables at all.
*/
getDefaults(): z.infer<Schema> {
const parsed = this.manifest.schema.safeParse({});
if (parsed.success) return parsed.data as z.infer<Schema>;
const defaults: Record<string, unknown> = {};
for (const [key, zodType] of Object.entries(this.manifest.schema.shape)) {
const value = defaultValueOf(zodType);
if (value !== undefined) defaults[key] = value;
}
return defaults as z.infer<Schema>;
}
/**
* Schema keys that have to be supplied from somewhere: no `.default()`, and
* not optional. Nothing else can fill them in, so a run that cannot prompt
* has to fail rather than deploy a cube with the value missing.
*/
requiredKeys(): string[] {
return Object.entries(this.manifest.schema.shape)
.filter(([, zodType]) => !zodType.safeParse(undefined).success)
.map(([key]) => key);
}
/** Schema keys the manifest declared as secrets. */
get secrets(): string[] {
return this.manifest.secrets ?? [];
}
isSecret(key: string): boolean {
return this.secrets.includes(key);
}
}
/**
* Result of loading cubes from the filesystem
*/
export interface LoadResult {
/** Map of cube key to Cube object */
cubes: Record<string, Cube>;
/** List of errors encountered during loading */
errors: string[];
}
@@ -1,6 +1,6 @@
/**
* Utility functions for cubes
* @module cubes/utils
* @module utils
*/
/**
@@ -1,10 +1,10 @@
/**
* Tests for cubes/factories module
* Tests for the manifest factories
*/
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { createManifest, manifest } from '../src/cubes/factories.js';
import { createManifest, manifest } from '../src/factories.js';
describe('createManifest', () => {
it('creates manifest with basic properties', () => {
@@ -0,0 +1,29 @@
/**
* A schema that behaves like zod's but does not share zod's prototypes.
*
* Once cubes arrive from `node_modules`, the schema a manifest builds may come
* from a *second* copy of zod — its own dependency, or one shipped inside a
* bundle. Such a schema is structurally identical and `instanceof` blind to it.
* Rebuilding the nodes as plain objects reproduces that from inside a single
* process, so anything that reads zod's internals stays pinned to `def.type`.
*/
import type { z } from 'zod';
/** Strips the prototype off a schema node and everything it wraps. */
function strip(node: unknown): unknown {
const def = { ...(node as { def: Record<string, unknown> }).def };
if (def.innerType) def.innerType = strip(def.innerType);
return { def };
}
export function foreignZodSchema<S extends z.ZodObject<any>>(schema: S): S {
return {
// Parsing is not what is under test — delegate it and keep the real
// behaviour, so only the introspection path sees the foreign nodes.
safeParse: (value: unknown) => schema.safeParse(value),
shape: Object.fromEntries(
Object.entries(schema.shape).map(([key, node]) => [key, strip(node)])
),
} as unknown as S;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Tests for the Cube runtime wrapper: default extraction and the required-key
* check that `--use-defaults` relies on.
*/
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { Cube, Manifest } from '../src/types.js';
import { foreignZodSchema } from './helpers/foreign-zod.js';
const cube = (schema: z.ZodObject<any>) =>
new Cube(Manifest.create({ id: 'c', name: 'C', schema }), '/cubes/c', 'deploy.py');
describe('Cube', () => {
it('reads id and name off the manifest', () => {
const c = cube(z.object({}));
expect(c.id).toBe('c');
expect(c.name).toBe('C');
});
it('defaults its source to its own directory', () => {
// What the loader overrides when a cube arrives from a package; a cube
// built by hand still has to answer the question.
expect(cube(z.object({})).source).toEqual({ type: 'dir', dir: '/cubes/c' });
});
it('keeps the source it was constructed with', () => {
const source = { type: 'package' as const, packageName: '@acme/cubes-net', dir: '/pkg/cubes' };
const c = new Cube(
Manifest.create({ id: 'c', name: 'C' }),
'/pkg/cubes/c',
'deploy.py',
source
);
expect(c.source).toBe(source);
});
});
describe('Cube.getDefaults', () => {
it('resolves every default when the whole schema parses', () => {
const c = cube(
z.object({
PORT: z.number().default(8080),
NAME: z.string().default('svc'),
})
);
expect(c.getDefaults()).toEqual({ PORT: 8080, NAME: 'svc' });
});
it('keeps the declared defaults when one field has none', () => {
const c = cube(
z.object({
SSID: z.string(),
PRIORITY: z.number().default(10),
HIDDEN: z.boolean().default(false),
})
);
expect(c.getDefaults()).toEqual({ PRIORITY: 10, HIDDEN: false });
});
it('unwraps a default sitting under optional or nullable', () => {
const c = cube(
z.object({
REQUIRED: z.string(),
A: z.number().default(1).optional(),
B: z.number().default(2).nullable(),
C: z.number().optional().default(3),
})
);
expect(c.getDefaults()).toEqual({ A: 1, B: 2, C: 3 });
});
it('evaluates a lazily declared default', () => {
const c = cube(
z.object({ REQUIRED: z.string(), TOKEN: z.string().default(() => 'generated') })
);
expect(c.getDefaults()).toEqual({ TOKEN: 'generated' });
});
it('omits an optional field that declares no default', () => {
const c = cube(z.object({ REQUIRED: z.string(), MAYBE: z.string().optional() }));
expect(c.getDefaults()).toEqual({});
});
it('returns an empty object for an empty schema', () => {
expect(cube(z.object({})).getDefaults()).toEqual({});
});
it('reads defaults off a schema built by a different copy of zod', () => {
// The per-field fallback reads zod's internals directly. Under `instanceof`
// a foreign schema yields no defaults at all, without erroring.
const c = cube(
foreignZodSchema(
z.object({
REQUIRED: z.string(),
PRIORITY: z.number().default(10),
NESTED: z.number().default(2).optional(),
})
)
);
expect(c.getDefaults()).toEqual({ PRIORITY: 10, NESTED: 2 });
});
});
describe('Cube.requiredKeys', () => {
it('lists the fields with neither a default nor optionality', () => {
const c = cube(
z.object({
SSID: z.string(),
PASSWORD: z.string(),
PRIORITY: z.number().default(10),
NOTE: z.string().optional(),
})
);
expect(c.requiredKeys()).toEqual(['SSID', 'PASSWORD']);
});
it('treats a nullable field without a default as required', () => {
const c = cube(z.object({ MAYBE: z.string().nullable() }));
expect(c.requiredKeys()).toEqual(['MAYBE']);
});
it('is empty when every field can fill itself in', () => {
const c = cube(z.object({ A: z.string().default('a'), B: z.string().optional() }));
expect(c.requiredKeys()).toEqual([]);
});
});
describe('Cube.secrets', () => {
it('is empty when the manifest declares none', () => {
const c = cube(z.object({ PASSWORD: z.string().default('x') }));
expect(c.secrets).toEqual([]);
// No name-based guessing: only what the manifest says.
expect(c.isSecret('PASSWORD')).toBe(false);
});
it('reports what the manifest declared', () => {
const c = new Cube(
Manifest.create({
id: 'c',
name: 'C',
schema: z.object({ USER: z.string(), PASSWORD: z.string() }),
secrets: ['PASSWORD'],
}),
'/cubes/c',
'deploy.py'
);
expect(c.secrets).toEqual(['PASSWORD']);
expect(c.isSecret('PASSWORD')).toBe(true);
expect(c.isSecret('USER')).toBe(false);
});
it('defaults to an empty list on a manifest built by hand', () => {
const c = new Cube({ id: 'c', name: 'C', schema: z.object({}) }, '/cubes/c', 'deploy.py');
expect(c.secrets).toEqual([]);
});
});
@@ -1,9 +1,9 @@
/**
* Tests for cubes/utils module
* Tests for the uniqid helper
*/
import { describe, expect, it } from 'vitest';
import { uniqid } from '../src/cubes/utils.js';
import { uniqid } from '../src/utils.js';
describe('uniqid', () => {
it('generates string of default length (5)', () => {
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": ".tsbuildinfo",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2020"],
"composite": true,
"module": "NodeNext",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["coverage", "node_modules", "dist"],
"references": []
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',
// Pure re-export barrel: no logic to cover.
'src/index.ts',
],
thresholds: {
branches: 85,
functions: 85,
lines: 80,
statements: 80,
},
},
},
});

Some files were not shown because too many files have changed in this diff Show More