[feat] nopy update command and auto-update pipeline

This commit is contained in:
Benjamin Diedrichsen
2026-07-29 11:16:52 +02:00
parent 6ecb2c366f
commit ea08e76a2f
25 changed files with 4659 additions and 453 deletions
+13 -2
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
@@ -124,13 +132,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::"
+33 -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."
@@ -171,13 +185,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 +214,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 +303,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/
+88 -8
View File
@@ -32,6 +32,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:
@@ -208,10 +210,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.
@@ -248,17 +320,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 .",
+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';
+57
View File
@@ -1,10 +1,21 @@
#!/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 } = createRequire(import.meta.url)('../package.json') as { version: string };
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 +23,50 @@ if (args.includes('--print-config')) {
process.exit(0);
}
if (args.includes('--version') || args.includes('-V')) {
console.log(version);
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",
+82 -21
View File
@@ -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
+870 -396
View File
File diff suppressed because it is too large Load Diff
+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",
+29
View File
@@ -66,6 +66,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 {
+56
View File
@@ -16,9 +16,22 @@ 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 };
/**
* 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`);
}
}
const program = new Command();
program
@@ -63,6 +76,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;
@@ -150,4 +165,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();
+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 };
}
+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);