Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ba1c2a32a | |||
| ea08e76a2f | |||
| 6ecb2c366f | |||
| ac050c4459 | |||
| 30d93dddc5 | |||
| 5ed68c0065 | |||
| fcc181700e | |||
| a4ce4879a4 |
@@ -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
|
||||
|
||||
@@ -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::"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"hosts": [],
|
||||
"cubeDirs": ["./cubes"],
|
||||
"cubeDirs": [],
|
||||
"cubePackages": ["@bitsquare/cubes-core"],
|
||||
"env": {},
|
||||
"log": {
|
||||
"verbosity": "info",
|
||||
|
||||
@@ -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/
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+2
-2
@@ -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: () => [],
|
||||
+2
-2
@@ -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' }]
|
||||
+2
-2
@@ -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: () => [],
|
||||
+2
-2
@@ -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: () => [],
|
||||
+2
-2
@@ -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: () => [],
|
||||
+7
-1
@@ -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.
|
||||
+3
-2
@@ -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
|
||||
+12
-3
@@ -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)
|
||||
|
||||
+3
-2
@@ -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
|
||||
+5
@@ -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.
|
||||
|
||||
+3
-2
@@ -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'),
|
||||
+2
-2
@@ -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: () => [],
|
||||
+2
-2
@@ -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: () => [],
|
||||
+2
-2
@@ -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: () => [],
|
||||
+2
-2
@@ -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)'),
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
/**
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
@@ -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)', () => {
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user