2 Commits

Author SHA1 Message Date
Benjamin Diedrichsen 1ba1c2a32a [chore] commit id display on version
Publish snapshot / snapshot (push) Successful in 1m26s
2026-07-29 13:00:10 +02:00
Benjamin Diedrichsen ea08e76a2f [feat] nopy update command and auto-update pipeline 2026-07-29 11:16:52 +02:00
34 changed files with 5240 additions and 501 deletions
+17 -3
View File
@@ -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
@@ -115,7 +123,10 @@ jobs:
# `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}"
(cd "$dir" && npm pkg set "version=${version}")
# `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.
@@ -124,13 +135,16 @@ jobs:
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
# 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 --registry "$REGISTRY")
(cd "$dir" && pnpm publish --ignore-scripts --no-git-checks --tag main --@bitsquare:registry="$REGISTRY")
fi
echo "::endgroup::"
+47 -6
View File
@@ -45,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: |
@@ -129,7 +141,9 @@ jobs:
# to read, which this step does not have yet.
missing=0
for spec in $(node scripts/linked-deps.mjs "$DIR" | tr ' ' '@'); do
if npm view "$spec" version --registry "$NPMJS_REGISTRY" >/dev/null 2>&1; then
# 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."
@@ -151,6 +165,20 @@ 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.
@@ -171,13 +199,18 @@ jobs:
} >> "$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
# 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.
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --registry "$GITEA_REGISTRY")
#
# 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
@@ -195,12 +228,17 @@ jobs:
} >> "$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" && pnpm publish --ignore-scripts --no-git-checks --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
@@ -279,5 +317,8 @@ jobs:
echo "### Released \`${NAME}@${VERSION}\` (\`${DIST_TAG}\`)"
echo ""
echo "- npmjs: \`npm install -g ${NAME}@${VERSION}\`"
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --registry ${GITEA_REGISTRY}\`"
# Scoped, never a bare `--registry`: Gitea serves @bitsquare only and
# does not proxy npmjs, so a bare flag sends every transitive
# dependency to a registry that has never heard of them.
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --@bitsquare:registry=${GITEA_REGISTRY}\`"
} >> "$GITHUB_STEP_SUMMARY"
+5
View File
@@ -22,3 +22,8 @@ tsconfig.tsbuildinfo
.npmrc
.npmrc-*
release.json
# ...except the repo-root .npmrc, which is checked in on purpose: it holds the
# @bitsquare -> Gitea scope mapping and nothing else. Credentials live in the
# .npmrc-* files above, which stay ignored.
!/.npmrc
+20
View File
@@ -0,0 +1,20 @@
# The @bitsquare scope resolves from the Gitea registry rather than npmjs.
#
# Gitea is a strict superset: release.yml publishes there *and* to npmjs, while
# publish-snapshot.yml pushes a `main` snapshot on every push. So this is not a
# trade — it is every released version plus the ones npmjs has never seen, which
# is what makes a snapshot testable before it is released.
#
# Scoped deliberately. A bare `registry=` would send all ~55 transitive
# dependencies to Gitea too, and Gitea serves this scope only — it does not
# proxy npmjs, so they would all 404. Everything outside @bitsquare keeps going
# to the default registry.
#
# Reading is anonymous; no token belongs in this file. The publish workflows
# write their credentials to a throwaway .npmrc-gitea / .npmrc-release, both of
# which stay gitignored.
#
# Note this maps the *scope*, not a channel: Gitea currently publishes no
# `latest` dist-tag, so an untagged `npm i @bitsquare/nopy` resolves to nothing.
# Ask for a tag — @main for the newest snapshot. See README.PUBLISH.md.
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
+115 -11
View File
@@ -22,6 +22,13 @@ publish order matters — see *Releasing*.
`.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
@@ -32,6 +39,8 @@ pnpm run lint # biome check . (lint:fix / lint:ci varian
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:
@@ -95,14 +104,19 @@ One pass per invocation, `nopy.main.ts` orchestrating:
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 the directories its `nopy.cubes` field declares.
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, a missing `nopy.cubes`, a directory that does not exist, and an
entry pointing outside the package root are all errors, never silent skips.
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
@@ -208,10 +222,80 @@ 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.
@@ -223,6 +307,18 @@ Tag-driven, one package at a time; see `README.PUBLISH.md`.
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
@@ -248,17 +344,25 @@ Three things the `workspace:*` links added, all of them non-obvious:
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 changes above have been verified locally (pack, npm-install of
the tarballs into a throwaway tree, run) but have **never run against the Gitea
registry**. Burn a throwaway version there before the first real release.
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` predates several refactors and still describes `Cube` and
`Manifest` as plain interfaces with a `key` field; the code has a `Cube` class
keyed on `id`. The `cubePackages` and `CubeSource` sections added for this work
are accurate; treat the rest of that file with suspicion. `DOCS-AUDIT.md` tracks
the wider drift.
`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.
+55 -7
View File
@@ -17,8 +17,14 @@ 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), §4.2 (password on stdout — points 1 and 2 of 3), §4.3 (what a session
records), part of §3.5 (dead exports), and one bullet of §6.4.
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.
---
@@ -26,7 +32,7 @@ records), part of §3.5 (dead exports), and one bullet of §6.4.
- [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](#3-docsapimd--systematic-drift)
- [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)
@@ -136,6 +142,10 @@ not. Two cubes that depend on each other recurse until the stack overflows —
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
@@ -256,6 +266,11 @@ 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).
@@ -333,7 +348,14 @@ hooks, presenting explicit parameters as a hook-only capability.
`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
### 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`,
@@ -388,7 +410,30 @@ first.
---
## 3. `docs/API.md` — systematic drift
## 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
@@ -786,8 +831,11 @@ deletion, but neither can stay documented as working.
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).** Too far gone to patch: two core types,
one whole module, and two functions describe code that no longer exists.
**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
+223 -7
View File
@@ -14,6 +14,9 @@ shipped. If you only want to cut a release, jump to
- [Secrets](#secrets)
- [Registry authentication in the workflows](#registry-authentication-in-the-workflows)
- [Installing the packages](#installing-the-packages)
- [Resolving from Gitea in this repo](#resolving-from-gitea-in-this-repo)
- [Testing a snapshot before you release](#testing-a-snapshot-before-you-release)
- [Upgrading an installed CLI](#upgrading-an-installed-cli)
- [Design decisions](#design-decisions)
- [Checking things locally](#checking-things-locally)
- [Troubleshooting](#troubleshooting)
@@ -176,15 +179,40 @@ 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 every package to the Gitea registry,
@@ -391,6 +419,170 @@ To track snapshots in another project:
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
@@ -452,6 +644,19 @@ 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.
@@ -475,14 +680,25 @@ nopy --help
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 @bitsquare/nopy@1.2.0 version # npmjs
npm view @bitsquare/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 |
+2
View File
@@ -13,6 +13,8 @@
"test": "pnpm -r run test",
"test:coverage": "pnpm -r run test:coverage",
"coverage:summary": "node scripts/coverage-summary.mjs",
"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 .",
+3 -3
View File
@@ -18,9 +18,9 @@ Then name it in `.nopyrc.json`:
}
```
`nopy` resolves the package from the directory of the config file that named it,
reads `nopy.cubes` out of its `package.json`, and scans those directories exactly
as it scans a `cubeDirs` entry. Nothing has to be linked or copied.
`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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@bitsquare/cubes-core",
"version": "1.0.0-alpha0",
"version": "0.5.0",
"description": "The core nopy cube bundle: apt, users, ssh, networking, services and runtimes.",
"keywords": [
"nopy",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@bitsquare/keyman",
"version": "1.0.0",
"version": "0.5.0",
"description": "A system to simplify ssh key management",
"keywords": [
"ssh",
@@ -55,10 +55,12 @@
"dependencies": {
"execa": "^10.0.0",
"inquirer": "^14.0.2",
"semver": "^7.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/semver": "^7.7.1",
"@vitest/coverage-v8": "^4.1.10",
"tsx": "^4.23.1",
"typescript": "^7.0.2",
+28
View File
@@ -1,3 +1,31 @@
#!/usr/bin/env node
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
export * from './keyman.main.js';
export type {
Channel,
CommandRunner,
PackageManager,
SelfUpdateResult,
UpdateCache,
UpdateStatus,
} from './keyman.update.js';
export {
buildSelfUpdateCommand,
channelForVersion,
checkForUpdate,
DEFAULT_CHECK_INTERVAL_MS,
detectPackageManager,
fetchChannelVersion,
formatCommand,
formatUpdateNotice,
getUpdateCachePath,
isUpdateCheckDisabled,
NPMJS_REGISTRY,
normalizeRegistry,
PACKAGE_NAME,
readUpdateCache,
resolveRegistry,
selfUpdate,
updateNotice,
writeUpdateCache,
} from './keyman.update.js';
+67
View File
@@ -1,10 +1,31 @@
#!/usr/bin/env node
import { createRequire } from 'node:module';
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { keyman } from './keyman.main.js';
import type { Channel } from './keyman.update.js';
import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js';
const { version, buildInfo } = createRequire(import.meta.url)('../package.json') as {
version: string;
buildInfo?: { commit?: string };
};
/**
* What `--version` prints. `version` itself stays untouched everywhere else —
* the commit is an annotation, stamped into `package.json` on the runner by the
* publish workflows and absent when running from source.
*/
const versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
const args = process.argv.slice(2);
/** Reads `--flag value` out of argv, or undefined when the flag is absent */
function flagValue(name: string): string | undefined {
const index = args.indexOf(name);
return index === -1 ? undefined : args[index + 1];
}
if (args.includes('--print-config')) {
const config = loadConfig();
const paths = resolveConfigPaths(config);
@@ -12,4 +33,50 @@ if (args.includes('--print-config')) {
process.exit(0);
}
if (args.includes('--version') || args.includes('-V')) {
console.log(versionLabel);
process.exit(0);
}
if (args[0] === 'self-update' || args[0] === 'upgrade' || args.includes('--self-update')) {
const dryRun = args.includes('--dry-run') || args.includes('-n');
try {
const result = await selfUpdate({
currentVersion: version,
channel: flagValue('--channel') as Channel | undefined,
registry: flagValue('--registry'),
dryRun,
force: args.includes('--force') || args.includes('-f'),
});
const { status } = result;
console.log(`Installed: ${status.current}`);
console.log(`Channel: ${status.channel}`);
console.log(`Registry: ${status.registry}`);
console.log(`Available: ${status.latest ?? 'unknown'}`);
console.log('');
if (result.ran) {
console.log(`Updated to ${status.latest}.`);
} else if (dryRun) {
console.log(`Would run: ${formatCommand(result.command)}`);
} else if (status.latest === null) {
console.error(`Could not reach ${status.registry} — nothing was changed.`);
process.exit(1);
} else {
console.log('Already up to date.');
}
} catch (error) {
console.error('Update failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
process.exit(0);
}
// Printed to stderr so it never mixes into machine-read output.
const notice = await updateNotice({ currentVersion: version });
if (notice) {
console.error(`\n${notice}\n`);
}
keyman();
+472
View File
@@ -0,0 +1,472 @@
/**
* Update checking and self-update for the keyman CLI
*
* A near-copy of nopy's `nopy.update` module, differing only in the package it
* names and the environment variables it reads. The two CLIs share no internal
* library — keyman deliberately stands alone — and a fifth workspace package
* for ~250 lines would buy another edge in the publish order for nothing. If a
* third CLI ever appears, extract it then.
*
* @module keyman.update
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execa } from 'execa';
import semver from 'semver';
/** The published package this CLI ships as */
export const PACKAGE_NAME = '@bitsquare/keyman';
/** The npm scope the package lives under, used for the registry config key */
export const SCOPE = '@bitsquare';
/** Where packages resolve from when nothing says otherwise */
export const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
/** Directory under the user's home holding the update-check cache */
export const UPDATE_CACHE_DIR = '.keyman';
/** File name of the update-check cache */
export const UPDATE_CACHE_FILE = 'update-check.json';
/** How long a cached check is considered fresh */
export const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** How long the background check may block the CLI */
export const DEFAULT_FETCH_TIMEOUT_MS = 1500;
/** How long `npm config get` may take before the registry falls back to npmjs */
export const DEFAULT_CONFIG_TIMEOUT_MS = 5000;
/**
* A dist-tag this project publishes under.
*
* `latest` is a release, `next` a prerelease (`0.6.0-rc.1`), `main` a snapshot
* built from a commit on `main` and published to Gitea only.
*/
export type Channel = 'latest' | 'next' | 'main';
/** A package manager that can install a global binary */
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
/** Runs a command and resolves with its trimmed stdout */
export type CommandRunner = (file: string, args: string[]) => Promise<string>;
/** The result of an update check */
export interface UpdateStatus {
/** The version currently running */
current: string;
/** The version the channel points at, or null if it could not be determined */
latest: string | null;
/** The channel the current version implies */
channel: Channel;
/** The registry the check went to */
registry: string;
/** Whether `latest` is strictly newer than `current` */
updateAvailable: boolean;
/** Whether the answer came from cache rather than the network */
fromCache: boolean;
}
/** The on-disk update-check cache */
export interface UpdateCache {
/** ISO timestamp of the check */
checkedAt: string;
/** The channel that was checked */
channel: Channel;
/** The registry that was checked */
registry: string;
/** The version the channel pointed at, or null if the lookup found nothing */
latest: string | null;
}
/**
* Derives the release channel from a version string.
*
* @param version - a semver version, typically this package's own
* @returns the dist-tag that version would have been published under
*/
export function channelForVersion(version: string): Channel {
const parsed = semver.parse(version, { loose: true });
if (!parsed || parsed.prerelease.length === 0) {
return 'latest';
}
return parsed.prerelease.some((part) => part === 'main') ? 'main' : 'next';
}
/** Normalises a registry URL to the trailing-slash form the packument path is appended to */
export function normalizeRegistry(url: string): string {
const trimmed = url.trim();
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
}
/** Runs a command through execa and returns its stdout */
const defaultRunner: CommandRunner = async (file, args) => {
const { stdout } = await execa(file, args, { timeout: DEFAULT_CONFIG_TIMEOUT_MS });
return stdout;
};
/**
* Resolves the registry `@bitsquare` packages come from.
*
* `KEYMAN_REGISTRY` wins, then npm's own scoped-registry config, then npmjs.
*/
export async function resolveRegistry(
options: { env?: NodeJS.ProcessEnv; run?: CommandRunner } = {}
): Promise<string> {
const env = options.env ?? process.env;
const override = env.KEYMAN_REGISTRY?.trim();
if (override) {
return normalizeRegistry(override);
}
const run = options.run ?? defaultRunner;
try {
const stdout = (await run('npm', ['config', 'get', `${SCOPE}:registry`])).trim();
// npm prints the string "undefined" for an unset key rather than nothing.
if (stdout && stdout !== 'undefined' && stdout !== 'null') {
return normalizeRegistry(stdout);
}
} catch {
// npm not on PATH, or the config is unreadable.
}
return NPMJS_REGISTRY;
}
/**
* Reads the version a dist-tag points at, straight from the registry.
*
* @returns the version, or null if the registry or the tag has nothing
*/
export async function fetchChannelVersion(options: {
registry: string;
channel: Channel;
packageName?: string;
timeoutMs?: number;
token?: string;
fetchImpl?: typeof fetch;
}): Promise<string | null> {
const doFetch = options.fetchImpl ?? globalThis.fetch;
const packageName = options.packageName ?? PACKAGE_NAME;
const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`;
const headers: Record<string, string> = {
accept: 'application/vnd.npm.install-v1+json, application/json',
};
if (options.token) {
headers.authorization = `Bearer ${options.token}`;
}
const response = await doFetch(url, {
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as { 'dist-tags'?: Record<string, string> };
return body['dist-tags']?.[options.channel] ?? null;
}
/** Path of the update-check cache file */
export function getUpdateCachePath(homedir: string = os.homedir()): string {
return path.join(homedir, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE);
}
/**
* Reads the update-check cache.
*
* @returns the cache, or null if it is missing or unreadable
*/
export function readUpdateCache(cachePath: string = getUpdateCachePath()): UpdateCache | null {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as UpdateCache;
return typeof parsed?.checkedAt === 'string' ? parsed : null;
} catch {
return null;
}
}
/** Writes the update-check cache. Best effort — a read-only home costs a check, not a failure */
export function writeUpdateCache(
cache: UpdateCache,
cachePath: string = getUpdateCachePath()
): void {
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
} catch {
// Ignored on purpose.
}
}
/** Whether the startup check should be skipped entirely */
export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
const flag = env.KEYMAN_NO_UPDATE_CHECK?.trim().toLowerCase();
if (flag && flag !== '0' && flag !== 'false') {
return true;
}
return Boolean(env.CI);
}
/**
* Checks whether a newer version exists on the current channel.
*
* Answers from cache when a check happened recently for the same channel and
* registry; a failed lookup degrades to the cached answer rather than none.
*/
export async function checkForUpdate(options: {
currentVersion: string;
channel?: Channel;
registry?: string;
force?: boolean;
intervalMs?: number;
cachePath?: string;
now?: number;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
fetchImpl?: typeof fetch;
run?: CommandRunner;
}): Promise<UpdateStatus> {
const {
currentVersion,
force = false,
intervalMs = DEFAULT_CHECK_INTERVAL_MS,
cachePath = getUpdateCachePath(),
now = Date.now(),
env = process.env,
} = options;
const channel = options.channel ?? channelForVersion(currentVersion);
const registry = normalizeRegistry(
options.registry ?? (await resolveRegistry({ env, run: options.run }))
);
const cache = readUpdateCache(cachePath);
const applicable = cache && cache.channel === channel && cache.registry === registry;
const age = cache ? now - Date.parse(cache.checkedAt) : Number.POSITIVE_INFINITY;
const fresh = applicable && Number.isFinite(age) && age >= 0 && age < intervalMs;
if (!force && fresh && cache) {
return status(currentVersion, cache.latest, channel, registry, true);
}
try {
const latest = await fetchChannelVersion({
registry,
channel,
timeoutMs: options.timeoutMs,
token: env.KEYMAN_REGISTRY_TOKEN?.trim() || undefined,
fetchImpl: options.fetchImpl,
});
writeUpdateCache(
{ checkedAt: new Date(now).toISOString(), channel, registry, latest },
cachePath
);
return status(currentVersion, latest, channel, registry, false);
} catch {
return status(
currentVersion,
applicable && cache ? cache.latest : null,
channel,
registry,
true
);
}
}
/** Assembles an {@link UpdateStatus}, deciding whether the remote version wins */
function status(
current: string,
latest: string | null,
channel: Channel,
registry: string,
fromCache: boolean
): UpdateStatus {
const updateAvailable = Boolean(
latest && semver.valid(latest) && semver.valid(current) && semver.gt(latest, current)
);
return { current, latest, channel, registry, updateAvailable, fromCache };
}
/**
* Detects which package manager installed this CLI, so `self-update` re-runs
* the same one rather than leaving two copies on the PATH.
*/
export function detectPackageManager(
options: { execPath?: string; env?: NodeJS.ProcessEnv } = {}
): PackageManager {
const env = options.env ?? process.env;
const override = env.KEYMAN_PACKAGE_MANAGER?.trim().toLowerCase();
if (override === 'npm' || override === 'pnpm' || override === 'yarn' || override === 'bun') {
return override;
}
const from = (options.execPath ?? process.argv[1] ?? '').replace(/\\/g, '/').toLowerCase();
if (from.includes('/pnpm/')) return 'pnpm';
if (from.includes('/.bun/')) return 'bun';
if (from.includes('/.yarn/') || from.includes('/yarn/')) return 'yarn';
return 'npm';
}
/**
* Builds the command that installs a given channel globally.
*
* The registry is passed as a **scoped** override rather than `--registry`,
* because the Gitea registry serves `@bitsquare` packages and does not proxy
* npmjs — a global `--registry` would send every dependency to a registry that
* has never heard of them.
*/
export function buildSelfUpdateCommand(options: {
packageManager: PackageManager;
channel: Channel;
registry: string;
packageName?: string;
}): { file: string; args: string[] } {
const packageName = options.packageName ?? PACKAGE_NAME;
const spec = `${packageName}@${options.channel}`;
const registryArgs =
normalizeRegistry(options.registry) === NPMJS_REGISTRY
? []
: [`--${SCOPE}:registry=${normalizeRegistry(options.registry)}`];
switch (options.packageManager) {
case 'pnpm':
return { file: 'pnpm', args: ['add', '--global', spec, ...registryArgs] };
case 'yarn':
return { file: 'yarn', args: ['global', 'add', spec, ...registryArgs] };
case 'bun':
return { file: 'bun', args: ['add', '--global', spec, ...registryArgs] };
default:
return { file: 'npm', args: ['install', '--global', spec, ...registryArgs] };
}
}
/** Renders a command as the shell line a user could paste */
export function formatCommand(command: { file: string; args: string[] }): string {
return [command.file, ...command.args].join(' ');
}
/**
* Renders the hint printed at startup when an update exists.
*
* @returns the notice, or null when there is nothing to say
*/
export function formatUpdateNotice(
status: UpdateStatus,
packageManager?: PackageManager
): string | null {
if (!status.updateAvailable || !status.latest) {
return null;
}
const command = buildSelfUpdateCommand({
packageManager: packageManager ?? detectPackageManager(),
channel: status.channel,
registry: status.registry,
});
const channelNote = status.channel === 'latest' ? '' : ` (${status.channel})`;
return [
`Update available: ${status.current} -> ${status.latest}${channelNote}`,
`Run "keyman self-update" or "${formatCommand(command)}"`,
].join('\n');
}
/**
* The startup path: returns the notice to print, or null.
*
* Never throws and never blocks for longer than the fetch timeout.
*/
export async function updateNotice(options: {
currentVersion: string;
env?: NodeJS.ProcessEnv;
cachePath?: string;
intervalMs?: number;
timeoutMs?: number;
now?: number;
fetchImpl?: typeof fetch;
run?: CommandRunner;
}): Promise<string | null> {
const env = options.env ?? process.env;
if (isUpdateCheckDisabled(env)) {
return null;
}
try {
const status = await checkForUpdate({ ...options, env });
return formatUpdateNotice(status, detectPackageManager({ env }));
} catch {
return null;
}
}
/** Outcome of a {@link selfUpdate} run */
export interface SelfUpdateResult {
/** The status the decision was based on */
status: UpdateStatus;
/** The command that was run, or would have been run */
command: { file: string; args: string[] };
/** Whether the install actually ran */
ran: boolean;
}
/**
* Installs the newest version on the current channel.
*
* @param options.dryRun - print the command instead of running it
* @param options.force - reinstall even when already up to date
*/
export async function selfUpdate(options: {
currentVersion: string;
channel?: Channel;
registry?: string;
packageManager?: PackageManager;
dryRun?: boolean;
force?: boolean;
env?: NodeJS.ProcessEnv;
cachePath?: string;
fetchImpl?: typeof fetch;
run?: CommandRunner;
spawn?: (file: string, args: string[]) => Promise<unknown>;
}): Promise<SelfUpdateResult> {
const env = options.env ?? process.env;
// Always ignore the cache here: the user asked, so the answer has to be current.
const status = await checkForUpdate({
currentVersion: options.currentVersion,
channel: options.channel,
registry: options.registry,
force: true,
cachePath: options.cachePath,
env,
fetchImpl: options.fetchImpl,
run: options.run,
});
const command = buildSelfUpdateCommand({
packageManager: options.packageManager ?? detectPackageManager({ env }),
channel: status.channel,
registry: status.registry,
});
if (options.dryRun || (!status.updateAvailable && !options.force)) {
return { status, command, ran: false };
}
const spawn =
options.spawn ?? ((file: string, args: string[]) => execa(file, args, { stdio: 'inherit' }));
await spawn(command.file, command.args);
return { status, command, ran: true };
}
+868
View File
@@ -0,0 +1,868 @@
/**
* Tests for keyman.update module
*
* Every network call, clock read and spawn is injected, so nothing here
* reaches a registry or the user's home directory.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildSelfUpdateCommand,
type Channel,
channelForVersion,
checkForUpdate,
detectPackageManager,
fetchChannelVersion,
formatCommand,
formatUpdateNotice,
getUpdateCachePath,
isUpdateCheckDisabled,
NPMJS_REGISTRY,
normalizeRegistry,
readUpdateCache,
resolveRegistry,
selfUpdate,
type UpdateCache,
updateNotice,
writeUpdateCache,
} from '../src/keyman.update.js';
const GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
let tmpDir: string;
let cachePath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-update-'));
cachePath = path.join(tmpDir, 'update-check.json');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
/** A fetch stand-in returning the given dist-tags */
function fakeFetch(distTags: Record<string, string>, ok = true): typeof fetch {
return (async () =>
({
ok,
json: async () => ({ 'dist-tags': distTags }),
}) as Response) as unknown as typeof fetch;
}
describe('channelForVersion', () => {
it('maps a clean release to latest', () => {
expect(channelForVersion('0.5.0')).toBe('latest');
expect(channelForVersion('1.2.3')).toBe('latest');
});
it('maps a snapshot to main', () => {
expect(channelForVersion('0.5.0-main.14.g6ecb2c3')).toBe('main');
});
it('maps any other prerelease to next', () => {
expect(channelForVersion('0.6.0-rc.1')).toBe('next');
expect(channelForVersion('1.0.0-alpha5')).toBe('next');
});
it('treats an unparseable version as latest', () => {
expect(channelForVersion('not-a-version')).toBe('latest');
expect(channelForVersion('')).toBe('latest');
});
});
describe('normalizeRegistry', () => {
it('adds a trailing slash', () => {
expect(normalizeRegistry('https://example.com/npm')).toBe('https://example.com/npm/');
});
it('leaves an existing trailing slash alone', () => {
expect(normalizeRegistry(GITEA)).toBe(GITEA);
});
it('trims surrounding whitespace', () => {
expect(normalizeRegistry(' https://example.com/npm ')).toBe('https://example.com/npm/');
});
});
describe('resolveRegistry', () => {
it('prefers the KEYMAN_REGISTRY override', async () => {
const run = vi.fn();
const registry = await resolveRegistry({
env: { KEYMAN_REGISTRY: 'https://example.com/npm' },
run,
});
expect(registry).toBe('https://example.com/npm/');
expect(run).not.toHaveBeenCalled();
});
it('falls back to npm config', async () => {
const run = vi.fn(async () => GITEA);
expect(await resolveRegistry({ env: {}, run })).toBe(GITEA);
expect(run).toHaveBeenCalledWith('npm', ['config', 'get', '@bitsquare:registry']);
});
it('treats npm printing "undefined" as unset', async () => {
const run = vi.fn(async () => 'undefined');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('treats npm printing "null" as unset', async () => {
const run = vi.fn(async () => 'null');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('treats empty output as unset', async () => {
const run = vi.fn(async () => ' ');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('falls back to npmjs when npm is missing', async () => {
const run = vi.fn(async () => {
throw new Error('ENOENT');
});
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('ignores a blank override', async () => {
const run = vi.fn(async () => GITEA);
expect(await resolveRegistry({ env: { KEYMAN_REGISTRY: ' ' }, run })).toBe(GITEA);
});
});
describe('fetchChannelVersion', () => {
it('reads the requested dist-tag', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'main',
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234', latest: '0.5.0' }),
});
expect(version).toBe('0.5.0-main.14.gabc1234');
});
it('returns null when the tag does not exist', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'latest',
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234' }),
});
expect(version).toBeNull();
});
it('returns null on a non-ok response', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'latest',
fetchImpl: fakeFetch({}, false),
});
expect(version).toBeNull();
});
it('returns null when the packument has no dist-tags at all', async () => {
const fetchImpl = (async () =>
({ ok: true, json: async () => ({}) }) as Response) as unknown as typeof fetch;
expect(await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl })).toBeNull();
});
it('url-encodes the scoped package name onto the registry', async () => {
const seen: string[] = [];
const fetchImpl = (async (url: string) => {
seen.push(url);
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
// No trailing slash on purpose: it must be normalised before joining.
await fetchChannelVersion({
registry: 'https://example.com/npm',
channel: 'latest',
fetchImpl,
});
expect(seen[0]).toBe('https://example.com/npm/%40bitsquare%2Fkeyman');
});
it('sends a bearer token when one is given', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
await fetchChannelVersion({ registry: GITEA, channel: 'latest', token: 'secret', fetchImpl });
expect(headers.authorization).toBe('Bearer secret');
});
it('omits the authorization header when no token is given', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl });
expect(headers.authorization).toBeUndefined();
});
});
describe('the update cache', () => {
it('round-trips', () => {
const cache: UpdateCache = {
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.6.0',
};
writeUpdateCache(cache, cachePath);
expect(readUpdateCache(cachePath)).toEqual(cache);
});
it('creates the containing directory', () => {
const nested = path.join(tmpDir, 'a', 'b', 'update-check.json');
writeUpdateCache(
{
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: null,
},
nested
);
expect(fs.existsSync(nested)).toBe(true);
});
it('reads a missing file as null', () => {
expect(readUpdateCache(path.join(tmpDir, 'absent.json'))).toBeNull();
});
it('reads malformed JSON as null', () => {
fs.writeFileSync(cachePath, '{ not json', 'utf-8');
expect(readUpdateCache(cachePath)).toBeNull();
});
it('rejects a file without a checkedAt stamp', () => {
fs.writeFileSync(cachePath, JSON.stringify({ latest: '9.9.9' }), 'utf-8');
expect(readUpdateCache(cachePath)).toBeNull();
});
it('swallows a write it cannot perform', () => {
// A path whose parent is a file, not a directory.
const blocked = path.join(cachePath, 'nested.json');
fs.writeFileSync(cachePath, '{}', 'utf-8');
expect(() =>
writeUpdateCache(
{
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: null,
},
blocked
)
).not.toThrow();
});
it('defaults to a path under the home directory', () => {
expect(getUpdateCachePath('/home/someone')).toBe('/home/someone/.keyman/update-check.json');
});
});
describe('isUpdateCheckDisabled', () => {
it('is off by default', () => {
expect(isUpdateCheckDisabled({})).toBe(false);
});
it('honours KEYMAN_NO_UPDATE_CHECK', () => {
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '1' })).toBe(true);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'yes' })).toBe(true);
});
it('treats 0 and false as not disabled', () => {
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '0' })).toBe(false);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'false' })).toBe(false);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '' })).toBe(false);
});
it('disables itself in CI', () => {
expect(isUpdateCheckDisabled({ CI: 'true' })).toBe(true);
});
});
describe('checkForUpdate', () => {
const base = {
currentVersion: '0.5.0',
registry: NPMJS_REGISTRY,
env: {} as NodeJS.ProcessEnv,
now: Date.parse('2026-07-29T12:00:00.000Z'),
};
it('reports a newer version on the channel', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status).toMatchObject({
current: '0.5.0',
latest: '0.6.0',
channel: 'latest',
updateAvailable: true,
fromCache: false,
});
});
it('reports no update when the channel matches', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
});
expect(status.updateAvailable).toBe(false);
});
it('does not treat an older published version as an update', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.4.0' }),
});
expect(status.updateAvailable).toBe(false);
});
it('derives the channel from the running version', async () => {
const status = await checkForUpdate({
...base,
currentVersion: '0.5.0-main.13.gabc1234',
cachePath,
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gdef5678', latest: '0.5.0' }),
});
expect(status.channel).toBe('main');
expect(status.latest).toBe('0.5.0-main.14.gdef5678');
expect(status.updateAvailable).toBe(true);
});
it('writes what it found to the cache', async () => {
await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }) });
expect(readUpdateCache(cachePath)).toEqual({
checkedAt: '2026-07-29T12:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.6.0',
});
});
it('answers from a fresh cache without touching the network', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const fetchImpl = vi.fn();
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(status.latest).toBe('0.7.0');
expect(status.fromCache).toBe(true);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('refetches once the cache goes stale', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-27T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.8.0' }),
});
expect(status.latest).toBe('0.8.0');
expect(status.fromCache).toBe(false);
});
it('ignores a cache written for a different channel', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'next',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache written for a different registry', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: GITEA,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache stamped in the future', async () => {
writeUpdateCache(
{
checkedAt: '2027-01-01T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache with an unparseable stamp', async () => {
fs.writeFileSync(
cachePath,
JSON.stringify({
checkedAt: 'whenever',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
}),
'utf-8'
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('refetches when forced, even with a fresh cache', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
force: true,
fetchImpl: fakeFetch({ latest: '0.9.0' }),
});
expect(status.latest).toBe('0.9.0');
expect(status.fromCache).toBe(false);
});
it('falls back to the cached answer when the network fails', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-20T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const fetchImpl = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
expect(status.latest).toBe('0.7.0');
expect(status.updateAvailable).toBe(true);
expect(status.fromCache).toBe(true);
});
it('reports nothing when the network fails and no cache applies', async () => {
const fetchImpl = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
expect(status.latest).toBeNull();
expect(status.updateAvailable).toBe(false);
});
it('resolves the registry when none is given', async () => {
const status = await checkForUpdate({
currentVersion: '0.5.0',
cachePath,
env: {},
run: async () => GITEA,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.registry).toBe(GITEA);
});
it('passes a registry token from the environment through', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.6.0' } }) } as Response;
}) as unknown as typeof fetch;
await checkForUpdate({
...base,
cachePath,
env: { KEYMAN_REGISTRY_TOKEN: 'tok' },
fetchImpl,
});
expect(headers.authorization).toBe('Bearer tok');
});
it('does not compare against an unparseable current version', async () => {
const status = await checkForUpdate({
...base,
currentVersion: 'dev',
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.updateAvailable).toBe(false);
});
});
describe('detectPackageManager', () => {
it('honours the environment override', () => {
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' })
).toBe('pnpm');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' })
).toBe('yarn');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' })
).toBe('bun');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' })
).toBe('npm');
});
it('ignores an unrecognised override', () => {
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'cargo' }, execPath: '/usr/lib/x' })
).toBe('npm');
});
it('recognises a pnpm global install', () => {
expect(
detectPackageManager({
env: {},
execPath: '/Users/x/Library/pnpm/global/5/node_modules/.bin/keyman',
})
).toBe('pnpm');
});
it('recognises a bun global install', () => {
expect(
detectPackageManager({
env: {},
execPath: '/Users/x/.bun/install/global/node_modules/keyman',
})
).toBe('bun');
});
it('recognises a yarn global install', () => {
expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/keyman' })).toBe('yarn');
});
it('defaults to npm', () => {
expect(
detectPackageManager({
env: {},
execPath: '/usr/local/lib/node_modules/@bitsquare/keyman/dist/keyman.cli.js',
})
).toBe('npm');
});
it('handles a windows-style path and an empty path', () => {
expect(
detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\keyman.exe' })
).toBe('pnpm');
expect(detectPackageManager({ env: {}, execPath: '' })).toBe('npm');
});
});
describe('buildSelfUpdateCommand', () => {
it('builds an npm global install without a registry flag for npmjs', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@latest');
});
it('adds a scoped registry override for a non-npmjs registry', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'main',
registry: GITEA,
});
// Scoped, not `--registry`: Gitea does not proxy npmjs, so the transitive
// dependencies have to keep resolving from npmjs.
expect(formatCommand(command)).toBe(
`npm install --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
);
expect(command.args).not.toContain('--registry');
});
it('normalises a registry given without a trailing slash', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: 'https://registry.npmjs.org',
});
expect(command.args).toEqual(['install', '--global', '@bitsquare/keyman@latest']);
});
it('builds for pnpm, yarn and bun', () => {
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'pnpm',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('pnpm add --global @bitsquare/keyman@next');
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'yarn',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('yarn global add @bitsquare/keyman@next');
expect(
formatCommand(
buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY })
)
).toBe('bun add --global @bitsquare/keyman@next');
});
it('accepts an explicit package name', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
packageName: '@bitsquare/nopy',
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/nopy@latest');
});
});
describe('formatUpdateNotice', () => {
const status = {
current: '0.5.0',
latest: '0.6.0',
channel: 'latest' as Channel,
registry: NPMJS_REGISTRY,
updateAvailable: true,
fromCache: false,
};
it('names both versions and the command', () => {
const notice = formatUpdateNotice(status, 'npm');
expect(notice).toContain('0.5.0 -> 0.6.0');
expect(notice).toContain('keyman self-update');
expect(notice).toContain('npm install --global @bitsquare/keyman@latest');
});
it('names a non-default channel', () => {
expect(formatUpdateNotice({ ...status, channel: 'main' }, 'npm')).toContain('(main)');
});
it('says nothing when there is no update', () => {
expect(formatUpdateNotice({ ...status, updateAvailable: false }, 'npm')).toBeNull();
});
it('says nothing when the latest version is unknown', () => {
expect(formatUpdateNotice({ ...status, latest: null }, 'npm')).toBeNull();
});
it('detects the package manager when none is given', () => {
expect(formatUpdateNotice(status)).toContain('@bitsquare/keyman@latest');
});
});
describe('updateNotice', () => {
it('returns a notice when an update exists', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY },
cachePath,
now: Date.parse('2026-07-29T12:00:00.000Z'),
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(notice).toContain('0.5.0 -> 0.6.0');
});
it('returns null when the check is disabled', async () => {
const fetchImpl = vi.fn();
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { KEYMAN_NO_UPDATE_CHECK: '1' },
cachePath,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(notice).toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('returns null rather than throwing when everything fails', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: {},
cachePath,
run: async () => {
throw new Error('no npm');
},
fetchImpl: (async () => {
throw new Error('offline');
}) as unknown as typeof fetch,
});
expect(notice).toBeNull();
});
});
describe('selfUpdate', () => {
const base = {
currentVersion: '0.5.0',
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY } as NodeJS.ProcessEnv,
packageManager: 'npm' as const,
};
it('runs the install when a newer version exists', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn,
});
expect(result.ran).toBe(true);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--global', '@bitsquare/keyman@latest']);
});
it('does nothing when already up to date', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
spawn,
});
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
it('reinstalls when forced', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
force: true,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
spawn,
});
expect(result.ran).toBe(true);
});
it('reports the command without running it on a dry run', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
dryRun: true,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn,
});
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
expect(formatCommand(result.command)).toBe('npm install --global @bitsquare/keyman@latest');
});
it('ignores a fresh cache, because the user asked', async () => {
writeUpdateCache(
{
checkedAt: new Date().toISOString(),
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.5.0',
},
cachePath
);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn: async () => undefined,
});
expect(result.status.latest).toBe('0.6.0');
expect(result.ran).toBe(true);
});
it('follows an explicit channel and registry', async () => {
const result = await selfUpdate({
currentVersion: '0.5.0',
env: {},
packageManager: 'pnpm',
channel: 'main',
registry: GITEA,
cachePath,
fetchImpl: fakeFetch({ main: '0.5.0-main.20.gaaaaaaa' }),
spawn: async () => undefined,
});
expect(formatCommand(result.command)).toBe(
`pnpm add --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
);
});
it('does not run when the registry could not be reached', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: (async () => {
throw new Error('offline');
}) as unknown as typeof fetch,
spawn,
});
expect(result.status.latest).toBeNull();
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@bitsquare/nopy-cube",
"version": "1.0.0-alpha0",
"version": "0.5.0",
"description": "Authoring types for nopy cubes: the Manifest factory and the Cube contract.",
"keywords": [
"nopy",
+86 -25
View File
@@ -302,12 +302,12 @@ All three are unioned and scanned the same way. A directory is a cube when it ho
#### Cube packages
A cube package is an ordinary npm package that ships cube directories and points at them from its own `package.json`:
A cube package is an ordinary npm package that ships its cubes in a `cubes/` directory at its root. That is the whole contract — no nopy-specific `package.json` field is required. A bundle whose cubes live elsewhere (compiled into `dist/cubes`, say) overrides the location:
```json
{
"name": "@bitsquare/cubes-core",
"nopy": { "cubes": ["./cubes"] }
"name": "@acme/cubes-web",
"nopy": { "cubes": ["./dist/cubes"] }
}
```
@@ -321,7 +321,7 @@ pnpm add -D @bitsquare/cubes-core
{ "cubePackages": ["@bitsquare/cubes-core"] }
```
Naming a package is a statement that cubes are expected from it, so anything wrong is an error that aborts the run rather than a silent skip: the package is not installed, it declares no `nopy.cubes`, or an entry points at a directory that does not exist or lies outside the package.
Naming a package is a statement that cubes are expected from it, so anything wrong is an error that aborts the run rather than a silent skip: the package is not installed, it has neither a `cubes/` directory nor a `nopy.cubes` override, its `nopy.cubes` is malformed, or an entry points at a directory that does not exist or lies outside the package.
#### Ids are claimed globally
@@ -333,36 +333,97 @@ Writing cubes to publish is covered in [CUBE-BUNDLES.md](docs/CUBE-BUNDLES.md).
### Installation
This package is part of a yarn workspace monorepo. Install from the repository root:
```bash
# From repository root (/ansiblings)
yarn install
yarn workspace @bitsquare/nopy build
npm install -g @bitsquare/nopy
```
To use the `nopy` command globally, you can:
The cubes live in a separate bundle, installed into whichever project describes
your infrastructure and named in its `.nopyrc.json`:
1. **Use yarn workspace command**:
```bash
pnpm add -D @bitsquare/cubes-core
```
```bash
yarn workspace @bitsquare/nopy nopy
```
```json
{ "hosts": ["your-host"], "cubePackages": ["@bitsquare/cubes-core"] }
```
2. **Link the package globally**:
#### Channels
```bash
cd packages/nopy
npm link
# Now you can use 'nopy' from anywhere
nopy install
```
Three dist-tags are published, and the one you install from is the one you stay
on until you ask otherwise:
3. **Use via npm scripts** (from packages/nopy directory):
| Channel | What it is | Registry |
| -------- | ----------------------------------------- | ------------ |
| `latest` | the current release — the default | npmjs, Gitea |
| `next` | a prerelease (`0.6.0-rc.1`) | npmjs, Gitea |
| `main` | a snapshot of every commit on `main` | Gitea only |
```bash
yarn nopy
```
```bash
npm install -g @bitsquare/nopy # latest
npm install -g @bitsquare/nopy@next # prereleases
```
Snapshots come from the Gitea registry. Point the **scope** at it rather than
setting a bare `registry=`, because that registry serves `@bitsquare` packages
only and does not proxy npmjs — everything else must keep resolving from npmjs:
```bash
npm install -g @bitsquare/nopy@main \
--@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
```
Or, persistently, in `~/.npmrc`:
```ini
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
```
Reading from Gitea needs no token while the repository is public.
### Upgrading
```bash
nopy self-update
```
That checks the channel your installed version came from, on the registry your
npm config points at, and re-runs the package manager that installed you (npm,
pnpm, yarn or bun — detected from the install path). Options:
```bash
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
```
The plain package-manager equivalent works too. Prefer `@latest` over
`npm update -g`, which resolves against the range recorded at install time:
```bash
npm install -g @bitsquare/nopy@latest
```
Once a day, `nopy` checks its channel in the background and prints a one-line
hint to **stderr** when a newer version exists — never to stdout, so `--json`
and `--print-only` output stay clean. The answer is cached in
`~/.nopy/update-check.json`; a registry that is slow or unreachable is given
1.5 seconds and then ignored.
| Variable | Effect |
| ----------------------- | --------------------------------------------- |
| `NOPY_NO_UPDATE_CHECK=1`| disable the startup check (also off when `CI` is set) |
| `NOPY_REGISTRY` | check a specific registry |
| `NOPY_REGISTRY_TOKEN` | bearer token, for a private registry |
| `NOPY_PACKAGE_MANAGER` | force `npm`/`pnpm`/`yarn`/`bun` for the install |
### Running from a checkout
```bash
pnpm install
pnpm --filter @bitsquare/nopy run nopy # runs the CLI from source via tsx
```
### Basic Commands
+873 -398
View File
File diff suppressed because it is too large Load Diff
+26 -12
View File
@@ -22,13 +22,13 @@ The rest of this document is for writing one.
## What a bundle is
An ordinary npm package that ships cube directories and points at them from its
own `package.json`. There is no build step, no plugin API and no entry point —
nopy reads the directories off disk and imports each `manifest.mjs` directly.
An ordinary npm package that ships its cubes in a `cubes/` directory. There is no
build step, no plugin API and no entry point — nopy reads the directory off disk
and imports each `manifest.mjs` directly.
```
@acme/cubes-web
├── package.json nopy.cubes → ["./cubes"]
├── package.json no nopy block needed
├── README.md
└── cubes/
├── nginx/
@@ -50,7 +50,6 @@ special-cased.
"name": "@acme/cubes-web",
"version": "1.0.0",
"type": "module",
"nopy": { "cubes": ["./cubes"] },
"files": ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"],
"publishConfig": { "access": "public" },
"dependencies": {
@@ -60,10 +59,24 @@ special-cased.
}
```
**`nopy.cubes`** is the only field nopy requires. It is an array of directories,
relative to the package root, each scanned recursively for cubes. Several
entries are fine; a single `["./cubes"]` is the norm. Every entry must exist and
must stay inside the package — a path escaping the root is refused, not resolved.
**Nothing declares the cubes.** `cubes/` at the package root is the convention,
scanned recursively, and a bundle that follows it needs no nopy-specific field at
all. Naming the package in `cubePackages` is already the statement that cubes are
expected from it.
**`nopy.cubes`** overrides that, for the bundle whose cubes are somewhere else — a
package compiled from TypeScript sources into `dist/cubes`, say, or one shipping
two separate trees:
```json
"nopy": { "cubes": ["./dist/cubes", "./contrib"] }
```
It is an array of directories relative to the package root. Every entry must
exist and must stay inside the package — a path escaping the root is refused, not
resolved. Present-but-malformed (an empty array, a bare string, non-strings) is an
error rather than a fall back to the default: saying something that does not parse
is not the same as saying nothing.
**`type: "module"`** matters: manifests are ESM. Without it a `manifest.mjs` still
loads (the extension carries the day), but anything it imports relatively will
@@ -222,7 +235,7 @@ Nothing bundle-specific: `npm publish` (or `pnpm publish`) with a version bump.
Some things worth deciding once:
- **Version the bundle independently of nopy.** There is no compatibility check
between the two — the loader reads whatever `nopy.cubes` points at. Document
between the two — the loader scans whatever directories it finds. Document
the nopy version you test against in your README.
- **Renaming or removing an id is breaking.** It invalidates recorded sessions
and breaks any manifest listing it as a dependency, including manifests in
@@ -254,8 +267,9 @@ For how this repository releases its own packages, see
| Symptom | Cause |
| --- | --- |
| `Cube package 'X' is not installed (looked up from …)` | Not installed, or installed somewhere other than the config that named it. The path in the message is where the lookup started. |
| `Cube package 'X' declares no cubes` | Missing or malformed `nopy.cubes` in the package's `package.json`. It must be a non-empty array of strings. |
| `'./cubes' does not exist in` | The directory was not packed. Check `files` and `npm pack --dry-run`. |
| `Cube package 'X' has no cubes/ directory in …` | No `cubes/` at the package root and no `nopy.cubes` pointing elsewhere. Usually the directory was not packed — check `files` and `npm pack --dry-run`. |
| `"nopy": { "cubes": … } must be a non-empty array of strings` | The override is present but malformed. Fix it, or omit it entirely to use `./cubes`. |
| `'…' does not exist in …` | A `nopy.cubes` entry pointing at a directory the tarball does not contain. |
| `'…' points outside the package` | A `nopy.cubes` entry escaping the package root. Not allowed. |
| `Duplicate cube id 'X' from N sources:` | Two or more cubes claiming one id; the message lists each source. Rename one — there is no precedence rule to lean on. |
| `ERR_MODULE_NOT_FOUND` for `zod` or `@bitsquare/nopy-cube` | The bundle did not declare them as dependencies. The resolve-hook fallback covers loose local cubes, not published packages. |
+14 -7
View File
@@ -141,7 +141,6 @@ A cube bundle is an npm package with a `nopy` field:
"name": "@acme/cubes-net",
"version": "1.0.0",
"type": "module",
"nopy": { "cubes": ["./cubes"] },
"files": ["cubes", "README.md", "LICENSE"],
"keywords": ["nopy", "nopy-cubes", "pyinfra"],
"dependencies": {
@@ -154,10 +153,16 @@ A cube bundle is an npm package with a `nopy` field:
Rules:
- `nopy.cubes` — directories relative to the package root, scanned exactly like
`cubeDirs` entries. Required; a package listed in `cubePackages` without a
`nopy` field is an error, not a silent skip. Listing it means the user expects
cubes from it.
- **Cube location is a convention, `<root>/cubes`.** *(Amended after Phase 5;
originally `nopy.cubes` was a required field.)* The field bought nothing a
convention does not: it is not a discovery marker — naming the package in
`cubePackages` already is one — and it says nothing about whether the
directories were actually packed, which is the failure authors really hit.
`nopy.cubes` remains as an **override**, directories relative to the package
root, for the bundle whose cubes are elsewhere (`dist/cubes` after a build).
Absent means the default; present-and-malformed is an error rather than a fall
back. Finding no cube directory at all is still an error, not a silent skip:
listing a package means the user expects cubes from it.
- Both dependencies are **regular dependencies, not peers**, and both are
load-bearing: a manifest imports `Manifest` from `@bitsquare/nopy-cube` and `z`
from `zod`. `@bitsquare/nopy-cube` peer-depends on zod, so the bundle's copy is
@@ -255,7 +260,8 @@ Errors (each aborts the run, consistent with the existing `errors` contract):
- package not found on any candidate path
- `package.json` unparseable
- no `nopy.cubes`, or it is not a non-empty array of strings
- neither a `cubes/` directory nor a `nopy.cubes` override
- `nopy.cubes` present but not a non-empty array of strings
- a `nopy.cubes` entry escapes the package root, or does not exist
### Wiring
@@ -625,7 +631,8 @@ runner alike.
- resolves through a symlinked package directory (mimicking pnpm)
- resolves from the declaring config's directory, not `cwd`
- missing package → error naming the spec
- package without `nopy.cubes`error
- package without `nopy.cubes`falls back to `cubes/`
- package with neither → error; malformed `nopy.cubes` → error, no fall back
- `nopy.cubes` entry that does not exist, and one that escapes the root → errors
- last-wins dedupe when parent and child config both name a package
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@bitsquare/nopy",
"version": "1.0.0-alpha5",
"version": "0.5.0",
"description": "A system to simplify pyinfra script management and execution.",
"keywords": [
"pyinfra",
@@ -61,11 +61,13 @@
"execa": "^10.0.0",
"fuzzy": "^0.1.3",
"inquirer": "^14.0.2",
"semver": "^7.8.5",
"zod": "^4.4.3",
"zx": "^8.8.5"
},
"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",
+33 -9
View File
@@ -8,13 +8,23 @@ import { createRequire } from 'node:module';
import path from 'node:path';
import type { CubePackageRef } from '../nopy.config.js';
/**
* Where a bundle's cubes live when its `package.json` does not say otherwise.
*
* Convention over configuration: shipping `cubes/` at the package root needs no
* `nopy` block at all. `nopy.cubes` remains as an override for the bundle whose
* cubes sit somewhere else — one compiled from TypeScript into `dist/cubes`,
* say — so the escape hatch survives without every author paying for it.
*/
const DEFAULT_CUBE_DIRS = ['./cubes'];
/** An installed cube package, located and validated. */
export interface CubePackage {
/** The name it was requested under. */
name: string;
/** Absolute path to the package root. */
root: string;
/** Absolute paths to its cube directories, from `nopy.cubes`. */
/** Absolute paths to its cube directories, from `nopy.cubes` or the default. */
dirs: string[];
}
@@ -40,7 +50,8 @@ function findPackageRoot(ref: CubePackageRef): string | undefined {
}
/**
* Resolves every named package to its cube directories.
* Resolves every named package to its cube directories — {@link DEFAULT_CUBE_DIRS}
* unless its `package.json` overrides them with `nopy.cubes`.
*
* Anything wrong is an error rather than a silent skip: naming a package in
* `cubePackages` is a statement that cubes are expected from it, and errors
@@ -75,27 +86,40 @@ export function resolveCubePackages(refs: CubePackageRef[]): {
continue;
}
// Absent is the ordinary case and means the convention. Present-but-wrong
// is a different thing entirely — the author meant to say something and it
// did not parse — so it stays an error rather than falling back silently.
const declared = manifest.nopy?.cubes;
const defaulted = declared === undefined;
if (
!Array.isArray(declared) ||
declared.length === 0 ||
!declared.every((entry) => typeof entry === 'string')
!defaulted &&
(!Array.isArray(declared) ||
declared.length === 0 ||
!declared.every((entry) => typeof entry === 'string'))
) {
errors.push(
`Cube package '${ref.spec}' declares no cubes. ` +
`Expected "nopy": { "cubes": ["./cubes"] } in ${root}/package.json.`
`Cube package '${ref.spec}': "nopy": { "cubes": … } in ${root}/package.json ` +
`must be a non-empty array of strings. Omit it to use the default, ./cubes.`
);
continue;
}
const dirs: string[] = [];
for (const entry of declared as string[]) {
for (const entry of defaulted ? DEFAULT_CUBE_DIRS : (declared as string[])) {
const dir = path.resolve(root, entry);
if (dir !== root && !dir.startsWith(root + path.sep)) {
errors.push(`Cube package '${ref.spec}': '${entry}' points outside the package.`);
} else if (!fs.existsSync(dir)) {
errors.push(`Cube package '${ref.spec}': '${entry}' does not exist in ${root}.`);
// Naming the entry would be misleading when nobody wrote one; say what
// was looked for and what would change it instead.
errors.push(
defaulted
? `Cube package '${ref.spec}' has no cubes/ directory in ${root}, and its ` +
`package.json declares no "nopy": { "cubes": [...] } pointing elsewhere.`
: `Cube package '${ref.spec}': '${entry}' does not exist in ${root}.`
);
} else {
dirs.push(dir);
}
+38
View File
@@ -36,6 +36,15 @@ export {
outputExecutionPlan,
summarizeResults,
} from './nopy.executor.js';
// Graceful exit
export {
CANCELLED_EXIT_CODE,
exitWithFarewell,
FAREWELL,
installGracefulExit,
isCancellation,
restoreTerminal,
} from './nopy.exit.js';
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
// History management
export {
@@ -66,6 +75,35 @@ export {
export type { AuthSession, CubeSession, NopySession } from './nopy.session.js';
// Session management
export { createSession, listSessions, loadSession, saveSession } from './nopy.session.js';
export type {
Channel,
CommandRunner,
PackageManager,
SelfUpdateResult,
UpdateCache,
UpdateStatus,
} from './nopy.update.js';
// Update checking and self-update
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 './nopy.update.js';
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
// Workflow
export {
+78 -2
View File
@@ -8,6 +8,7 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { loadConfig } from './nopy.config.js';
import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js';
import {
clearHistory,
formatHistoryList,
@@ -16,14 +17,41 @@ import {
listHistory,
} from './nopy.history.js';
import { nopy } from './nopy.main.js';
import type { Channel } from './nopy.update.js';
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
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;
/**
* Prints the update hint to stderr, so it never lands in `--json` output or in
* a `--print-only` command list being piped somewhere.
*/
async function printUpdateNotice(): Promise<void> {
const notice = await updateNotice({ currentVersion: version });
if (notice) {
console.error(`\n${notice}\n`);
}
}
// Before anything can open a prompt: a cancelled TUI leaves through
// nopy.exit, not through node's default unhandled-rejection trace.
installGracefulExit();
const program = new Command();
program
.name('nopy')
.version(version)
.version(versionLabel)
.description('A CLI tool for pyinfra script management and execution.')
.addHelpText(
'after',
@@ -63,6 +91,8 @@ program
.option('-j, --json', 'Output results as JSON')
.option('--no-history', 'Do not save this session to history')
.action(async (options) => {
await printUpdateNotice();
// Loaded lazily so that --help/--version work outside a configured project.
const execConfig = loadConfig().execution ?? {};
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
@@ -109,6 +139,11 @@ program
process.exit(1);
}
} catch (error) {
// A prompt the user backed out of is not a failed run: inquirer rejects
// cleanly, so unlike the enquirer case this arrives here rather than at
// the process-level handler.
if (isCancellation(error)) exitWithFarewell();
if (options.json) {
console.log(
JSON.stringify(
@@ -150,4 +185,45 @@ program
console.log('Session history cleared.');
});
program
.command('self-update')
.description('Update nopy to the newest version on your channel')
.alias('upgrade')
.option('-n, --dry-run', 'Show the install command without running it')
.option('-f, --force', 'Reinstall even when already up to date')
.option('--channel <tag>', 'Check a specific channel (latest, next, main)')
.option('--registry <url>', 'Install from a specific registry')
.action(async (options) => {
try {
const result = await selfUpdate({
currentVersion: version,
channel: options.channel as Channel | undefined,
registry: options.registry,
dryRun: options.dryRun,
force: options.force,
});
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 (options.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);
}
});
program.parse();
+132
View File
@@ -0,0 +1,132 @@
/**
* What happens when the user walks out of the TUI instead of finishing it.
* @module nopy.exit
*/
/** Parting words. Printed whenever a run ends because the user asked it to. */
export const FAREWELL = 'Bye Bye Honeypie';
/** Conventional exit code for "terminated by SIGINT" — 128 + 2. */
export const CANCELLED_EXIT_CODE = 130;
/** ETX: the byte a raw-mode terminal delivers for Ctrl-C. */
const ETX = '\x03';
/** Undoes `ansi.cursor.hide()`, which every enquirer prompt writes on start. */
const SHOW_CURSOR = '\x1B[?25h';
/**
* Error names the two prompt libraries use for "the user called it off".
*
* `ExitPromptError` is what `@inquirer/core` rejects with on Ctrl-C;
* `CancelPromptError` is the same thing reached from outside the prompt.
*/
const CANCEL_ERROR_NAMES = new Set(['ExitPromptError', 'CancelPromptError']);
/**
* Whether a thrown value is the user cancelling rather than something failing.
*
* Three shapes, one per way out of a prompt:
*
* - `ERR_USE_AFTER_CLOSE` — enquirer's teardown exploding. Ctrl-C in raw mode
* reaches *both* node's readline, which closes the interface because it has
* no `SIGINT` listener, and enquirer's own keypress queue, which then cancels
* the prompt and calls `rl.pause()` on the interface node has already closed.
* Node >= 22 throws there rather than ignoring it. The throw happens inside
* `Prompt.close()`, i.e. *before* `emit('cancel')`, so `prompt.run()` never
* settles and the `try/catch` around it in `nopy.prompts` never runs — the
* rejection surfaces with nothing awaiting it, which is why this has to be
* caught at the process level.
* - `ExitPromptError` — inquirer, which does reject cleanly and whose rejection
* travels up the normal call chain.
* - a bare `''` or an ETX byte — enquirer rejecting a cancelled prompt with the
* keypress that cancelled it, on the runs where the teardown does not throw.
*/
export function isCancellation(error: unknown): boolean {
if (error === '' || error === ETX) return true;
if (typeof error !== 'object' || error === null) return false;
const { name, code } = error as { name?: unknown; code?: unknown };
return (
code === 'ERR_USE_AFTER_CLOSE' || (typeof name === 'string' && CANCEL_ERROR_NAMES.has(name))
);
}
/**
* Puts the terminal back the way it was found.
*
* A prompt owns the terminal while it runs: stdin is in raw mode and the cursor
* is hidden. Exiting from under it leaves the shell with no cursor and no echo,
* so this runs on every abnormal exit, cancelled or crashed. Best-effort by
* design — a destroyed stdin throws on `setRawMode`, and a failure to tidy up
* must not replace the message explaining why we are leaving.
*/
export function restoreTerminal(): void {
try {
if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false);
if (process.stdout.isTTY) process.stdout.write(SHOW_CURSOR);
} catch {
// Nothing useful to do about a terminal that will not be restored.
}
}
/**
* Says goodbye and leaves.
*
* The farewell goes to **stderr**, for the same reason the update hint does:
* `--json` and `--print-only` stay machine-readable no matter how the run ends.
*
* `process.exit` rather than letting the loop drain, because the prompt that
* was cancelled is still holding stdin — after the teardown above threw, its
* promise is pending forever and nothing else will end the process.
*/
export function exitWithFarewell(code: number = CANCELLED_EXIT_CODE): never {
restoreTerminal();
process.stderr.write(`\n${FAREWELL}\n`);
return process.exit(code) as never;
}
/**
* Reports a genuine crash, having first handed the terminal back.
*
* Deliberately as loud as node's own default — the stack, not a summary. The
* only thing being taken over is *when* it prints, so that {@link
* restoreTerminal} gets to run first.
*/
function reportFatal(error: unknown): never {
restoreTerminal();
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
return process.exit(1) as never;
}
/**
* Installs the process-level handlers that turn a Ctrl-C into {@link FAREWELL}.
*
* Two entry points, because Ctrl-C arrives differently depending on who owns
* the terminal. During a prompt, stdin is in raw mode: the process gets no
* `SIGINT` at all, the keypress goes to the prompt library, and the failure
* comes back as an unhandled rejection. Everywhere else — cube loading, a
* pyinfra run — the signal arrives normally.
*
* Returns a disposer, which the CLI ignores and the tests do not.
*/
export function installGracefulExit(): () => void {
const onSignal = () => exitWithFarewell();
const onFatal = (reason: unknown) => {
if (isCancellation(reason)) {
exitWithFarewell();
return;
}
reportFatal(reason);
};
process.on('SIGINT', onSignal);
process.on('uncaughtException', onFatal);
process.on('unhandledRejection', onFatal);
return () => {
process.off('SIGINT', onSignal);
process.off('uncaughtException', onFatal);
process.off('unhandledRejection', onFatal);
};
}
+4 -2
View File
@@ -83,7 +83,9 @@ export async function AuthSelection(useAuthKey?: boolean): Promise<{
if (useAuthKey) return { authMethod: 'ssh-key' };
const answers = await inquirer.prompt([
{
type: 'list',
// `select`, not `list`: inquirer 14 dropped the legacy name and rejects
// an unknown type outright.
type: 'select',
name: 'authMethod',
message: 'Select authentication method:',
choices: ['ssh-key', 'password'],
@@ -118,7 +120,7 @@ export async function PasswordSelection(username: string): Promise<string> {
export async function HostSelection(hosts: string[]): Promise<string> {
const selectedHost = await inquirer.prompt([
{
type: 'list',
type: 'select',
name: 'host',
message: 'Select host from inventory',
choices: ['docker', 'vagrant', ...hosts, 'custom'],
+513
View File
@@ -0,0 +1,513 @@
/**
* Update checking and self-update for the nopy CLI
*
* The channel a user is on is never stored anywhere — it is derived from the
* version they are running, which is the one piece of state that is always
* correct. A `-main.` prerelease came from the snapshot workflow, any other
* prerelease came out under `next`, and a clean version came out under
* `latest`. Upgrading therefore keeps you on the channel you installed from
* instead of silently moving you to a different one.
*
* @module nopy.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/nopy';
/** 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 = '.nopy';
/** 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.
*
* Short on purpose: this runs before the first prompt, so a slow or
* unreachable registry has to cost a moment, not a session.
*/
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 });
// An unparseable version is treated as a release: the worst case is that a
// check goes to `latest` and finds nothing newer.
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.
*
* `NOPY_REGISTRY` wins, then npm's own scoped-registry config — asking npm is
* what makes a global install from Gitea check Gitea for its updates without
* anything else being configured — and npmjs is the fallback.
*
* @returns a registry URL in trailing-slash form
*/
export async function resolveRegistry(
options: { env?: NodeJS.ProcessEnv; run?: CommandRunner } = {}
): Promise<string> {
const env = options.env ?? process.env;
const override = env.NOPY_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. Neither is worth failing a
// deployment over.
}
return NPMJS_REGISTRY;
}
/**
* Reads the version a dist-tag points at, straight from the registry.
*
* Deliberately a plain `fetch` of the packument rather than shelling out to
* `npm view`: it is one request, it honours a timeout, and it cannot be slowed
* down by npm's own startup.
*
* @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> = {
// The abbreviated packument where the registry supports it; Gitea ignores
// this and sends the full document, which parses the same.
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;
// A hand-edited or half-written file must not be trusted into the compare.
return typeof parsed?.checkedAt === 'string' ? parsed : null;
} catch {
return null;
}
}
/**
* Writes the update-check cache. Best effort — a read-only home directory
* costs a network check per run, 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.
*
* `NOPY_NO_UPDATE_CHECK` is the explicit opt-out; `CI` covers the case nobody
* remembers to opt out of.
*/
export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
const flag = env.NOPY_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; otherwise asks the registry and refreshes the cache. A failed
* lookup falls back to whatever the cache last saw, so a flaky network degrades
* to a stale answer rather than no answer.
*/
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);
// A cache entry for a different channel or registry answers a different
// question, so it is never fresh for this one.
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.NOPY_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 {
// Offline, timed out, or the registry returned something unparseable.
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.
*
* The install path is the evidence: pnpm and bun keep globals under their own
* directory, npm does not.
*/
export function detectPackageManager(
options: { execPath?: string; env?: NodeJS.ProcessEnv } = {}
): PackageManager {
const env = options.env ?? process.env;
const override = env.NOPY_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`.
* That is load-bearing for Gitea: its npm registry serves `@bitsquare`
* packages and does not proxy npmjs, so a global `--registry` would send
* `commander`, `execa` and every other 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 one-line 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 "nopy 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, because it
* sits in front of every command the user actually asked for.
*/
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 };
}
+34 -5
View File
@@ -108,19 +108,48 @@ describe('resolveCubePackages', () => {
expect(errors[0]).toMatch(/cannot read/);
});
it('reports a package that declares no cubes', () => {
install(tmpDir, 'plain', {});
it('falls back to cubes/ when the package declares nothing', () => {
// The convention. A bundle that ships cubes/ at its root needs no `nopy`
// block at all, and one with an unrelated `nopy` block still gets it.
const plain = install(tmpDir, 'plain', {});
const other = install(tmpDir, 'other', { nopy: { somethingElse: true } });
const { packages, errors } = resolveCubePackages(
['plain', 'other'].map((spec) => ({ spec, from: tmpDir }))
);
expect(errors).toEqual([]);
expect(packages.map((pkg) => pkg.dirs)).toEqual([
[path.join(plain, 'cubes')],
[path.join(other, 'cubes')],
]);
});
it('reports a package with neither a declaration nor a cubes/ directory', () => {
install(tmpDir, 'bare', {}, []);
const { packages, errors } = resolveCubePackages([{ spec: 'bare', from: tmpDir }]);
expect(packages).toEqual([]);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/has no cubes\/ directory/);
expect(errors[0]).toMatch(/declares no "nopy"/);
});
it('reports a malformed declaration instead of falling back to the default', () => {
// Each of these ships a usable cubes/ directory. Saying something that does
// not parse is not the same as saying nothing, so none of them resolve.
install(tmpDir, 'empty', { nopy: { cubes: [] } });
install(tmpDir, 'wrong-type', { nopy: { cubes: 'cubes' } });
install(tmpDir, 'not-strings', { nopy: { cubes: [1] } });
const { packages, errors } = resolveCubePackages(
['plain', 'empty', 'wrong-type', 'not-strings'].map((spec) => ({ spec, from: tmpDir }))
['empty', 'wrong-type', 'not-strings'].map((spec) => ({ spec, from: tmpDir }))
);
expect(packages).toEqual([]);
expect(errors).toHaveLength(4);
for (const error of errors) expect(error).toMatch(/declares no cubes/);
expect(errors).toHaveLength(3);
for (const error of errors) expect(error).toMatch(/must be a non-empty array of strings/);
});
it('reports a cube directory that does not exist', () => {
+218
View File
@@ -0,0 +1,218 @@
/**
* Tests for nopy.exit.
*
* The handlers are invoked by calling the listener `installGracefulExit`
* registered, not by `process.emit()`-ing the event: vitest listens for
* `unhandledRejection` and `uncaughtException` itself and would report a
* synthetic one as a failure of the test file.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CANCELLED_EXIT_CODE,
exitWithFarewell,
FAREWELL,
installGracefulExit,
isCancellation,
restoreTerminal,
} from '../src/nopy.exit.js';
let exit: ReturnType<typeof vi.spyOn>;
let stderr: ReturnType<typeof vi.spyOn>;
/** Pretends stdin/stdout are the terminal a prompt would have taken over. */
function fakeTerminal(opts: { isTTY: boolean; isRaw?: boolean }) {
const setRawMode = vi.fn();
const original = {
isTTY: process.stdin.isTTY,
isRaw: process.stdin.isRaw,
setRawMode: process.stdin.setRawMode,
outTTY: process.stdout.isTTY,
};
Object.defineProperty(process.stdin, 'isTTY', { value: opts.isTTY, configurable: true });
Object.defineProperty(process.stdin, 'isRaw', { value: opts.isRaw ?? false, configurable: true });
Object.defineProperty(process.stdin, 'setRawMode', { value: setRawMode, configurable: true });
Object.defineProperty(process.stdout, 'isTTY', { value: opts.isTTY, configurable: true });
const restore = () => {
Object.defineProperty(process.stdin, 'isTTY', { value: original.isTTY, configurable: true });
Object.defineProperty(process.stdin, 'isRaw', { value: original.isRaw, configurable: true });
Object.defineProperty(process.stdin, 'setRawMode', {
value: original.setRawMode,
configurable: true,
});
Object.defineProperty(process.stdout, 'isTTY', { value: original.outTTY, configurable: true });
};
return { setRawMode, restore };
}
/** The listener `installGracefulExit` most recently added for `event`. */
const lastListener = (event: string) =>
process.listeners(event as 'SIGINT').at(-1) as (reason?: unknown) => void;
beforeEach(() => {
exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('isCancellation', () => {
it('recognises enquirer tearing down a readline node already closed', () => {
const err = Object.assign(new Error('readline was closed'), { code: 'ERR_USE_AFTER_CLOSE' });
expect(isCancellation(err)).toBe(true);
});
it('recognises the inquirer cancellations', () => {
const exitPrompt = Object.assign(new Error('User force closed the prompt'), {
name: 'ExitPromptError',
});
const cancelPrompt = Object.assign(new Error('Prompt was canceled'), {
name: 'CancelPromptError',
});
expect(isCancellation(exitPrompt)).toBe(true);
expect(isCancellation(cancelPrompt)).toBe(true);
});
it('recognises the bare values enquirer rejects a cancelled prompt with', () => {
expect(isCancellation('')).toBe(true);
expect(isCancellation('\x03')).toBe(true);
});
it('leaves a genuine failure alone', () => {
expect(isCancellation(new Error('pyinfra exited 1'))).toBe(false);
expect(isCancellation(Object.assign(new Error('nope'), { code: 'ENOENT' }))).toBe(false);
expect(isCancellation('boom')).toBe(false);
expect(isCancellation(undefined)).toBe(false);
expect(isCancellation(null)).toBe(false);
expect(isCancellation(7)).toBe(false);
});
});
describe('restoreTerminal', () => {
it('leaves raw mode and shows the cursor again', () => {
const terminal = fakeTerminal({ isTTY: true, isRaw: true });
restoreTerminal();
expect(terminal.setRawMode).toHaveBeenCalledWith(false);
expect(process.stdout.write).toHaveBeenCalledWith('\x1B[?25h');
terminal.restore();
});
it('touches nothing when the output is not a terminal', () => {
const terminal = fakeTerminal({ isTTY: false });
restoreTerminal();
expect(terminal.setRawMode).not.toHaveBeenCalled();
expect(process.stdout.write).not.toHaveBeenCalled();
terminal.restore();
});
it('survives a stdin that refuses to leave raw mode', () => {
const terminal = fakeTerminal({ isTTY: true, isRaw: true });
terminal.setRawMode.mockImplementation(() => {
throw new Error('stdin destroyed');
});
expect(() => restoreTerminal()).not.toThrow();
terminal.restore();
});
});
describe('exitWithFarewell', () => {
it('says goodbye on stderr and exits 130', () => {
exitWithFarewell();
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
});
it('accepts a different exit code', () => {
exitWithFarewell(0);
expect(exit).toHaveBeenCalledWith(0);
});
});
describe('installGracefulExit', () => {
let dispose: () => void;
beforeEach(() => {
dispose = installGracefulExit();
});
afterEach(() => {
dispose();
});
it('says goodbye on SIGINT', () => {
lastListener('SIGINT')();
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
});
it('says goodbye for the rejection nothing is awaiting', () => {
// The shape of the real crash: enquirer's Ctrl-C teardown, which leaves
// `prompt.run()` pending forever, so no `catch` in nopy.prompts sees it.
lastListener('unhandledRejection')(
Object.assign(new Error('readline was closed'), { code: 'ERR_USE_AFTER_CLOSE' })
);
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
});
it('still reports a real crash, loudly, and exits 1', () => {
const boom = new Error('everything is on fire');
lastListener('uncaughtException')(boom);
expect(stderr).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(boom.stack);
expect(exit).toHaveBeenCalledWith(1);
});
it('reports a thrown non-error too', () => {
lastListener('uncaughtException')('just a string');
expect(console.error).toHaveBeenCalledWith('just a string');
expect(exit).toHaveBeenCalledWith(1);
});
it('reports an error with no stack by its message', () => {
const stackless = new Error('no stack here');
stackless.stack = undefined;
lastListener('uncaughtException')(stackless);
expect(console.error).toHaveBeenCalledWith('no stack here');
});
it('hands the process back on dispose', () => {
const before = {
SIGINT: process.listenerCount('SIGINT'),
uncaughtException: process.listenerCount('uncaughtException'),
unhandledRejection: process.listenerCount('unhandledRejection'),
};
dispose();
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT - 1);
expect(process.listenerCount('uncaughtException')).toBe(before.uncaughtException - 1);
expect(process.listenerCount('unhandledRejection')).toBe(before.unhandledRejection - 1);
// The afterEach disposer runs a second time; make it a no-op.
dispose = () => {};
});
});
+26
View File
@@ -218,6 +218,32 @@ describe('HostSelection', () => {
});
});
describe('question types', () => {
it('declares only types the installed inquirer actually ships', async () => {
// Checked against the real module, not the mock: `list` was accepted for
// years and inquirer 14 dropped it, which took out host selection entirely
// — a failure no amount of mocked prompting can see.
const actual = await vi.importActual<typeof import('inquirer')>('inquirer');
const supported = Object.keys(actual.createPromptModule().prompts);
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
await HostSelection(['web-1']);
inquirerPrompt.mockResolvedValue({ authMethod: 'password', username: 'u', password: 'p' });
await AuthSelection(false);
inquirerPrompt.mockResolvedValue({ password: 'p' });
await PasswordSelection('deploy');
const declared = new Set(
inquirerPrompt.mock.calls.flatMap(([asked]: [Record<string, any>[]]) =>
asked.map((q) => q.type ?? 'input')
)
);
expect(declared.size).toBeGreaterThan(0);
expect(supported).toEqual(expect.arrayContaining([...declared]));
});
});
describe('VariableAssignment', () => {
const schema = z.object({
port: z.number().default(8080).describe('Listen port'),
+865
View File
@@ -0,0 +1,865 @@
/**
* Tests for nopy.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/nopy.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(), 'nopy-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 NOPY_REGISTRY override', async () => {
const run = vi.fn();
const registry = await resolveRegistry({
env: { NOPY_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: { NOPY_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%2Fnopy');
});
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/.nopy/update-check.json');
});
});
describe('isUpdateCheckDisabled', () => {
it('is off by default', () => {
expect(isUpdateCheckDisabled({})).toBe(false);
});
it('honours NOPY_NO_UPDATE_CHECK', () => {
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: '1' })).toBe(true);
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: 'yes' })).toBe(true);
});
it('treats 0 and false as not disabled', () => {
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: '0' })).toBe(false);
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: 'false' })).toBe(false);
expect(isUpdateCheckDisabled({ NOPY_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: { NOPY_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: { NOPY_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' })
).toBe('pnpm');
expect(
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' })
).toBe('yarn');
expect(
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' })
).toBe('bun');
expect(
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' })
).toBe('npm');
});
it('ignores an unrecognised override', () => {
expect(
detectPackageManager({ env: { NOPY_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/nopy',
})
).toBe('pnpm');
});
it('recognises a bun global install', () => {
expect(
detectPackageManager({ env: {}, execPath: '/Users/x/.bun/install/global/node_modules/nopy' })
).toBe('bun');
});
it('recognises a yarn global install', () => {
expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/nopy' })).toBe('yarn');
});
it('defaults to npm', () => {
expect(
detectPackageManager({
env: {},
execPath: '/usr/local/lib/node_modules/@bitsquare/nopy/dist/nopy.cli.js',
})
).toBe('npm');
});
it('handles a windows-style path and an empty path', () => {
expect(
detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\nopy.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/nopy@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/nopy@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/nopy@latest']);
});
it('builds for pnpm, yarn and bun', () => {
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'pnpm',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('pnpm add --global @bitsquare/nopy@next');
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'yarn',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('yarn global add @bitsquare/nopy@next');
expect(
formatCommand(
buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY })
)
).toBe('bun add --global @bitsquare/nopy@next');
});
it('accepts an explicit package name', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
packageName: '@bitsquare/keyman',
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@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('nopy self-update');
expect(notice).toContain('npm install --global @bitsquare/nopy@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/nopy@latest');
});
});
describe('updateNotice', () => {
it('returns a notice when an update exists', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { NOPY_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: { NOPY_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: { NOPY_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/nopy@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/nopy@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/nopy@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();
});
});
+17
View File
@@ -50,6 +50,9 @@ importers:
inquirer:
specifier: ^14.0.2
version: 14.0.2(@types/node@26.1.1)
semver:
specifier: ^7.8.5
version: 7.8.5
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -57,6 +60,9 @@ importers:
'@types/node':
specifier: ^26.1.1
version: 26.1.1
'@types/semver':
specifier: ^7.7.1
version: 7.7.1
'@vitest/coverage-v8':
specifier: ^4.1.10
version: 4.1.10(vitest@4.1.10)
@@ -93,6 +99,9 @@ importers:
inquirer:
specifier: ^14.0.2
version: 14.0.2(@types/node@26.1.1)
semver:
specifier: ^7.8.5
version: 7.8.5
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -103,6 +112,9 @@ importers:
'@types/node':
specifier: ^26.1.1
version: 26.1.1
'@types/semver':
specifier: ^7.7.1
version: 7.7.1
'@vitest/coverage-v8':
specifier: ^4.1.10
version: 4.1.10(vitest@4.1.10)
@@ -658,6 +670,9 @@ packages:
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@typescript/typescript-aix-ppc64@7.0.2':
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
engines: {node: '>=16.20.0'}
@@ -1640,6 +1655,8 @@ snapshots:
dependencies:
undici-types: 8.3.0
'@types/semver@7.7.1': {}
'@typescript/typescript-aix-ppc64@7.0.2':
optional: true
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* Reports what each publishable package looks like on Gitea versus npmjs.
*
* The two registries are deliberately not equivalent: `publish-snapshot.yml`
* pushes a `main` snapshot to Gitea on every push to `main`, while `release.yml`
* publishes a tagged version to both. Gitea is therefore a superset, and the
* interesting question before a release is which versions exist *only* there —
* those are the ones that can still be tested and un-published.
*
* It also flags a missing `latest` dist-tag, which is worth a line of output
* because npm reports it by printing nothing and exiting 0. `npm view <name>`
* against a registry with no `latest` looks identical to a working lookup of an
* empty package, which is how the whole 1.0.0-alphaN dist-tag problem stayed
* invisible for as long as it did.
*
* node scripts/registry-status.mjs
* node scripts/registry-status.mjs --json
* node scripts/registry-status.mjs --registry http://localhost:4873/
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const PACKAGES_DIR = 'packages';
const SCOPE = '@bitsquare';
const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
const FALLBACK_GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
const TIMEOUT_MS = 15_000;
const argv = process.argv.slice(2);
const asJson = argv.includes('--json');
const flag = (name, fallback) => {
const at = argv.indexOf(name);
return at !== -1 && argv[at + 1] ? argv[at + 1] : fallback;
};
const withSlash = (url) => (url.endsWith('/') ? url : `${url}/`);
/**
* The scope mapping in the repo's own `.npmrc` is the single source of truth,
* so this never drifts from what an actual install would do.
*/
function resolveGiteaRegistry() {
try {
const out = execFileSync('npm', ['config', 'get', `${SCOPE}:registry`], {
encoding: 'utf-8',
timeout: 10_000,
}).trim();
// npm prints the literal string "undefined" for an unset key.
if (out && out !== 'undefined' && out !== 'null') return withSlash(out);
} catch {
// npm missing or unreadable config — the fallback is still correct.
}
return FALLBACK_GITEA;
}
const GITEA_REGISTRY = withSlash(flag('--registry', resolveGiteaRegistry()));
/** Fetches a packument, normalising every failure into a shape the report can print. */
async function packument(registry, name) {
let response;
try {
response = await fetch(`${registry}${encodeURIComponent(name)}`, {
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (error) {
return { reachable: false, note: error.name === 'TimeoutError' ? 'timed out' : 'unreachable' };
}
if (response.status === 404) return { reachable: true, published: false };
if (!response.ok) return { reachable: true, note: `HTTP ${response.status}` };
let body;
try {
body = await response.json();
} catch {
return { reachable: true, note: 'unparseable response' };
}
// Gitea answers a missing package with 200 + {"error": "Not found"} rather
// than a 404, so the body has to be checked as well as the status.
if (body.error) return { reachable: true, published: false };
return {
reachable: true,
published: true,
tags: body['dist-tags'] ?? {},
versions: Object.keys(body.versions ?? {}),
};
}
const packages = fs
.readdirSync(PACKAGES_DIR)
.map((name) => path.join(PACKAGES_DIR, name))
.filter((dir) => fs.existsSync(path.join(dir, 'package.json')))
.map((dir) => ({ dir, manifest: JSON.parse(fs.readFileSync(path.join(dir, 'package.json'))) }))
.filter(({ manifest }) => !manifest.private)
.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
const report = await Promise.all(
packages.map(async ({ dir, manifest }) => {
const [gitea, npmjs] = await Promise.all([
packument(GITEA_REGISTRY, manifest.name),
packument(NPMJS_REGISTRY, manifest.name),
]);
const npmjsVersions = new Set(npmjs.versions ?? []);
const giteaOnly = (gitea.versions ?? []).filter((v) => !npmjsVersions.has(v));
return {
name: manifest.name,
dir,
local: manifest.version,
gitea,
npmjs,
giteaOnly,
localPublished: {
gitea: (gitea.versions ?? []).includes(manifest.version),
npmjs: npmjsVersions.has(manifest.version),
},
};
})
);
if (asJson) {
console.log(
JSON.stringify(
{ registries: { gitea: GITEA_REGISTRY, npmjs: NPMJS_REGISTRY }, report },
null,
2
)
);
process.exit(0);
}
const describe = (result) => {
if (!result.reachable) return `(${result.note})`;
if (result.note) return `(${result.note})`;
if (!result.published) return '(not published)';
const tags = Object.entries(result.tags)
.map(([tag, version]) => `${tag}=${version}`)
.sort()
.join(', ');
const count = `${result.versions.length} version${result.versions.length === 1 ? '' : 's'}`;
return `${count}${tags ? `${tags}` : ' — no dist-tags'}`;
};
console.log('Registry status\n');
console.log(` gitea ${GITEA_REGISTRY}`);
console.log(` npmjs ${NPMJS_REGISTRY}`);
for (const entry of report) {
console.log(`\n${entry.name} (local ${entry.local})`);
console.log(` gitea ${describe(entry.gitea)}`);
console.log(` npmjs ${describe(entry.npmjs)}`);
if (entry.giteaOnly.length > 0) {
console.log(` gitea only ${entry.giteaOnly.join(', ')}`);
}
const notes = [];
if (entry.gitea.published && !entry.gitea.tags.latest) {
notes.push('no `latest` on gitea — an untagged install resolves to nothing, silently');
}
if (!entry.localPublished.gitea && !entry.localPublished.npmjs) {
notes.push(`local ${entry.local} is on neither registry`);
} else if (entry.localPublished.gitea && !entry.localPublished.npmjs) {
notes.push(`local ${entry.local} is testable on gitea, not yet released to npmjs`);
}
for (const note of notes) console.log(` ! ${note}`);
}
const testable = report.filter((entry) => entry.giteaOnly.length > 0);
console.log(
testable.length > 0
? `\n${testable.length} of ${report.length} packages have versions on gitea that npmjs does not.` +
'\nInstall one with an explicit tag, e.g. `npm i -g @bitsquare/nopy@main` — see README.PUBLISH.md.'
: '\nEvery version on gitea is also on npmjs.'
);
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/**
* Installs a published snapshot the way a stranger would, into a throwaway
* project, and runs it.
*
* This is the rehearsal that the local `pnpm pack` check cannot be: it goes to
* the real registry, resolves the real `@bitsquare/nopy-cube` version that
* `pnpm publish` baked into the tarball, and puts a real `nopy` binary on disk.
* A tarball that installs here is one a user can install.
*
* node scripts/try-snapshot.mjs # @main from Gitea
* node scripts/try-snapshot.mjs --tag latest # a release, still from Gitea
* node scripts/try-snapshot.mjs --registry https://registry.npmjs.org/
* node scripts/try-snapshot.mjs --keep # leave the directory behind
*
* `npm` is used rather than `pnpm` on purpose: npm is the client that rejects a
* leaked `workspace:` range, so a clean install here is the stronger proof.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const SCOPE = '@bitsquare';
const DEFAULT_REGISTRY = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
const CLI_PACKAGE = '@bitsquare/nopy';
const BUNDLE_PACKAGE = '@bitsquare/cubes-core';
const args = process.argv.slice(2);
/** Reads `--flag value`, falling back to a default */
const flag = (name, fallback) => {
const index = args.indexOf(name);
return index === -1 ? fallback : args[index + 1];
};
const tag = flag('--tag', 'main');
const registry = flag('--registry', DEFAULT_REGISTRY).replace(/\/?$/, '/');
const keep = args.includes('--keep');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-snapshot-'));
/** Runs a command in the throwaway project, streaming its output */
const run = (file, argv) =>
execFileSync(file, argv, { cwd: dir, stdio: 'inherit', env: process.env });
/** Runs a command and captures stdout */
const capture = (file, argv) =>
execFileSync(file, argv, { cwd: dir, encoding: 'utf-8', env: process.env }).trim();
/**
* Runs a command with stdin closed and returns everything it printed,
* regardless of exit status — used for the interactive path, which is expected
* to bail out once it finds no terminal to prompt at.
*/
const probe = (file, argv) => {
try {
return execFileSync(file, argv, {
cwd: dir,
encoding: 'utf-8',
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (error) {
return `${error.stdout ?? ''}${error.stderr ?? ''}`;
}
};
let failed = false;
try {
console.log(`Registry: ${registry}`);
console.log(`Channel: ${tag}`);
console.log(`Project: ${dir}\n`);
// Scoped, never a bare `registry=`: the Gitea registry serves @bitsquare and
// does not proxy npmjs, so commander/execa/zod must keep resolving there.
fs.writeFileSync(path.join(dir, '.npmrc'), `${SCOPE}:registry=${registry}\n`, 'utf-8');
fs.writeFileSync(
path.join(dir, 'package.json'),
`${JSON.stringify({ name: 'nopy-snapshot-check', version: '0.0.0', private: true }, null, 2)}\n`,
'utf-8'
);
console.log('--- install ---');
run('npm', [
'install',
'--no-audit',
'--no-fund',
`${CLI_PACKAGE}@${tag}`,
`${BUNDLE_PACKAGE}@${tag}`,
]);
const installed = JSON.parse(
fs.readFileSync(path.join(dir, 'node_modules', CLI_PACKAGE, 'package.json'), 'utf-8')
);
const bundle = JSON.parse(
fs.readFileSync(path.join(dir, 'node_modules', BUNDLE_PACKAGE, 'package.json'), 'utf-8')
);
// The whole point of packing with pnpm: this must be a concrete version, not
// the literal string `workspace:*`.
const linked = installed.dependencies?.['@bitsquare/nopy-cube'];
if (!linked || linked.startsWith('workspace:')) {
throw new Error(
`${CLI_PACKAGE} declares nopy-cube as "${linked}" — a workspace range escaped.`
);
}
console.log('\n--- versions ---');
console.log(`${CLI_PACKAGE}@${installed.version}`);
console.log(`${BUNDLE_PACKAGE}@${bundle.version}`);
console.log(` -> @bitsquare/nopy-cube ${linked}`);
console.log('\n--- nopy --version ---');
console.log(capture(path.join(dir, 'node_modules', '.bin', 'nopy'), ['--version']));
// The part a tarball most often breaks: the loader reading cubes out of an
// installed bundle in node_modules rather than a local directory.
fs.writeFileSync(
path.join(dir, '.nopyrc.json'),
`${JSON.stringify({ hosts: ['snapshot-check'], cubePackages: [BUNDLE_PACKAGE] }, null, 2)}\n`,
'utf-8'
);
console.log('\n--- cube discovery ---');
// stdin is closed, so the cube-selection prompt renders its choices and
// gives up immediately instead of waiting for a keystroke. Those rendered
// choices are the evidence: they only exist if the loader resolved the
// bundle out of node_modules and imported every manifest.
const discovery = probe(path.join(dir, 'node_modules', '.bin', 'nopy'), ['install', '-P', '-D']);
// Built from a char code rather than written literally: a raw escape byte in
// a regex is a lint error, and the `\x1b` escape is flagged just the same.
const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, 'g');
const listed = [
...new Set(
[
...discovery
.replace(ansi, '')
.matchAll(/([a-z0-9:_-]+) - [^\n]*\(@bitsquare\/cubes-core\)/g),
].map((match) => match[1])
),
];
if (listed.length === 0) {
throw new Error(
`nopy loaded no cubes from ${BUNDLE_PACKAGE}. Output was:\n${discovery.slice(0, 2000)}`
);
}
// A count of what the prompt's viewport rendered, not of the whole bundle —
// the check is that the loader found cubes at all, not how many.
console.log(`${listed.length} cubes listed by the selection prompt`);
console.log(` ${listed.slice(0, 5).join(', ')}${listed.length > 5 ? ', …' : ''}`);
console.log('\nSnapshot install works.');
} catch (error) {
failed = true;
console.error(`\nSnapshot check failed: ${error.message}`);
} finally {
if (keep || failed) {
console.error(`\nLeft the project at ${dir}`);
} else {
fs.rmSync(dir, { recursive: true, force: true });
}
}
process.exit(failed ? 1 : 0);