From ea08e76a2fe2104c678ce24c1d0d057108977530 Mon Sep 17 00:00:00 2001 From: Benjamin Diedrichsen Date: Wed, 29 Jul 2026 11:16:52 +0200 Subject: [PATCH] [feat] nopy update command and auto-update pipeline --- .gitea/workflows/publish-snapshot.yml | 15 +- .gitea/workflows/release.yml | 39 +- .gitignore | 5 + .npmrc | 20 + CLAUDE.md | 96 +- DOCS-AUDIT.md | 62 +- README.PUBLISH.md | 230 ++++- package.json | 2 + packages/cubes-core/package.json | 2 +- packages/keyman/package.json | 4 +- packages/keyman/src/index.ts | 28 + packages/keyman/src/keyman.cli.ts | 57 ++ packages/keyman/src/keyman.update.ts | 472 +++++++++ packages/keyman/tests/update.test.ts | 868 +++++++++++++++++ packages/nopy-cube/package.json | 2 +- packages/nopy/README.md | 103 +- packages/nopy/docs/API.md | 1270 +++++++++++++++++-------- packages/nopy/package.json | 4 +- packages/nopy/src/index.ts | 29 + packages/nopy/src/nopy.cli.ts | 56 ++ packages/nopy/src/nopy.update.ts | 513 ++++++++++ packages/nopy/tests/update.test.ts | 865 +++++++++++++++++ pnpm-lock.yaml | 17 + scripts/registry-status.mjs | 183 ++++ scripts/try-snapshot.mjs | 170 ++++ 25 files changed, 4659 insertions(+), 453 deletions(-) create mode 100644 .npmrc create mode 100644 packages/keyman/src/keyman.update.ts create mode 100644 packages/keyman/tests/update.test.ts create mode 100644 packages/nopy/src/nopy.update.ts create mode 100644 packages/nopy/tests/update.test.ts create mode 100644 scripts/registry-status.mjs create mode 100644 scripts/try-snapshot.mjs diff --git a/.gitea/workflows/publish-snapshot.yml b/.gitea/workflows/publish-snapshot.yml index 234f503..8ad471f 100644 --- a/.gitea/workflows/publish-snapshot.yml +++ b/.gitea/workflows/publish-snapshot.yml @@ -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::" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 609b003..c86332b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 ` uploads to + # Gitea and `npm view --registry ` 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" diff --git a/.gitignore b/.gitignore index 354721f..f15b29c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..182aa7f --- /dev/null +++ b/.npmrc @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 2ac56a1..7a55312 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=`, 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 ` was measured uploading to **Gitea**, and +the `npm view --registry ` guard answered from Gitea and skipped the npmjs +publish. Both workflows now `rm -f .npmrc` after checkout *and* pass +`--@bitsquare:registry=` 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 +` against a registry with no `latest` tag prints nothing and exits **0**, +which is why this looked like a working lookup. (`npm view @` +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 `-main..g` 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. diff --git a/DOCS-AUDIT.md b/DOCS-AUDIT.md index dc06fc0..e64e4d1 100644 --- a/DOCS-AUDIT.md +++ b/DOCS-AUDIT.md @@ -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 diff --git a/README.PUBLISH.md b/README.PUBLISH.md index 98c7df7..1a14f4b 100644 --- a/README.PUBLISH.md +++ b/README.PUBLISH.md @@ -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=` 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 ` 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=`. 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 # 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 @` exits 1 for a version that does not exist, so it is +> a sound check. `npm view ` — no version — is **not**: against a registry +> with no `latest` tag it prints nothing and exits 0. + ## Troubleshooting | Symptom | Cause and fix | diff --git a/package.json b/package.json index 9258928..dcdb6dd 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/packages/cubes-core/package.json b/packages/cubes-core/package.json index 00b44f7..5a44231 100644 --- a/packages/cubes-core/package.json +++ b/packages/cubes-core/package.json @@ -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", diff --git a/packages/keyman/package.json b/packages/keyman/package.json index 224b456..a53fabc 100644 --- a/packages/keyman/package.json +++ b/packages/keyman/package.json @@ -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", diff --git a/packages/keyman/src/index.ts b/packages/keyman/src/index.ts index 9d41d45..9b9bd3f 100644 --- a/packages/keyman/src/index.ts +++ b/packages/keyman/src/index.ts @@ -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'; diff --git a/packages/keyman/src/keyman.cli.ts b/packages/keyman/src/keyman.cli.ts index 81a37e3..6a63f1b 100644 --- a/packages/keyman/src/keyman.cli.ts +++ b/packages/keyman/src/keyman.cli.ts @@ -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(); diff --git a/packages/keyman/src/keyman.update.ts b/packages/keyman/src/keyman.update.ts new file mode 100644 index 0000000..75ec3e0 --- /dev/null +++ b/packages/keyman/src/keyman.update.ts @@ -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; + +/** 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 { + 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 { + const doFetch = options.fetchImpl ?? globalThis.fetch; + const packageName = options.packageName ?? PACKAGE_NAME; + const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`; + + const headers: Record = { + 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 }; + 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 { + 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 { + 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; +}): Promise { + 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 }; +} diff --git a/packages/keyman/tests/update.test.ts b/packages/keyman/tests/update.test.ts new file mode 100644 index 0000000..93566f8 --- /dev/null +++ b/packages/keyman/tests/update.test.ts @@ -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, 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 = {}; + const fetchImpl = (async (_url: string, init: RequestInit) => { + headers = init.headers as Record; + 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 = {}; + const fetchImpl = (async (_url: string, init: RequestInit) => { + headers = init.headers as Record; + 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 = {}; + const fetchImpl = (async (_url: string, init: RequestInit) => { + headers = init.headers as Record; + 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(); + }); +}); diff --git a/packages/nopy-cube/package.json b/packages/nopy-cube/package.json index fbe5e3c..0273582 100644 --- a/packages/nopy-cube/package.json +++ b/packages/nopy-cube/package.json @@ -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", diff --git a/packages/nopy/README.md b/packages/nopy/README.md index f8972a4..2da2747 100644 --- a/packages/nopy/README.md +++ b/packages/nopy/README.md @@ -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 # 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 diff --git a/packages/nopy/docs/API.md b/packages/nopy/docs/API.md index 1ec36d5..42ba8d7 100644 --- a/packages/nopy/docs/API.md +++ b/packages/nopy/docs/API.md @@ -1,19 +1,247 @@ # Nopy API Reference -This document describes the public API for the nopy package. +The public surface of **`@bitsquare/nopy`** (the CLI and its library exports) and +of **`@bitsquare/nopy-cube`** (the authoring package a `manifest.mjs` imports). + +Everything below was checked against the source. Where the code does something a +reader would not expect — a field that is always empty, a function nothing calls +— it is documented as it behaves, not as it reads. See +[Known gaps](#known-gaps) for the short list of those. + +If you are writing cubes rather than calling nopy from code, you want +[CUBE-BUNDLES.md](CUBE-BUNDLES.md) and [HOOKS.md](HOOKS.md); only the +[Authoring API](#authoring-api-bitsquarenopy-cube) section here applies to you. --- ## Table of Contents +- [Two packages](#two-packages) +- [Authoring API (`@bitsquare/nopy-cube`)](#authoring-api-bitsquarenopy-cube) - [Main Module](#main-module) - [Cubes Module](#cubes-module) +- [Variables Module](#variables-module) - [Executor Module](#executor-module) -- [Builder Module](#builder-module) - [Workflow Module](#workflow-module) - [Session Module](#session-module) +- [History Module](#history-module) - [Config Module](#config-module) - [Prompts Module](#prompts-module) +- [Update Module](#update-module) +- [CLI Usage](#cli-usage) +- [Creating a Cube](#creating-a-cube) +- [Known gaps](#known-gaps) + +--- + +## Two packages + +| Package | Contains | Depends on | +| --- | --- | --- | +| `@bitsquare/nopy-cube` | `Manifest`, `Cube`, `Hook`, `uniqid`, the zod helpers | zod (peer) | +| `@bitsquare/nopy` | the CLI, the loader, config, sessions, execution | `@bitsquare/nopy-cube` | + +A `manifest.mjs` should import from **`@bitsquare/nopy-cube`**: it is types and a +factory with no CLI, no prompts and no process spawning, so a cube bundle can +depend on it without pulling the whole tool into its dependency graph. + +```javascript +import { Manifest } from '@bitsquare/nopy-cube'; // prefer this +import { cubes } from '@bitsquare/nopy'; // cubes.Manifest — still supported +``` + +`@bitsquare/nopy` re-exports the entire authoring surface, so both forms work and +older manifests keep loading. The `cubes` namespace object +(`src/nopy.cubes.ts`) is marked `@deprecated` and exists only for that +compatibility; it also carries a `cubes.load` alias for `loadCubes`. + +--- + +## Authoring API (`@bitsquare/nopy-cube`) + +### `Manifest(opts)` + +Factory for a cube manifest. `name` is the only required option; everything else +is filled in. + +```javascript +import { Manifest } from '@bitsquare/nopy-cube'; +import { z } from 'zod'; + +export default Manifest({ + id: 'apt:essentials', + name: 'Install essential apt packages', + secrets: [], + dependencies: (vars) => (vars.WITH_BUILD_TOOLS ? ['apt:build'] : []), + schema: z.object({ + PACKAGES: z.string().default('curl,git').describe('Comma-separated packages'), + }), +}); +``` + +Defaults applied by the factory: `id: ''`, `schema: z.object({})`, +`secrets: []`, `before: []`, `after: []`, `dependencies: undefined`. + +`createManifest` and `manifest` are exported as identical aliases. +`ManifestFactory` is a third alias, marked `@deprecated` — and it is not +re-exported through `@bitsquare/nopy`, so it is only reachable from +`@bitsquare/nopy-cube` directly. + +#### `Manifest` (interface) + +```typescript +interface Manifest { + /** Unique identifier, used for dependency references and as the session key. */ + id: string; + /** Human-readable name, shown in the picker. */ + name: string; + /** Zod object schema for the cube's variables. */ + schema: Schema; + /** Schema keys whose values must never be persisted or printed. */ + secrets?: string[]; + /** Dependencies, computed from the *collected* variables. */ + dependencies?: (variables: z.infer) => DependencySpec[]; + before?: Hook[]; + after?: Hook[]; +} +``` + +`dependencies` is a **function**, not an array: it runs after the cube's +variables have been collected, so it can branch on what the user actually +answered. + +`secrets` is a plain array rather than schema metadata on purpose. `.meta()` and +`.describe()` store into zod's global registry, which is per-copy — a manifest +that built its schema with its own copy of zod would write the marker somewhere +this process cannot read it. A missed `.describe()` costs an ugly prompt label; a +missed secret marker writes a password to disk, so this one cannot be allowed to +fail open. An entry naming a key that is not in the schema is a load error. + +### `Cube` + +A loaded cube: its manifest, where it lives, and how it got into the run. This is +a **class**, constructed by the loader; the manifest's fields stay on +`.manifest` rather than being flattened onto it. + +```typescript +class Cube { + constructor( + manifest: Manifest, + dir: string, // absolute path to the cube directory + deployScript: string, // filename, e.g. 'deploy.py' — not a path + source?: CubeSource, // defaults to { type: 'dir', dir } + ); + + get id(): string; // manifest.id + get name(): string; // manifest.name + get secrets(): string[]; // manifest.secrets ?? [] + + getDefaults(): z.infer; + requiredKeys(): string[]; + isSecret(key: string): boolean; +} +``` + +`getDefaults()` parses `{}` against the schema, which resolves every default in +one pass — but that throws as soon as one field has no `.default()`. It then +falls back to a per-field read so the defaults that *are* declared survive; a +single required field used to leave the cube with no variables at all. + +`requiredKeys()` returns the keys nothing can fill in on its own: no `.default()` +and not optional. A `--use-defaults` run that cannot supply one aborts by name +rather than deploying the cube with the value missing. + +### `CubeSource` + +Where a cube came from. Carried because a cube's directory does not say how it +got into the run — `/…/node_modules/@acme/cubes-net/cubes/x` could equally have +come from a `cubeDirs` entry pointing straight at it. It is what makes a +duplicate-id error legible when the collision is between a local tree and an +installed bundle. + +```typescript +type CubeSource = + | { type: 'dir'; dir: string } + | { type: 'package'; packageName: string; dir: string }; +``` + +The cube picker also uses it: a cube from a package is labelled +`id - name (@acme/cubes-net)`, and because the fuzzy filter matches on the label, +typing a package name narrows the list to that bundle. + +### `Hook` and `HookContext` + +```typescript +type Hook = ( + ctx: HookContext, + variables: z.infer +) => void | Promise; + +interface HookContext { + /** Pulls another cube into the run, optionally passing it variables. */ + exec: (key: string, variables: CubeVariables) => Promise | void; +} +``` + +`exec()` re-enters the resolver, so a hook can pull in a cube that is not a +declared dependency. The `variables` argument is the cube's *effective* values, +not schema-validated output — see [Known gaps](#known-gaps). Full semantics in +[HOOKS.md](HOOKS.md). + +### `DependencySpec` and `CubeVariables` + +```typescript +type CubeVariables = Record; +type DependencySpec = string | [id: string, variables?: CubeVariables]; +``` + +The tuple form passes variables down, at `param` precedence: + +```javascript +dependencies: (v) => ['apt:essentials', ['user:add', { USER: v.USER }]], +``` + +### `AnyObjectSchema` + +```typescript +type AnyObjectSchema = z.ZodObject>>; +``` + +Stands in for zod 3's `z.AnyZodObject`, which zod 4 removed. + +### `zodKind(node)` / `zodInner(node)` + +```typescript +function zodKind(zodType: unknown): string; // node.def.type — 'default', 'boolean', … +function zodInner(zodType: unknown): z.ZodType; // node.def.innerType +``` + +Schema introspection that survives a second copy of zod. `instanceof z.ZodDefault` +compares against the *running* copy; a manifest is free to build its schema with +its own, and then every `instanceof` quietly returns false and the caller falls +through to a wrong answer. Nothing that inspects a cube's schema may go back to +`instanceof`. `zodInner` is only valid for a node whose `zodKind` is a wrapper +(`default`, `optional`, `nullable`). + +### `uniqid(length?)` + +```typescript +const id = uniqid(); // 'Kx7Pm' +const long = uniqid(10); // 'Kx7PmQr2Yw' +``` + +An LCG seeded from `process.hrtime.bigint()`. Unique enough for an identifier, +not cryptographic. Note that using it in a `.default()` means a fresh value on +every run — fine for a session that gets recorded, surprising for anything else. + +### `LoadResult` + +```typescript +interface LoadResult { + cubes: Record; // keyed by cube id + errors: string[]; +} +``` --- @@ -21,34 +249,36 @@ This document describes the public API for the nopy package. ### `nopy(options?)` -Main entry point for nopy deployments. +Runs one full deployment pass: load config → load cubes → pick a workflow → +resolve cubes and their dependencies → execute. ```typescript import { nopy } from '@bitsquare/nopy'; -const result = await nopy({ - useDefaults: false, - dryRun: true, -}); +const result = await nopy({ useDefaults: true, dryRun: true }); ``` -**Parameters:** +**`NopyOptions`** | Name | Type | Default | Description | |------|------|---------|-------------| -| `useDefaults` | `boolean` | `false` | Skip variable prompts, use defaults | -| `useAuthKey` | `boolean` | `false` | Force SSH key authentication | -| `saveSession` | `string` | - | Path to save session file | -| `loadSession` | `string` | - | Path to load session for replay | -| `dryRun` | `boolean` | `false` | Show execution plan without running | -| `continueOnError` | `boolean` | `false` | Continue after failures | -| `jsonOutput` | `boolean` | `false` | Output results as JSON | +| `useDefaults` | `boolean` | `false` | Skip the variable prompts. A cube with a required key nothing supplied aborts the run by name. | +| `useAuthKey` | `boolean` | `false` | Force SSH key auth, skipping the auth prompt. | +| `saveSession` | `string` | – | Path to write the session to. **Ignored during a replay.** | +| `loadSession` | `string` | – | Path to a session file to replay. | +| `replaySession` | `NopySession` | – | A session object to replay, used by `-R` / `-H` from history. Takes precedence over `loadSession`. | +| `dryRun` | `boolean` | `false` | Print the execution plan instead of running it. | +| `printOnly` | `boolean` | `false` | Print the built pyinfra commands and return; the executor is never reached. | +| `continueOnError` | `boolean` | `false` | Keep going after a cube fails. | +| `jsonOutput` | `boolean` | `false` | Suppress the config banner and progress lines. See [Known gaps](#known-gaps). | +| `saveToHistory` | `boolean` | `true` | Record the session in `.nopy.history.json`. | -**Returns:** `Promise` +**Returns:** `Promise` — `undefined` when cube loading +produced errors, which is the one failure mode that returns rather than throws. ```typescript interface NopyResult { - success: boolean; + success: boolean; // summary.failed === 0 results: ExecutionResult[]; summary: { total: number; @@ -59,125 +289,90 @@ interface NopyResult { } ``` +`--dry-run` and `--print-only` both return with an empty `results` array; +`--print-only` additionally reports `total` as the number of commands built. + --- ## Cubes Module -The cubes module provides types and functions for working with deployment units. +### `loadCubes()` -The authoring half of it — `Manifest`, `Cube`, `Hook`, `uniqid` and the rest — -actually lives in **[`@bitsquare/nopy-cube`](../../nopy-cube)**, a package with -no CLI and no dependency other than zod. `@bitsquare/nopy` re-exports all of it, -so both of these work: - -```javascript -import { Manifest } from '@bitsquare/nopy-cube'; // in a manifest.mjs — prefer this -import { cubes } from '@bitsquare/nopy'; // cubes.Manifest — still supported -``` - -Import from `nopy-cube` in a cube bundle you intend to publish: it lets the -bundle depend on the authoring types without pulling the whole CLI in as a -dependency. See [CUBE-BUNDLES.md](CUBE-BUNDLES.md). - -### Types - -#### `Cube` - -A fully loaded cube with filesystem location. - -```typescript -interface Cube { - key: string; // Unique identifier - name: string; // Human-readable name - dir: string; // Absolute path to cube directory - source: CubeSource; // Where it was discovered - dependencies: string[]; - schema: Schema; - defaults: () => z.infer; - before: Hook[]; - after: Hook[]; -} -``` - -#### `CubeSource` - -Where a cube came from. Carried so that a duplicate-id error can name the origin -of each claimant, which is the difference between a usable error message and a -puzzle when the collision is between a local tree and an installed bundle. - -```typescript -type CubeSource = - | { type: 'dir'; dir: string } - | { type: 'package'; packageName: string; dir: string }; -``` - -#### `Manifest` - -Cube manifest (used in `manifest.mjs` files). - -```typescript -interface Manifest { - name: string; - key: string; - dependencies: string[]; - schema: Schema; - defaults: () => z.infer; - before: Hook[]; - after: Hook[]; -} -``` - -#### `Hook` - -Hook function for before/after cube execution. See [Cube Hooks](HOOKS.md) for more details. - -```typescript -type Hook = ( - ctx: HookContext, - params: z.infer -) => void | Promise; - -interface HookContext { - /** - * Schedules another cube for execution. - * @param key - The unique identifier or path of the cube. - * @param params - Variables to pass to the cube. - */ - exec: (key: string, params: CubeVariables) => Promise | void; -} -``` - -### Functions - -#### `loadCubes()` - -Loads all cubes from discovered cube directories — `cubeDirs`, the directories -declared by every package in `cubePackages`, and any ancestor directory holding a -`.npcubes` marker. +Loads every cube from every discovered root. ```typescript const { cubes, errors } = await loadCubes(); ``` -**Returns:** `Promise` +Roots come from three places, unioned: + +1. `cubeDirs` from the merged configuration; +2. every ancestor of the working directory holding a `.npcubes` marker file; +3. the directories declared by each package in `cubePackages`. + +A directory is a cube when it holds both a manifest (`manifest.mjs` or +`*.manifest.mjs`) and a deploy script (`deploy.py` or `*.deploy.py`). Scanning is +recursive and skips dotted directories and `node_modules`. Manifests are loaded +by dynamic `import()`. + +The cube's id is `manifest.id`, falling back to an `[id]` prefix in +`manifest.name`, then to the directory's basename. Ids are flat and need not +mirror the path, and they are claimed **globally** — across `cubeDirs`, +`.npcubes` trees and every installed bundle at once. + +`errors` is non-empty for: + +- a duplicate id (the message names every claimant and how each got into the run); +- a manifest that throws on import, exports a non-object, or has no `name`; +- a `secrets` entry naming a key that is not in the schema; +- a package in `cubePackages` that is not installed, cannot be read, or declares + no `nopy.cubes`; +- a `nopy.cubes` entry that does not exist or points outside its package root. + +Any of them aborts the run (`nopy.main.ts` returns before the workflow). None is +a silent skip. Note that `cubes` is still populated when a duplicate is reported, +for callers that only want to display what was found. + +`loadCubes()` also registers the resolve hook (`cubes/resolve-hook.mjs`) before +importing anything. The hook tries ordinary Node resolution first and only on +failure falls back to resolving `@bitsquare/nopy-cube`, `@bitsquare/nopy` and +`zod` from the running CLI's own `node_modules` — so a hand-written cube in a +directory with no `node_modules` loads, while a cube shipping its own zod keeps +it. Registration is best-effort: it is a convenience, never load-bearing. + +### `findCubeRoots()` ```typescript -interface LoadResult { - cubes: Record; - errors: string[]; +const { roots, errors } = findCubeRoots(); + +interface CubeRoot { + dir: string; + source: CubeSource; } ``` -`errors` is non-empty for a duplicate id, a manifest that fails to load, a -package in `cubePackages` that is not installed or declares no cubes, and a -`nopy.cubes` entry that is missing or points outside its package. Any of them -aborts the run — none is a silent skip. +The roots `loadCubes()` would scan, each tagged with where it came from. A +missing `cubeDirs` entry is ignored; a missing package is not. -#### `resolveCubePackages(refs)` +### `findCubeDirectories()` + +`findCubeRoots().roots.map(r => r.dir)` — the paths alone. It **drops the +errors**, so anything that needs to know a named package was missing should call +`findCubeRoots()` instead. + +### `getCube(cubeName)` + +```typescript +const cube = await getCube('apt:essentials'); // Cube | undefined +``` + +Convenience wrapper over `loadCubes()`. It discards `errors` too. + +### `resolveCubePackages(refs)` Resolves `CubePackageRef[]` to installed packages and their cube directories. -Called by `loadCubes()`; exported because the resolution failures are worth -testing on their own. +Called by `findCubeRoots()`; exported because its failure modes are worth testing +on their own. ```typescript const { packages, errors } = resolveCubePackages(config.cubePackages); @@ -185,68 +380,162 @@ const { packages, errors } = resolveCubePackages(config.cubePackages); interface CubePackage { name: string; // the name it was requested under root: string; // absolute path to the package root - dirs: string[]; // absolute paths from its `nopy.cubes` field + dirs: string[]; // absolute paths, from the package's `nopy.cubes` field } ``` -#### `resolveDependencies(cubes, selectedCubeNames)` +Resolution goes through `createRequire(...).resolve.paths()` plus `existsSync` on +`//package.json`, deliberately bypassing the `exports` map: a bundle +ships directories and has no entry point to declare. `existsSync` also follows +the symlink pnpm plants at `node_modules/` — which is why the loader cannot +simply scan `node_modules` instead, since `readdir` reports that entry as a +symlink rather than a directory and skips every package silently. -Resolves all transitive dependencies for selected cubes. +Duplicate refs are deduped here, last-wins: `mergeValue` only dedupes arrays of +primitives and these are objects, so a package named by both a parent and a child +config arrives twice. Configs merge root-first, so the last occurrence is the one +from the most specific config and carries the right resolution origin. + +### `BuildContext` + +The resolver. One instance per run; it accumulates rather than returning. ```typescript -const order = resolveDependencies(cubes, ['apt-all']); -// Returns: ['apt:essentials', 'apt-more', 'apt-all'] +const context = new BuildContext( + cubes, // Record + variables, // Variables + session, // NopySession + config, // NopyConfig + { method: 'ssh-key', username: undefined, password: undefined }, + { useDefaults: false, isSessionReplay: false } +); + +for (const host of session.hosts!) { + for (const cubeId of selectedCubes) { + await context.resolveCube(cubeId, host); + } +} + +context.deployCalls; // DeployCall[] — in execution order +context.cubeSessions; // CubeSession[] — what a session file would record ``` -**Parameters:** +#### `resolveCube(cubeId, host, overrides?)` -| Name | Type | Description | -|------|------|-------------| -| `cubes` | `Record` | Map of all available cubes | -| `selectedCubeNames` | `string[]` | Cubes to resolve | +Recursive, per (cube, host): -**Returns:** `string[]` - Cube names in execution order +1. declare the cube's secrets, assign `overrides` at `param`, assign schema + defaults at `default`; +2. collect variables — read them back from the session on replay (then prompt for + the gaps), skip the prompts under `useDefaults`, otherwise prompt; +3. run `before` hooks; +4. call `manifest.dependencies(collectedVariables)` and recurse into each; +5. emit the deploy call; +6. run `after` hooks. -**Throws:** `Error` if cube not found or circular dependency detected +There is no separate topological sort — the ordering falls out of the recursion, +and a `${cubeId}:${host}` set makes emission idempotent. Consequently there is no +cycle detection either: two mutually dependent cubes recurse until the stack +overflows. -#### `cubes.Manifest(options)` +**Throws** when the cube id is unknown, when `useDefaults` cannot fill a required +key, when a replay would need a value only the user has (secrets are never +recorded), and when a cancelled prompt leaves a required key empty. -Factory function for creating cube manifests. +The command it builds: + +``` +pyinfra -y [--user U --password P] --data "K=V" … --chdir / +``` + +--- + +## Variables Module + +One `Variable` per (cube, key), holding every value it has ever been given. + +### `Origin` + +Where a value came from, in ascending precedence: + +| Origin | Rank | Source | +|---|---|---| +| `default` | 0 | a `.default()` on the cube's schema | +| `env` | 1 | the `env` block of `.nopyrc.json` | +| `session` | 2 | read back from a recorded session on replay | +| `prompt` | 3 | what the user typed | +| `param` | 4 | a dependency spec or a hook's `exec()` | + +The order used to be the field order of an object literal — load-bearing, +invisible, and one careless reformat away from silently changing which value +wins. It is stated once now and everything derives from it. + +There are no scope bags: config `env` is seeded onto each cube as a real +assignment, and a replay assigns at `session` rather than being smuggled into the +prompts. + +### `Variable` ```typescript -import { cubes } from '@bitsquare/nopy'; +class Variable { + readonly assignments: Assignment[]; // the raw trace, newest first, never reordered + redacted: boolean; // declared a secret by the manifest -export default cubes.Manifest({ - name: 'My Cube', - dependencies: () => [['apt:essentials']], - schema: z.object({ - VERSION: z.string().default('1.0'), - }), -}); + get ordered(): Assignment[]; // the trace re-ranked by origin, winner first + get effective(): Assignment; + get value(): Value; + get origin(): Origin; + + assign(assignment: Assignment): void; + toJSON(): { cube: string; name: string; value: Value; origin: Origin }; +} + +interface Assignment { value: Value; origin: Origin } +type Value = string | number | boolean; +type TVariables = Record; ``` -`createManifest` and `manifest` are exported as equivalent aliases; `cubes.Manifest` is the documented form. +`ordered` is a **stable** sort of `assignments`. That stability is load-bearing: +the trace is newest-first and `Array.prototype.sort` is stable per spec, so two +assignments sharing an origin resolve to the newer one while the value it +displaced stays visible underneath. The trace is never persisted. -#### `uniqid(length?)` +`toJSON()` yields `MASK` instead of the value when `redacted`. -Generates a random alphanumeric string. +### `Variables` ```typescript -const id = uniqid(); // 'Kx7Pm' -const long = uniqid(10); // 'Kx7PmQr2Yw' +class Variables { + constructor(env?: TVariables); + + declareSecrets(cube: string, keys: readonly string[]): void; + isSecret(cube: string, name: string): boolean; + + assign(cube: string, origin: Origin, values?: TVariables): void; + + all(cube: string): Variable[]; + of(cube: string, name: string): Variable | undefined; + + get(cube: string): TVariables; // effective values → the pyinfra command line + persistable(cube: string): TVariables; // the same, minus declared secrets +} + +const MASK = '********'; ``` +`declareSecrets()` is retroactive as well as prospective, so it does not matter +whether the caller declares before or after the values arrive. + +`persistable()` leaves a secret out entirely rather than masking it, so a replay +sees it as absent and asks for it again. That is why replaying a session whose +cubes declare secrets is interactive even under `-D` — a `-D` replay that would +need one fails by name instead of hanging. + --- ## Executor Module -Handles pyinfra command execution. - -### Types - -#### `DeployCall` - -A deployment command ready for execution. +### `DeployCall` ```typescript interface DeployCall { @@ -254,32 +543,25 @@ interface DeployCall { host: string; cwd: string; command: string[]; - env: Record; - dependencies: string[]; + env: Record; // the cube's effective variables + secrets?: string[]; // schema keys the manifest declared secret + dependencies: DependencySpec[]; // always [] — see Known gaps } ``` -#### `ExecutionResult` - -Result of executing a deployment command. +### `ExecutionResult` / `ExecutionOptions` ```typescript interface ExecutionResult { cube: string; host: string; success: boolean; - duration: number; - stdout?: string; - stderr?: string; + duration: number; // ms + stdout?: string; // never populated — stdio is inherited + stderr?: string; // never populated — stdio is inherited error?: Error; } -``` -#### `ExecutionOptions` - -Options for deployment execution. - -```typescript interface ExecutionOptions { continueOnError?: boolean; dryRun?: boolean; @@ -288,186 +570,151 @@ interface ExecutionOptions { } ``` -### Functions +### `executeDeployCalls(calls, options?)` -#### `executeDeployCalls(calls, options?)` - -Executes an array of deployment calls sequentially, in the order they were built. +Runs the calls **sequentially**, in the order they were built, through +`execa({ shell: true })` with `stdio: 'inherit'` so pyinfra's output reaches the +terminal live. Stops at the first failure unless `continueOnError`. With +`dryRun`, prints the plan and returns `[]` without executing. ```typescript const results = await executeDeployCalls(calls, { continueOnError: false, - onProgress: (result, completed, total) => { - console.log(`${completed}/${total}`); - }, + onProgress: (result, completed, total) => console.log(`${completed}/${total}`), }); ``` -#### `outputExecutionPlan(calls, asJson?)` - -Outputs the execution plan without running. +### `outputExecutionPlan(calls, asJson?)` ```typescript -outputExecutionPlan(deployCalls); // Text output -outputExecutionPlan(deployCalls, true); // JSON output +outputExecutionPlan(deployCalls); // text +outputExecutionPlan(deployCalls, true); // JSON ``` -#### `summarizeResults(results)` +Both forms mask secrets. Note that `executeDeployCalls` calls this without the +second argument, so `--dry-run --json` prints the text plan. -Generates a summary of execution results. +### `maskCommand(call)` / `maskVariables(call)` + +```typescript +maskCommand(call); // string — the command as it is safe to print +maskVariables(call); // Record +``` + +pyinfra takes its data on the command line, so the real values have to be in +`call.command`; these are the last point before they would reach a log, a +`--print-only` dump or a dry-run plan. `maskCommand` replaces the SSH +`--password` argument and every `--data "KEY=…"` whose key the manifest declared +a secret. + +This covers nopy's own output only. The value still reaches pyinfra on its +command line, so it is visible in `ps` — inherent to pyinfra's `--data` +interface, not something nopy can mask. + +### `summarizeResults(results)` ```typescript const summary = summarizeResults(results); -// { -// total: 5, -// successful: 4, -// failed: 1, -// totalDuration: 12345, -// failures: [{ cube: 'docker', ... }] -// } -``` - ---- - -## Builder Module - -Constructs deployment commands. - -### `buildDeployCalls(cubeNames, hosts, context)` - -Builds deployment calls for all cubes and hosts. - -```typescript -const result = await buildDeployCalls( - ['apt:essentials', 'apt-more'], - ['@docker/test'], - { - cubes, - session, - config, - authMethod: 'ssh-key', - useDefaults: true, - isSessionReplay: false, - } -); -``` - -**Returns:** `Promise` - -```typescript -interface BuildResult { - deployCalls: DeployCall[]; - cubeSessions: CubeSession[]; - sessionEnv: Record; -} +// { total, successful, failed, totalDuration, failures: ExecutionResult[] } ``` --- ## Workflow Module -Manages interactive and replay workflows. +Picks interactive, file-replay or history-replay and normalises all three into +one shape. -### `runWorkflow(sessionPath, cubes, config, options?)` - -Runs the appropriate workflow based on options. +### `runWorkflow(sessionPath, cubes, config, options?, replaySession?)` ```typescript -const result = await runWorkflow( - undefined, // null for interactive, path for replay - cubes, - config, - { useDefaults: false } -); +const result = await runWorkflow(undefined, cubes, config, { useDefaults: false }); ``` -**Returns:** `Promise` +Dispatch order: `replaySession` (history) → `sessionPath` (file) → interactive. ```typescript interface WorkflowResult { session: NopySession; - cubesWithDependencies: string[]; + selectedCubes: string[]; // ids chosen, or the session's cube keys on replay authMethod: string; username?: string; password?: string; isReplay: boolean; } + +interface WorkflowOptions { + useDefaults?: boolean; + useAuthKey?: boolean; +} ``` ### `runInteractiveWorkflow(cubes, config, options?)` -Runs the interactive cube selection workflow. +Cube picker → host picker → auth. A host matching `@vagrant` or `@docker` skips +the auth prompt and uses `ssh`. ### `runReplayWorkflow(sessionPath, cubes, config)` -Runs a replay from a saved session file. +Loads and replays a session file. Re-prompts only for a missing host and for a +password (never persisted); a cube in the session that no longer exists is warned +about here and fails later in `resolveCube`. + +### `runSessionReplayWorkflow(session, cubes, config)` + +The same, from a session object rather than a path — the `-R` / `-H` path. --- ## Session Module -Manages session save/load operations. - ### Types -#### `NopySession` - -Complete session configuration. - ```typescript interface NopySession { name?: string; cubes: CubeSession[]; hosts?: string[]; auth: AuthSession; - env?: SessionVariables; + env?: TVariables; } -``` -#### `CubeSession` - -Configuration for a single cube. - -```typescript interface CubeSession { - key: string; - variables: SessionVariables; + key: string; // the cube id + variables: TVariables; } -``` -#### `AuthSession` - -Authentication configuration. - -```typescript interface AuthSession { method: 'ssh-key' | 'password' | 'ssh'; username?: string; + // password is intentionally absent — never persisted } ``` -### Functions +There is no `version` or `timestamp` field, and nothing validates compatibility. -#### `saveSession(session, filePath)` +A `CubeSession` records every value the cube settled on, whatever its origin — +not just the prompted ones — minus anything the manifest declared a secret. So a +`--use-defaults` run records a usable session instead of an empty one, and a +replay reproduces the run rather than re-deriving it from whatever the defaults +and `env` happen to say later. -Saves a session to a JSON file. +### `saveSession(session, filePath)` + +Writes JSON, creating the directory if needed. Note that `nopy()` skips this +during a replay. + +### `loadSession(filePath)` ```typescript -saveSession(session, './my-deployment.nopysession.json'); +const session = await loadSession('./deployment.session.json'); +const session = await loadSession('./deployment.session.mjs'); // default export ``` -#### `loadSession(filePath)` +Dispatches on the extension; `.json` and `.mjs` only. Validates that `cubes` is +an array, that `hosts` (if present) is an array, and that `auth` exists. -Loads a session from a JSON or MJS file. - -```typescript -const session = await loadSession('./deployment.json'); -const session = await loadSession('./deployment.mjs'); -``` - -#### `createSession(params)` - -Creates a session object from runtime data. +### `createSession(params)` ```typescript const session = createSession({ @@ -477,241 +724,468 @@ const session = createSession({ }); ``` -#### `listSessions(dirPath?)` +### `listSessions(dirPath?)` -Lists all session files in a directory. +Non-recursive; matches **`*.session.json`** and **`*.session.mjs`** only. +A file named `deploy.nopysession.json` will not be listed, though `loadSession` +reads it fine. + +--- + +## History Module + +Sessions are recorded automatically after a successful non-replay run, into +`.nopy.history.json` in the working directory. ```typescript -const sessions = listSessions('./sessions'); -// ['./sessions/deploy.session.json', './sessions/test.session.mjs'] +const HISTORY_FILE = '.nopy.history.json'; +const DEFAULT_HISTORY_SIZE = 10; + +interface HistoryEntry { + id: string; // base36 timestamp + random suffix + name: string; // "MM/DD/YYYY, HH:mm - cube1, cube2 → host" + timestamp: string; // ISO + session: NopySession; +} + +interface SessionHistory { + entries: HistoryEntry[]; // newest first +} ``` +| Function | Returns | Notes | +|---|---|---| +| `getHistoryPath()` | `string` | `/.nopy.history.json` | +| `loadHistory()` | `SessionHistory` | empty history if absent or unparseable | +| `saveHistory(history)` | `void` | | +| `addToHistory(session, maxEntries?)` | `HistoryEntry` | prepends, then trims to `maxEntries` | +| `getLastSession()` | `HistoryEntry \| undefined` | | +| `getSessionById(id)` | `HistoryEntry \| undefined` | | +| `listHistory()` | `HistoryEntry[]` | | +| `clearHistory()` | `void` | | +| `removeFromHistory(id)` | `boolean` | `false` if the id was not found | +| `formatHistoryList(entries)` | `string` | what `nopy history` prints | + +Recording is suppressed for a dry run, a replay, a run that built no deploy +calls, `--no-history`, and `history.autoSave: false` in the config. + --- ## Config Module -Manages nopy configuration. +### `NopyConfig` -### Types - -#### `NopyConfig` - -Configuration file structure. +The merged result. A config *file* is `NopyConfigFile`, which is this partial +plus a `resolution` block, and which lists `cubePackages` as plain strings. ```typescript interface NopyConfig { hosts: string[]; cubeDirs: string[]; cubePackages: CubePackageRef[]; - env: EnvConfig; + env: TVariables; log?: LogConfig; + history?: HistoryConfig; + execution?: ExecutionConfig; } + +interface LogConfig { + verbosity?: 'silent' | 'info' | 'verbose' | 'trace'; + debug?: boolean; +} + +interface HistoryConfig { + maxSessions?: number; // default 10 + autoSave?: boolean; // default true +} + +interface ExecutionConfig { + continueOnError?: boolean; +} + +type ResolutionStrategy = 'merge' | 'override'; +type ResolutionConfig = { [K in keyof NopyConfig]?: ResolutionStrategy }; ``` -#### `CubePackageRef` +### `CubePackageRef` -A package named in `cubePackages`, paired with where it was named. In the config -file an entry is just a string (`"@bitsquare/cubes-core"`); `loadConfig()` -normalises it. +A package named in `cubePackages`, paired with where it was named. In the file an +entry is just a string (`"@bitsquare/cubes-core"`); `loadConfig()` normalises it. ```typescript interface CubePackageRef { /** The package name, as written in the config. */ spec: string; - /** Directory of the config file that named it — resolution starts here. */ + /** Directory of the `.nopyrc.json` that named it — resolution starts here. */ from: string; } ``` `from` is what makes a package named in a parent config resolve against *that* -config's `node_modules`, not the working directory's. It is the same problem -`PATH_PROPERTIES` solves for relative `cubeDirs`. +config's `node_modules` rather than the working directory's. It is the same +problem `PATH_PROPERTIES` solves for relative `cubeDirs`, with a different answer: +a reference to resolve later instead of a rewritten path. -#### `LogConfig` +> The `CubePackageRef` name is currently not re-exported from the package root, +> though `NopyConfig` refers to it. Import it from `@bitsquare/nopy` and you get +> `NopyConfig` but not this type by name. -Logging configuration. - -```typescript -interface LogConfig { - verbosity?: 'silent' | 'info' | 'verbose' | 'trace'; - debug?: boolean; -} -``` - -### Functions - -#### `loadConfig()` - -Loads configuration from `.nopyrc.json`. +### `loadConfig()` ```typescript const config = loadConfig(); ``` -Search order: +Collects every `.nopyrc.json` from the working directory up to the filesystem +root, plus `~/.nopyrc.json`, and merges them **root-first** — so the most +specific file wins. The home config is applied first, at the lowest priority. -1. `./nopyrc.json` (local) -2. `~/.nopyrc.json` (home) +Per-property strategy comes from the child's `resolution` block, defaulting to +`merge`: arrays concatenate (and dedupe, when every element is a primitive), +objects deep-merge, primitives are replaced. `override` replaces outright. -#### `saveConfig(data, local?)` - -Saves configuration to a file. - -```typescript -saveConfig({ hosts: ['server.local'] }); // Local -saveConfig({ hosts: ['server.local'] }, false); // Home +```json +{ + "hosts": ["local-host"], + "cubePackages": ["@bitsquare/cubes-core"], + "resolution": { "hosts": "override" } +} ``` -#### `logConfigToFlags(logConfig?)` +Only `cubeDirs` has its relative paths resolved against its own config file's +directory. `cubePackages` gets the origin recorded instead, as above. -Converts log config to pyinfra flags. +**Throws** when no config file exists anywhere — which is why `nopy.cli.ts` calls +it lazily inside the action, so `--help` and `--version` work outside a project. + +### `getConfigPaths()` + +The config files that would be loaded, in merge order. Used for the banner. + +### `saveConfig(data, configPath?)` ```typescript -logConfigToFlags({ verbosity: 'verbose', debug: true }); -// ['-vv', '--debug'] +saveConfig({ hosts: ['server.local'] }); // /.nopyrc.json +saveConfig({ hosts: ['server.local'] }, '/etc/.nopyrc.json'); ``` +Shallow-merges over whatever the target file already holds. The second parameter +is a **path**, not a boolean. + +### `logConfigToFlags(logConfig?)` + +```typescript +logConfigToFlags({ verbosity: 'verbose', debug: true }); // ['-vv', '--debug'] +``` + +`silent → []`, `info → ['-v']`, `verbose → ['-vv']`, `trace → ['-vvv']`. +Nothing feeds the result into the built command — see +[Known gaps](#known-gaps). + --- ## Prompts Module -Interactive prompts for user input. - ### `CubeSelection(cubes)` -Prompts user to select cubes to execute. - ```typescript -const { selectedCubes } = await CubeSelection(cubes); +const { selectedCubes } = await CubeSelection(cubes); // string[] of ids ``` +Multi-select with fuzzy filtering on the rendered label. A cancelled prompt +returns an empty array rather than throwing. + ### `HostSelection(hosts)` -Prompts user to select a target host. - -```typescript -const host = await HostSelection(['server1', 'server2']); -``` +Offers `docker`, `vagrant`, the configured hosts, and `custom`, returning +`@vagrant/` or `@docker/` where applicable. ### `AuthSelection(useAuthKey?)` -Prompts user to select authentication method. - ```typescript const { authMethod, username, password } = await AuthSelection(); ``` -### `VariableAssignment(cube, env)` - -Prompts user to customize cube variables. - -```typescript -const vars = await VariableAssignment(cube, { existing: 'value' }); -``` +Returns `{ authMethod: 'ssh-key' }` immediately when `useAuthKey` is set. ### `PasswordSelection(username)` -Prompts for password input. +Masked single prompt; returns the password. + +### `VariableAssignment(cube, variables, opts?)` ```typescript -const password = await PasswordSelection('admin'); +await VariableAssignment(cube, variables); // every schema key +await VariableAssignment(cube, variables, { keys: gaps }); // a subset ``` +**Returns `Promise` and mutates the `Variables` instance**, assigning at +`prompt`. Answers are coerced back to the schema's declared type — booleans from +`true`/`yes`/`1`, numbers where parseable — via `zodKind`, not `instanceof`. + +It reads what to offer out of `variables`, so the caller is expected to have +assigned the schema defaults first (which `BuildContext.resolveCube` does). It +deliberately does not fall back to `cube.getDefaults()`: calling that a second +time re-evaluates every lazily declared default, so a cube generating one would +show a value different from the one the run already recorded. + +Every schema key is offered, not just the defaulted ones — a field without a +default is precisely the field that has to be asked about. Keys already supplied +at `param` are skipped, since the operator's answer could not win anyway. A +cancelled form leaves the existing values in place. + +--- + +## Update Module + +`src/nopy.update.ts`. Backs `nopy self-update` and the one-line notice printed +before an install run. Every network call, clock read and process spawn is an +injectable option (`fetchImpl`, `now`, `run`, `spawn`), which is what makes the +module testable without a registry. + +`@bitsquare/keyman` carries a near-identical copy (`keyman.update.ts`, +`KEYMAN_*` env vars, `~/.keyman/` cache). The duplication is deliberate: a fifth +workspace package would add a publish-order edge for ~250 lines. + +### Channels + +There is no stored channel — **the running version is the state**. + +```typescript +channelForVersion('0.5.0'); // 'latest' +channelForVersion('0.6.0-rc.1'); // 'next' +channelForVersion('0.5.0-main.42.gabc'); // 'main' +``` + +`channelForVersion(version)` returns `'main'` when any prerelease part is the +literal `main`, `'next'` for any other prerelease, and `'latest'` otherwise — +including for an unparseable version, where the worst case is a check that finds +nothing newer. It is the mirror image of the rule `release.yml` publishes under, +so a binary always checks the tag it came from. + +### `resolveRegistry(options?)` + +`NOPY_REGISTRY` → `npm config get @bitsquare:registry` → `NPMJS_REGISTRY`. +Asking npm is the load-bearing part: a CLI installed from Gitea checks Gitea for +its own updates with nothing else configured, because the scope mapping that +installed it is still in `.npmrc`. npm prints the literal string `undefined` for +an unset key, which is treated as unset; a missing npm is swallowed. Returns +trailing-slash form (`normalizeRegistry`). + +### `fetchChannelVersion(options)` + +```typescript +await fetchChannelVersion({ registry, channel, timeoutMs?, token?, fetchImpl?, packageName? }); +``` + +One `GET ${registry}${encodeURIComponent(name)}` with the abbreviated-packument +accept header, returning `body['dist-tags'][channel] ?? null`. A non-`ok` +response is `null`, not a throw. Deliberately `fetch` rather than shelling out to +`npm view`: one request, a real timeout, and immune to npm's startup cost. +`token` (from `NOPY_REGISTRY_TOKEN`) becomes a bearer header for a private +registry. + +### `checkForUpdate(options)` → `UpdateStatus` + +```typescript +interface UpdateStatus { + current: string; // the running version + latest: string | null; // what the channel points at, null if undeterminable + channel: Channel; + registry: string; + updateAvailable: boolean; // semver.gt(latest, current) + fromCache: boolean; +} +``` + +Cached in `~/.nopy/update-check.json` for `DEFAULT_CHECK_INTERVAL_MS` (24 h). An +entry counts as fresh only when its channel **and** registry match the current +question and its age is finite, `>= 0` (a future timestamp is rejected) and under +the interval. A failed lookup degrades to the applicable cached answer rather +than to no answer. + +### `buildSelfUpdateCommand(options)` + +```typescript +buildSelfUpdateCommand({ packageManager: 'npm', channel: 'main', registry: gitea }); +// → npm install --global @bitsquare/nopy@main --@bitsquare:registry=https://…/npm/ +``` + +The registry flag is **scope-mapped, never bare `--registry`**: the Gitea +registry serves `@bitsquare` only and does not proxy npmjs, so a bare +`--registry` breaks the install's transitive dependencies. It is omitted +entirely when the registry is already npmjs. `pnpm add --global`, +`yarn global add` and `bun add --global` are the other three forms. + +### `detectPackageManager(options?)` + +`NOPY_PACKAGE_MANAGER` wins; otherwise the install path is the evidence — +`/pnpm/`, `/.bun/`, `/.yarn/`|`/yarn/`, else npm. The point is that +`self-update` re-runs whatever installed the CLI instead of leaving two copies +on `PATH`. + +### `updateNotice(options)` / `formatUpdateNotice(status, pm?)` + +`updateNotice()` is the startup path: returns the string to print or `null`, and +**never throws** — it sits in front of every command the user actually asked +for. Returns `null` immediately when `isUpdateCheckDisabled(env)`: +`NOPY_NO_UPDATE_CHECK` set to anything but `0`/`false`, or `CI` set at all. The +CLI prints it to **stderr**, so `--json` and piped stdout stay clean. + +### `selfUpdate(options)` → `SelfUpdateResult` + +Always checks with `force: true` — the user asked, so a cached answer will not +do. Returns `{status, command, ran}`; `ran` is `false` for `dryRun`, and for +"already current" unless `force`. The install inherits stdio. + +| Env var | Effect | +| --- | --- | +| `NOPY_REGISTRY` | registry to check and install from | +| `NOPY_REGISTRY_TOKEN` | bearer token for a private registry | +| `NOPY_NO_UPDATE_CHECK` | suppress the startup notice | +| `NOPY_PACKAGE_MANAGER` | override install-command detection | +| `CI` | suppresses the notice implicitly | + --- ## CLI Usage ```bash -# Interactive deployment -nopy install +nopy install # interactive (the default command; `nopy` alone works, as does `nopy i`) +nopy install -D # use defaults, no variable prompts +nopy install -K # force SSH key auth +nopy install -R # repeat the last session from history +nopy install -H # replay a specific session from history +nopy install -s ./sess.json # save the session after deploying +nopy install -l ./sess.json # replay a session file +nopy install -n # dry run — print the plan, execute nothing +nopy install -P # print the built pyinfra commands and exit +nopy install -c # continue after a failure +nopy install -j # JSON output +nopy install --no-history # do not record this run -# With defaults (no prompts) -nopy install -D +nopy history # list recorded sessions (alias: h; -j for JSON) +nopy clear-history # drop them all -# SSH key auth -nopy install -K - -# Save session -nopy install -s ./my-session.json - -# Replay session -nopy install -l ./my-session.json - -# Dry run -nopy install -n - -# JSON output -nopy install -j - -# Continue on error -nopy install -c +nopy self-update # install the newest version on the current channel (alias: upgrade) +nopy self-update -n # print the install command, run nothing +nopy self-update -f # reinstall even when already current +nopy self-update --channel next --registry ``` +`--continue-on-error` overrides `execution.continueOnError` from the config. +Exit code is 1 when any cube failed. + +`self-update` prints Installed / Channel / Registry / Available, then one of +"Updated to X.", "Would run: …", or "Already up to date." When `latest` is +`null` it reports `Could not reach ` and exits 1 — deliberately not +"up to date", since an unanswerable check is not a negative answer. See +[Known gaps](#known-gaps) for what that message conflates. + +> `-H ` and `--no-history` share one Commander destination, so passing both +> discards the id and falls through to an interactive run. + --- ## Creating a Cube -### File Structure +### File structure -A cube is a directory containing both a `manifest.mjs` and a `deploy.py`: +A cube is a directory holding both a manifest and a deploy script: ``` cubes/ -└── my-cube/ - ├── manifest.mjs - └── deploy.py +└── apt/ + └── essentials/ + ├── manifest.mjs + └── deploy.py ``` -Cube directories may be nested for grouping (`cubes/apt/install/`), and any extra files alongside the pair are available to the deploy script via relative paths. +Directories may be nested for grouping, and any extra files alongside the pair +are reachable from the deploy script, which runs with the cube directory as its +working directory. The prefixed forms `.manifest.mjs` and +`.deploy.py` are still recognised. -The prefixed forms `.manifest.mjs` and `.deploy.py` are still recognized for backwards compatibility. - -### Manifest Example +### Manifest ```javascript // manifest.mjs -import { cubes } from '@bitsquare/nopy'; +import { Manifest } from '@bitsquare/nopy-cube'; import { z } from 'zod'; -export default cubes.Manifest({ - name: 'My Cube', - dependencies: () => [['apt:essentials']], +export default Manifest({ + id: 'apt:essentials', + name: 'Install essential packages', + dependencies: () => ['apt:update'], schema: z.object({ - VERSION: z.string().default('1.0').describe('Version to install'), - ENABLE_FEATURE: z.boolean().default(false), + PACKAGES: z.string().default('curl,git').describe('Comma-separated packages'), + ENABLE_FEATURE: z.boolean().default(false).describe('Enable the optional feature'), }), - before: [ - (ctx, params) => { - console.log('Before my-cube'); - }, - ], - after: [ - (ctx, params) => { - console.log('After my-cube'); - }, - ], + before: [(ctx, vars) => console.log('before', vars.PACKAGES)], + after: [(ctx, vars) => ctx.exec('admin:report', { STAGE: 'apt' })], }); ``` -### Deploy Script Example +> **Call `.default()` before `.describe()`.** In zod 4, `.default()` returns a +> `ZodDefault` wrapper that does not inherit `.description` from the type it +> wraps, and the prompt reads the description off the outer node. So +> `z.boolean().describe('Update cache').default(false)` prompts with the bare key +> `UPDATE`, while `z.boolean().default(false).describe('Update cache')` prompts +> with the sentence. Verified against zod 4.4.3. + +Every schema key reaches pyinfra as `--data KEY=value`, so `host.data.KEY` is +always defined. pyinfra parses the values itself: `"true"` arrives as a bool and +numeric strings as ints. + +### Deploy script ```python # deploy.py from pyinfra import host from pyinfra.operations import apt, server -VERSION = host.data.get('VERSION', '1.0') -ENABLE_FEATURE = host.data.get('ENABLE_FEATURE', False) +PACKAGES = host.data.PACKAGES.split(',') +ENABLE_FEATURE = host.data.ENABLE_FEATURE -apt.packages( - name='Install my-package', - packages=[f'my-package={VERSION}'], - update=True, -) +apt.packages(name='Install packages', packages=PACKAGES, update=True, _sudo=True) if ENABLE_FEATURE: - server.shell( - name='Enable feature', - commands=['my-package --enable-feature'], - ) + server.shell(name='Enable feature', commands=['my-package --enable-feature']) ``` + +For packaging cubes as an installable npm bundle, see +[CUBE-BUNDLES.md](CUBE-BUNDLES.md). + +--- + +## Known gaps + +Real behaviour that a reader would otherwise take on trust. Tracked in +`DOCS-AUDIT.md` and summarised in `CLAUDE.md`. + +- **`logConfigToFlags()` is never consumed.** It is exported and unit-tested, but + nothing feeds its output into the built pyinfra command, so `log.verbosity` and + `log.debug` in `.nopyrc.json` have no effect today. +- **`--json` emits nothing on success.** `jsonOutput` suppresses the banner and + the progress lines, and prints `{success: false, errors}` when cube *loading* + fails. The success path returns `NopyResult` to the caller without printing it, + so a CI job gets pyinfra's inherited stdio and an exit code. `--dry-run --json` + prints the *text* plan. +- **No cycle detection.** Ordering is a side effect of recursion, not a + topological sort. Two mutually dependent cubes overflow the stack. +- **`DeployCall.dependencies` is always `[]`.** The field is populated nowhere; + dependency information lives in the emission order. +- **`ExecutionResult.stdout` / `.stderr` are always `undefined`,** because the + executor inherits stdio rather than capturing it. +- **Hook variables are not schema-validated.** The second argument to a hook is + the effective values as collected. `schema.parse()` runs in exactly one place — + `Cube.getDefaults()`, against `{}` — and prompt input is type-coerced, which is + not the same thing. +- **Nothing checks bundle/CLI compatibility.** A cube package declares no + supported nopy range and the loader reads whatever `nopy.cubes` points at. +- **`self-update` reports an empty channel as unreachable.** `latest === null` + means either the request failed *or* the registry answered normally and the + dist-tag simply has no version — the second is exactly what a Gitea package + with no `latest` looks like — and both print `Could not reach `. + The distinction exists in `fetchChannelVersion` (a non-`ok` response returns + `null` rather than throwing) but is not carried out to the message. diff --git a/packages/nopy/package.json b/packages/nopy/package.json index 8c07f76..d19ea9a 100644 --- a/packages/nopy/package.json +++ b/packages/nopy/package.json @@ -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", diff --git a/packages/nopy/src/index.ts b/packages/nopy/src/index.ts index e858cb3..ed510ff 100644 --- a/packages/nopy/src/index.ts +++ b/packages/nopy/src/index.ts @@ -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 { diff --git a/packages/nopy/src/nopy.cli.ts b/packages/nopy/src/nopy.cli.ts index 6fa50eb..4bad1f5 100644 --- a/packages/nopy/src/nopy.cli.ts +++ b/packages/nopy/src/nopy.cli.ts @@ -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 { + 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 ', 'Check a specific channel (latest, next, main)') + .option('--registry ', '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(); diff --git a/packages/nopy/src/nopy.update.ts b/packages/nopy/src/nopy.update.ts new file mode 100644 index 0000000..c920fbe --- /dev/null +++ b/packages/nopy/src/nopy.update.ts @@ -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; + +/** 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 { + 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 { + const doFetch = options.fetchImpl ?? globalThis.fetch; + const packageName = options.packageName ?? PACKAGE_NAME; + const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`; + + const headers: Record = { + // 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 }; + 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 { + 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 { + 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; +}): Promise { + 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 }; +} diff --git a/packages/nopy/tests/update.test.ts b/packages/nopy/tests/update.test.ts new file mode 100644 index 0000000..f7770ab --- /dev/null +++ b/packages/nopy/tests/update.test.ts @@ -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, 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 = {}; + const fetchImpl = (async (_url: string, init: RequestInit) => { + headers = init.headers as Record; + 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 = {}; + const fetchImpl = (async (_url: string, init: RequestInit) => { + headers = init.headers as Record; + 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 = {}; + const fetchImpl = (async (_url: string, init: RequestInit) => { + headers = init.headers as Record; + 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(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12205f9..ad47dc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/registry-status.mjs b/scripts/registry-status.mjs new file mode 100644 index 0000000..567c6da --- /dev/null +++ b/scripts/registry-status.mjs @@ -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 ` + * 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.' +); diff --git a/scripts/try-snapshot.mjs b/scripts/try-snapshot.mjs new file mode 100644 index 0000000..aaf3927 --- /dev/null +++ b/scripts/try-snapshot.mjs @@ -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);