Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ba1c2a32a | |||
| ea08e76a2f |
@@ -35,6 +35,14 @@ jobs:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Drop the repo's Gitea scope mapping
|
||||
# See the same step in release.yml. This job only ever targets Gitea, so
|
||||
# the committed file happens to agree with it — but it agrees by
|
||||
# accident, and a project-level `@bitsquare:registry` silently outranks
|
||||
# the userconfig written below. Removing it keeps the registry a
|
||||
# property of the step rather than of the checkout.
|
||||
run: rm -f .npmrc
|
||||
|
||||
- name: Set up pnpm
|
||||
# Version comes from `packageManager` in the root package.json.
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -115,7 +123,10 @@ jobs:
|
||||
# `g` prefix keeps the identifier a valid semver one even when the
|
||||
# abbreviated sha happens to be all digits.
|
||||
version="${base}-main.${{ github.run_number }}.g${short_sha}"
|
||||
(cd "$dir" && npm pkg set "version=${version}")
|
||||
# `buildInfo.commit` is what `nopy --version` annotates itself with.
|
||||
# An unknown top-level key is ignored by npm and package.json is
|
||||
# always in the tarball, so it ships without any `files` change.
|
||||
(cd "$dir" && npm pkg set "version=${version}" "buildInfo.commit=${short_sha}")
|
||||
done
|
||||
|
||||
# Pass 2: publish.
|
||||
@@ -124,13 +135,16 @@ jobs:
|
||||
version=$(node -p "require('./${dir}/package.json').version")
|
||||
|
||||
echo "::group::${name}@${version}"
|
||||
if npm view "${name}@${version}" version --registry "$REGISTRY" >/dev/null 2>&1; then
|
||||
# Scoped, not `--registry`: for a scoped package npm resolves
|
||||
# `@scope:registry` first, so a bare flag loses to any project
|
||||
# .npmrc that sets the scoped key.
|
||||
if npm view "${name}@${version}" version --@bitsquare:registry="$REGISTRY" >/dev/null 2>&1; then
|
||||
echo "Already published — skipping (this is a re-run of the same workflow)."
|
||||
else
|
||||
# pnpm, not npm: npm ships `workspace:*` verbatim and the install
|
||||
# then fails with EUNSUPPORTEDPROTOCOL. --no-git-checks because
|
||||
# stamping the versions above left the tree dirty.
|
||||
(cd "$dir" && pnpm publish --ignore-scripts --no-git-checks --tag main --registry "$REGISTRY")
|
||||
(cd "$dir" && pnpm publish --ignore-scripts --no-git-checks --tag main --@bitsquare:registry="$REGISTRY")
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
|
||||
@@ -45,6 +45,18 @@ jobs:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Drop the repo's Gitea scope mapping
|
||||
# The committed .npmrc points @bitsquare at Gitea so local work resolves
|
||||
# snapshots. It must not survive into a publish job: it is a *project*
|
||||
# config, which outranks both the userconfig the steps below write and a
|
||||
# `--registry` flag, because `@scope:registry` is more specific than
|
||||
# `registry`. Left in place, `pnpm publish --registry <npmjs>` uploads to
|
||||
# Gitea and `npm view --registry <npmjs>` answers from Gitea — so the
|
||||
# npmjs release silently publishes nowhere and then skips itself.
|
||||
# Measured, not assumed. The checkout is disposable; each step below
|
||||
# names its registry explicitly anyway.
|
||||
run: rm -f .npmrc
|
||||
|
||||
- name: Resolve the release from the tag
|
||||
id: target
|
||||
run: |
|
||||
@@ -129,7 +141,9 @@ jobs:
|
||||
# to read, which this step does not have yet.
|
||||
missing=0
|
||||
for spec in $(node scripts/linked-deps.mjs "$DIR" | tr ' ' '@'); do
|
||||
if npm view "$spec" version --registry "$NPMJS_REGISTRY" >/dev/null 2>&1; then
|
||||
# Scoped, not `--registry`: `@scope:registry` outranks it, so a bare
|
||||
# flag can be silently overridden by any project-level .npmrc.
|
||||
if npm view "$spec" version --@bitsquare:registry="$NPMJS_REGISTRY" >/dev/null 2>&1; then
|
||||
echo "${spec} is published"
|
||||
else
|
||||
echo "::error::${NAME} depends on ${spec}, which is not on npmjs. Release it first."
|
||||
@@ -151,6 +165,20 @@ jobs:
|
||||
# Explicit, so the publish steps can skip lifecycle scripts entirely.
|
||||
run: pnpm run build
|
||||
|
||||
- name: Stamp the commit into the manifest
|
||||
# What `nopy --version` annotates itself with. The version is untouched:
|
||||
# this only adds a `buildInfo.commit` key, which npm ignores and which
|
||||
# ships regardless of `files` because package.json is always packed.
|
||||
# Before the pack below, so the artefact under test is the one publish
|
||||
# ships. The tree is left dirty, which is why both publish steps pass
|
||||
# --no-git-checks — they already did, for the detached HEAD.
|
||||
env:
|
||||
DIR: ${{ steps.target.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
short_sha=$(git rev-parse --short=7 HEAD)
|
||||
(cd "$DIR" && npm pkg set "buildInfo.commit=${short_sha}")
|
||||
|
||||
- name: Verify the packed manifests
|
||||
# Packages link to each other with `workspace:*`, which npm cannot
|
||||
# install. Proves on the tarball that pack rewrote it.
|
||||
@@ -171,13 +199,18 @@ jobs:
|
||||
} >> "$NPMRC"
|
||||
export npm_config_userconfig="$NPMRC"
|
||||
|
||||
if npm view "${NAME}@${VERSION}" version --registry "$GITEA_REGISTRY" >/dev/null 2>&1; then
|
||||
if npm view "${NAME}@${VERSION}" version --@bitsquare:registry="$GITEA_REGISTRY" >/dev/null 2>&1; then
|
||||
echo "${NAME}@${VERSION} is already on Gitea — skipping."
|
||||
else
|
||||
# pnpm, not npm: npm ships `workspace:*` verbatim and the install
|
||||
# then fails with EUNSUPPORTEDPROTOCOL. --no-git-checks because a
|
||||
# tag build is a detached HEAD.
|
||||
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --registry "$GITEA_REGISTRY")
|
||||
#
|
||||
# The registry is named as `--@bitsquare:registry`, not `--registry`.
|
||||
# Every package here is scoped, and for a scoped package npm resolves
|
||||
# `@scope:registry` ahead of `registry` — so a bare flag loses to any
|
||||
# project .npmrc that sets the scoped key.
|
||||
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --@bitsquare:registry="$GITEA_REGISTRY")
|
||||
fi
|
||||
|
||||
- name: Publish to npmjs
|
||||
@@ -195,12 +228,17 @@ jobs:
|
||||
} >> "$NPMRC"
|
||||
export npm_config_userconfig="$NPMRC"
|
||||
|
||||
if npm view "${NAME}@${VERSION}" version --registry "$NPMJS_REGISTRY" >/dev/null 2>&1; then
|
||||
if npm view "${NAME}@${VERSION}" version --@bitsquare:registry="$NPMJS_REGISTRY" >/dev/null 2>&1; then
|
||||
echo "${NAME}@${VERSION} is already on npmjs — skipping."
|
||||
else
|
||||
# No --provenance: that needs GitHub Actions OIDC, which Gitea has no
|
||||
# equivalent for.
|
||||
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --access public --registry "$NPMJS_REGISTRY")
|
||||
#
|
||||
# Scoped flag, as above — and it matters most here. With a bare
|
||||
# `--registry` this line was measured uploading to Gitea whenever a
|
||||
# project .npmrc mapped the scope, which is the one mistake npmjs
|
||||
# will not let you take back.
|
||||
(cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --access public --@bitsquare:registry="$NPMJS_REGISTRY")
|
||||
fi
|
||||
|
||||
- name: Remove the registry credentials
|
||||
@@ -279,5 +317,8 @@ jobs:
|
||||
echo "### Released \`${NAME}@${VERSION}\` (\`${DIST_TAG}\`)"
|
||||
echo ""
|
||||
echo "- npmjs: \`npm install -g ${NAME}@${VERSION}\`"
|
||||
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --registry ${GITEA_REGISTRY}\`"
|
||||
# Scoped, never a bare `--registry`: Gitea serves @bitsquare only and
|
||||
# does not proxy npmjs, so a bare flag sends every transitive
|
||||
# dependency to a registry that has never heard of them.
|
||||
echo "- Gitea: \`npm install -g ${NAME}@${VERSION} --@bitsquare:registry=${GITEA_REGISTRY}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -22,6 +22,13 @@ publish order matters — see *Releasing*.
|
||||
`.nopyrc.json` names it in `cubePackages`, and the loader reads it out of
|
||||
`node_modules`. There is no `cubes/` directory at the repo root any more.
|
||||
|
||||
## Documenting
|
||||
|
||||
Be modest. Size the write-up to the change: most work needs none, and a small
|
||||
module never earns a section in `docs/API.md`. Where a reason is genuinely
|
||||
non-obvious, one comment next to the code beats three paragraphs in a document
|
||||
nobody re-reads. Document the surprising, not the obvious.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
@@ -32,6 +39,8 @@ pnpm run lint # biome check . (lint:fix / lint:ci varian
|
||||
pnpm test # vitest run, every package with tests
|
||||
pnpm run test:coverage # vitest with the coverage gate
|
||||
pnpm run coverage:summary # renders the last coverage run as a Markdown table
|
||||
pnpm run registry:status # what is on Gitea vs npmjs, and what is Gitea-only
|
||||
pnpm run try:snapshot # install a published snapshot into a temp project and run it
|
||||
```
|
||||
|
||||
Single package / single test:
|
||||
@@ -95,14 +104,19 @@ One pass per invocation, `nopy.main.ts` orchestrating:
|
||||
if no config file exists anywhere — which is why `nopy.cli.ts` calls it lazily
|
||||
inside the action, so `--help`/`--version` work outside a project.
|
||||
2. **`cubes/packages.ts`** — `resolveCubePackages()` turns each `CubePackageRef`
|
||||
into a package root plus the directories its `nopy.cubes` field declares.
|
||||
into a package root plus its cube directories. The location is a **convention**:
|
||||
`<root>/cubes`, so a bundle needs no nopy-specific `package.json` field at all.
|
||||
`nopy.cubes` survives only as an override, for the bundle whose cubes are
|
||||
elsewhere (`dist/cubes` after a build, say) — absent means the default, but
|
||||
present-and-malformed is an error rather than a fall back, since saying
|
||||
something that does not parse is not the same as saying nothing.
|
||||
Resolution goes through `createRequire(...).resolve.paths()` + `existsSync`,
|
||||
deliberately bypassing the `exports` map: a bundle ships directories and has
|
||||
no entry point to declare. `existsSync` also follows the symlink pnpm plants
|
||||
at `node_modules/<name>`, which a `readdir` scan skips outright (it reports
|
||||
`isSymbolicLink()`, not `isDirectory()`). A missing package, an unreadable
|
||||
manifest, a missing `nopy.cubes`, a directory that does not exist, and an
|
||||
entry pointing outside the package root are all errors, never silent skips.
|
||||
manifest, no cube directory found, and an entry pointing outside the package
|
||||
root are all errors, never silent skips.
|
||||
Duplicate refs are deduped here, last-wins, because `mergeValue` only dedupes
|
||||
arrays of primitives and these are objects.
|
||||
3. **`cubes/loader.ts`** — `findCubeRoots()` unions `config.cubeDirs`, the
|
||||
@@ -208,10 +222,80 @@ result with Zod and falls back to defaults instead of throwing. `VAULT_ROOT` in
|
||||
the environment beats the config file. Encryption shells out to `age` /
|
||||
`age-keygen` / `ssh-keygen`, which must be on `PATH`.
|
||||
|
||||
### Updating
|
||||
|
||||
`nopy.update.ts` and `keyman.update.ts` are two near-identical copies of one
|
||||
module: derive the channel from the running version (`-main.` → `main`, any
|
||||
other prerelease → `next`, clean → `latest`), resolve the registry from
|
||||
`npm config get @bitsquare:registry`, read `dist-tags` off the packument with a
|
||||
plain `fetch`, compare with semver. Nothing about the channel is stored — the
|
||||
version you are running is the one piece of state that is always right, so an
|
||||
upgrade cannot silently move you to a different channel.
|
||||
|
||||
They back a `self-update` subcommand and a once-a-day startup check whose hint
|
||||
goes to **stderr**, so `--json` and `--print-only` stay machine-readable. The
|
||||
cache is `~/.nopy/update-check.json` / `~/.keyman/update-check.json`; a
|
||||
mismatched channel or registry in the cache is never treated as fresh. The
|
||||
check is disabled whenever `CI` is set.
|
||||
|
||||
The install command uses `--@bitsquare:registry=<url>`, never `--registry`:
|
||||
Gitea serves the `@bitsquare` scope and does **not** proxy npmjs, so a global
|
||||
`--registry` would send every transitive dependency to a registry that has never
|
||||
heard of them. Verified — `npm i -g @bitsquare/nopy@main --@bitsquare:registry=…`
|
||||
pulls `nopy-cube` from Gitea and the other 55 packages from npmjs. pnpm accepts
|
||||
the same flag; the `npm_config_@bitsquare:registry` env var does not work with
|
||||
pnpm and is not used.
|
||||
|
||||
The duplication between the two modules is deliberate: keyman shares no internal
|
||||
library with nopy, and a fifth workspace package for ~250 lines would add
|
||||
another edge to the publish order. Extract it if a third CLI appears.
|
||||
|
||||
## Releasing
|
||||
|
||||
Tag-driven, one package at a time; see `README.PUBLISH.md`.
|
||||
|
||||
### Registry resolution
|
||||
|
||||
The repo commits a root `.npmrc` mapping `@bitsquare:registry` to the Gitea
|
||||
registry, so every npm/pnpm command run from the repo — global installs
|
||||
included, since npm reads the project file for those too — resolves the scope
|
||||
from Gitea. `.gitignore` ignores `.npmrc` generally (the workflows write
|
||||
credentials to `.npmrc-gitea` / `.npmrc-release`) and carries a `!/.npmrc`
|
||||
negation for the root file, which holds the mapping and no token.
|
||||
|
||||
Not a trade against npmjs: Gitea is a strict superset for this scope, since
|
||||
`release.yml` publishes to both and `publish-snapshot.yml` adds a `main`
|
||||
snapshot per push. It cannot affect `pnpm install` either — every `@bitsquare`
|
||||
range in the workspace is `workspace:*` resolving to `link:`, so nothing in the
|
||||
tree is fetched from that scope.
|
||||
|
||||
Two consequences: a bare `npm view @bitsquare/…` from the repo now answers for
|
||||
**Gitea**, and an *untagged* install resolves to nothing, because Gitea
|
||||
publishes no `latest` tag yet — always name `@main` or `@next`.
|
||||
`pnpm run registry:status` prints both registries side by side and marks the
|
||||
versions Gitea has that npmjs does not.
|
||||
|
||||
The sharp edge is in CI. `@scope:registry` is resolved *before* `registry` for a
|
||||
scoped package, so the scoped key beats a `--registry` flag; and a project
|
||||
`.npmrc` outranks the userconfig the workflows write. With the committed file in
|
||||
place, `pnpm publish --registry <npmjs>` was measured uploading to **Gitea**, and
|
||||
the `npm view --registry <npmjs>` guard answered from Gitea and skipped the npmjs
|
||||
publish. Both workflows now `rm -f .npmrc` after checkout *and* pass
|
||||
`--@bitsquare:registry=<url>` on every publish and lookup; either alone is
|
||||
sufficient, and both were verified with `pnpm publish --dry-run`. This is the
|
||||
same reason `self-update` never emits a bare `--registry`.
|
||||
|
||||
**Versions are `0.x.y`, not `1.0.0-alphaN`.** The dist-tag rule in `release.yml`
|
||||
is mechanical — anything with a `-` goes out as `next` — so while every package
|
||||
carried an `alphaN` suffix, `latest` never moved. `latest` on npmjs pointed at
|
||||
`1.0.0-alpha5` only because npmjs sets it on a package's *first* publish
|
||||
regardless of `--tag`; on Gitea it did not exist at all. Note that `npm view
|
||||
<name>` against a registry with no `latest` tag prints nothing and exits **0**,
|
||||
which is why this looked like a working lookup. (`npm view <name>@<version>`
|
||||
does exit 1 for a missing version, so the workflows' idempotency guards are
|
||||
fine.) All four packages were reset to `0.5.0`; `1.0.0-alpha5` stays the
|
||||
numerically highest version on npmjs, so install with an explicit `@latest`.
|
||||
|
||||
- Push to `main` → `publish-snapshot.yml` publishes every package to the Gitea
|
||||
registry as `<version>-main.<run>.g<sha>` under the `main` dist-tag. The
|
||||
version is set on the runner with `npm pkg set` and never committed.
|
||||
@@ -223,6 +307,18 @@ Tag-driven, one package at a time; see `README.PUBLISH.md`.
|
||||
|
||||
So: bump `packages/<pkg>/package.json`, land it on `main`, then tag that commit.
|
||||
|
||||
Both workflows also stamp `buildInfo.commit` (the 7-char sha) into the manifest
|
||||
with the same `npm pkg set`, never committed either — the snapshot loop stamps
|
||||
every package, and the release step stamps whichever one the tag named. Both
|
||||
CLIs append it to `--version` in parentheses — `0.5.0 (ab12cd7)` — and print the
|
||||
bare version when the field is absent, which is every run from source. The
|
||||
version string itself is untouched: `nopy.cli.ts` and `keyman.cli.ts` decorate
|
||||
only the string they print, while `updateNotice()` and `selfUpdate()` keep
|
||||
reading the raw `version`, so channel derivation never sees the annotation. An
|
||||
unknown top-level key is ignored by npm and `package.json` is always packed, so
|
||||
nothing in `files` had to change. The two CLIs are kept in step here for the
|
||||
same reason their update modules are duplicated rather than shared.
|
||||
|
||||
Three things the `workspace:*` links added, all of them non-obvious:
|
||||
|
||||
- **`pnpm publish`, never `npm publish`.** `link-workspace-packages` is unset and
|
||||
@@ -248,17 +344,25 @@ Three things the `workspace:*` links added, all of them non-obvious:
|
||||
built pyinfra command, so `log.verbosity` / `log.debug` in `.nopyrc.json`
|
||||
currently have no effect. Treat `docs/REFACTORING.md` as a plan, not a record.
|
||||
|
||||
The publish-lane changes above have been verified locally (pack, npm-install of
|
||||
the tarballs into a throwaway tree, run) but have **never run against the Gitea
|
||||
registry**. Burn a throwaway version there before the first real release.
|
||||
The publish lane has now run against the Gitea registry: all four packages are
|
||||
there under `@main`, and `pnpm run try:snapshot` installs them into a throwaway
|
||||
project with npm and runs the binary. The npmjs lane has only ever published
|
||||
`@bitsquare/nopy`; `keyman`, `nopy-cube` and `cubes-core` have never been
|
||||
released there, so the *check linked deps are released* guard in `release.yml`
|
||||
will stop the first `nopy` release until `nopy-cube` ships.
|
||||
|
||||
Nothing checks that a bundle and the CLI reading it are compatible versions;
|
||||
`nopy.engines` was considered and deferred. `docs/CUBE-PACKAGES.md` is where all
|
||||
of this came from and is now a record of what was built, including what differed
|
||||
from the plan.
|
||||
|
||||
`docs/API.md` predates several refactors and still describes `Cube` and
|
||||
`Manifest` as plain interfaces with a `key` field; the code has a `Cube` class
|
||||
keyed on `id`. The `cubePackages` and `CubeSource` sections added for this work
|
||||
are accurate; treat the rest of that file with suspicion. `DOCS-AUDIT.md` tracks
|
||||
the wider drift.
|
||||
`docs/API.md` was regenerated against the source and now covers every export in
|
||||
`src/index.ts` plus the authoring package; its *Known gaps* section is the short
|
||||
list of behaviour that surprises a reader (`--json` printing nothing on success,
|
||||
`DeployCall.dependencies` always empty, `ExecutionResult.stdout` never populated,
|
||||
no cycle detection, and `self-update` reporting an empty dist-tag as an
|
||||
unreachable registry). `CubePackageRef` is referenced by the exported
|
||||
`NopyConfig` but is not itself re-exported, so a consumer cannot name the type —
|
||||
one line, not yet fixed. `DOCS-AUDIT.md` tracks the drift in the remaining
|
||||
documents; §2.9 (the nopy README shipping yarn-workspace instructions to npmjs)
|
||||
is closed, so the keyman README (§2.10) is now the worst of them.
|
||||
|
||||
+55
-7
@@ -17,8 +17,14 @@ state.
|
||||
Findings closed since are marked **✅ … fixed** and keep their original text as
|
||||
the record of what was wrong. So far: §1.1 (`--use-defaults`), §2.2
|
||||
(`getDefaults()`), §2.1 (precedence — the second half closed differently than
|
||||
proposed), §4.2 (password on stdout — points 1 and 2 of 3), §4.3 (what a session
|
||||
records), part of §3.5 (dead exports), and one bullet of §6.4.
|
||||
proposed), §3 in full (`docs/API.md`, regenerated), §4.2 (password on stdout —
|
||||
points 1 and 2 of 3), §4.3 (what a session records), §2.9 (the nopy README's
|
||||
yarn install instructions), and one bullet of §6.4.
|
||||
|
||||
Closing §3 also settled the documentation half of several findings elsewhere
|
||||
without touching their underlying cause: §1.2, §1.3, §1.5, §2.3, §2.7, §4.4 and
|
||||
§6.5 are each now stated accurately in `docs/API.md`, but the code still behaves
|
||||
as those findings describe and they stay open.
|
||||
|
||||
---
|
||||
|
||||
@@ -26,7 +32,7 @@ records), part of §3.5 (dead exports), and one bullet of §6.4.
|
||||
|
||||
- [1. Documented features that do not exist](#1-documented-features-that-do-not-exist)
|
||||
- [2. Documented behaviour that differs from the code](#2-documented-behaviour-that-differs-from-the-code)
|
||||
- [3. `docs/API.md` — systematic drift](#3-docsapimd--systematic-drift)
|
||||
- [3. ✅ `docs/API.md` — systematic drift — fixed](#3--docsapimd--systematic-drift--fixed)
|
||||
- [4. Undocumented behaviour](#4-undocumented-behaviour)
|
||||
- [5. Cube documentation](#5-cube-documentation)
|
||||
- [6. Defects found while verifying](#6-defects-found-while-verifying)
|
||||
@@ -136,6 +142,10 @@ not. Two cubes that depend on each other recurse until the stack overflows —
|
||||
recursive call. `docs/API.md:160` still promises `Error` "if ... circular
|
||||
dependency detected".
|
||||
|
||||
> The `API.md` promise is gone (§3): the regenerated file states that ordering
|
||||
> falls out of the recursion and that there is no cycle detection. The README
|
||||
> claims and the missing detection itself both stand.
|
||||
|
||||
---
|
||||
|
||||
## 2. Documented behaviour that differs from the code
|
||||
@@ -256,6 +266,11 @@ anyone copying it gets bare `UPDATE` / `PACKAGES` keys as prompt labels instead
|
||||
of the sentences they wrote. `docs/API.md:610` happens to use the working order —
|
||||
the two documents disagree, and neither mentions that it matters.
|
||||
|
||||
> `docs/API.md` now says so explicitly, next to its manifest example, with the
|
||||
> zod 4.4.3 measurement (§3). The README example and the 15 affected manifests
|
||||
> are untouched, and the one-line fix in `nopy.prompts.ts` — read through the
|
||||
> `ZodDefault` wrapper — is still the better answer.
|
||||
|
||||
15 of the 22 cubes in `cubes/` are affected; among them
|
||||
`net:tailscale` (all 4 fields), `runtime:nodevm` (all 4), `user:add` (all 4),
|
||||
`ssh:keygen` (all 4) and `admin:locale` (all 4).
|
||||
@@ -333,7 +348,14 @@ hooks, presenting explicit parameters as a hook-only capability.
|
||||
`params` scope that `exec()` writes to. The two mechanisms are identical in this
|
||||
respect; per §2.1 both outrank user prompts.
|
||||
|
||||
### 2.9 🟠 nopy README installation section describes the wrong package manager
|
||||
### 2.9 ✅ nopy README installation section describes the wrong package manager — **fixed**
|
||||
|
||||
> **Resolved.** The yarn-workspace block is gone. The section now opens with
|
||||
> `npm install -g @bitsquare/nopy` (and the pnpm equivalent), documents the
|
||||
> `latest` / `next` / `main` channels, shows the `@bitsquare` scope mapping
|
||||
> needed to install from Gitea, and gains an *Upgrading* section covering
|
||||
> `nopy self-update` and the `NOPY_*` env vars. The finding below is kept as the
|
||||
> record of what was wrong.
|
||||
|
||||
`README.md:248-279` says "This package is part of a **yarn** workspace monorepo",
|
||||
then gives `yarn install`, `yarn workspace @bitsquare/nopy build`,
|
||||
@@ -388,7 +410,30 @@ first.
|
||||
|
||||
---
|
||||
|
||||
## 3. `docs/API.md` — systematic drift
|
||||
## 3. ✅ `docs/API.md` — systematic drift — **fixed**
|
||||
|
||||
> **Resolved by regenerating the file**, which is what §3's own recommendation
|
||||
> asked for — the drift was structural rather than a set of stale lines, so
|
||||
> patching would have left the shape wrong. Every export in `src/index.ts` was
|
||||
> re-read against its source and the file now covers all of them: the authoring
|
||||
> package as its own section, `BuildContext` in place of the phantom Builder
|
||||
> Module, and the variables, history and prompts modules that had no entry at
|
||||
> all. The findings below are kept as the record of what was wrong.
|
||||
>
|
||||
> Three things were deliberately added rather than merely corrected. A
|
||||
> **Known gaps** section states the behaviour a reader would otherwise take on
|
||||
> trust — `logConfigToFlags` being unconsumed (§1.3), `--json` printing nothing
|
||||
> on success (§1.2), the absent cycle detection (§1.5, §6.5), `DeployCall.dependencies`
|
||||
> always being `[]`, `ExecutionResult.stdout`/`stderr` never being populated, and
|
||||
> hook variables not being schema-validated (§2.7). The `.describe()`/`.default()`
|
||||
> ordering hazard (§2.3) is called out where the manifest example lives, with the
|
||||
> zod 4.4.3 measurement. And `-P` is documented alongside the rest of the CLI
|
||||
> (§4.4 — the README half of that finding stands).
|
||||
>
|
||||
> One thing surfaced while writing it and is **not** fixed: `CubePackageRef` is
|
||||
> referenced by the exported `NopyConfig` but is not itself re-exported from
|
||||
> `src/index.ts`, so a consumer cannot name the type. Recorded in the file as a
|
||||
> note.
|
||||
|
||||
`docs/API.md` documents an earlier architecture. It is not a matter of
|
||||
individual stale lines: the two central type definitions, one whole module, and
|
||||
@@ -786,8 +831,11 @@ deletion, but neither can stay documented as working.
|
||||
through the `ZodDefault` wrapper in `nopy.prompts.ts`, or fix the ordering in all
|
||||
14 manifests and the README example. The first is one line and cannot regress.
|
||||
|
||||
**5 — Regenerate `docs/API.md` (§3).** Too far gone to patch: two core types,
|
||||
one whole module, and two functions describe code that no longer exists.
|
||||
**5 — ~~Regenerate `docs/API.md` (§3).~~ Done.** Rewritten against the source
|
||||
rather than patched, and extended to the exports that never had an entry
|
||||
(variables, history, prompts, the authoring package). One new finding came out of
|
||||
it: `CubePackageRef` is not re-exported from `src/index.ts` although `NopyConfig`
|
||||
refers to it — a one-line fix, left for whoever next touches the export list.
|
||||
|
||||
**6 — Cube docs (§5) and the two missing READMEs.** `service/autostart` is the
|
||||
worst — its README belongs to a different cube, and its `deploy.py` does not run
|
||||
|
||||
+223
-7
@@ -14,6 +14,9 @@ shipped. If you only want to cut a release, jump to
|
||||
- [Secrets](#secrets)
|
||||
- [Registry authentication in the workflows](#registry-authentication-in-the-workflows)
|
||||
- [Installing the packages](#installing-the-packages)
|
||||
- [Resolving from Gitea in this repo](#resolving-from-gitea-in-this-repo)
|
||||
- [Testing a snapshot before you release](#testing-a-snapshot-before-you-release)
|
||||
- [Upgrading an installed CLI](#upgrading-an-installed-cli)
|
||||
- [Design decisions](#design-decisions)
|
||||
- [Checking things locally](#checking-things-locally)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
@@ -176,15 +179,40 @@ and `continue-on-error: true` — and can never be the reason a run goes red.
|
||||
|
||||
| Source | Version | Registry | dist-tag |
|
||||
| ----------------------------------------- | ----------------------------- | ------------ | -------- |
|
||||
| push to `main` | `1.0.0-main.42.g736c012` | Gitea | `main` |
|
||||
| tag `nopy-v1.2.0` | `1.2.0` | Gitea, npmjs | `latest` |
|
||||
| tag `nopy-v1.2.0-rc.1` | `1.2.0-rc.1` | Gitea, npmjs | `next` |
|
||||
| push to `main` | `0.5.0-main.42.g736c012` | Gitea | `main` |
|
||||
| tag `nopy-v0.6.0` | `0.6.0` | Gitea, npmjs | `latest` |
|
||||
| tag `nopy-v0.6.0-rc.1` | `0.6.0-rc.1` | Gitea, npmjs | `next` |
|
||||
|
||||
The rule for the dist-tag is mechanical: a version containing a prerelease part
|
||||
(anything with a `-` in it) goes out as `next` and is marked as a prerelease on
|
||||
the Gitea release; anything else goes out as `latest`. There is no way to publish
|
||||
a prerelease over `latest` by accident.
|
||||
|
||||
### Why 0.x and not 1.0.0-alphaN
|
||||
|
||||
The packages used to be numbered `1.0.0-alpha5`, `1.0.0-alpha0` and so on. Every
|
||||
one of those is a prerelease, so the rule above sent every release to `next` and
|
||||
**`latest` never moved**. That is a quiet failure rather than a loud one: on
|
||||
npmjs `latest` happened to point at `1.0.0-alpha5` only because npmjs sets
|
||||
`latest` on a package's *first* publish whatever `--tag` says, and it would have
|
||||
stayed pinned there through every subsequent alpha. On Gitea, which has no such
|
||||
fallback, `latest` did not exist at all — and `npm view @bitsquare/nopy` against
|
||||
a registry with no `latest` prints nothing and exits **0**, so it looks like a
|
||||
successful lookup of a package with no data.
|
||||
|
||||
`0.x.y` says the same thing about stability that `1.0.0-alphaN` was trying to
|
||||
say, while leaving the prerelease slot free for actual release candidates. So
|
||||
`latest` rolls on every release, `next` means what it says, and no dist-tag has
|
||||
to be moved by hand.
|
||||
|
||||
> **One-off consequence of the switch.** `1.0.0-alpha5` is semver-*greater* than
|
||||
> any `0.x`, and it is already on npmjs. Publishing `0.5.0` moves the `latest`
|
||||
> tag to it correctly, but the alpha remains the numerically highest version on
|
||||
> the registry. Install with an explicit tag (`npm i -g @bitsquare/nopy@latest`,
|
||||
> which follows the tag and will downgrade), not with `npm update -g`. Consider
|
||||
> `npm deprecate '@bitsquare/nopy@1.0.0-alpha5' 'Superseded by the 0.x line'` so
|
||||
> nobody lands on it by pinning.
|
||||
|
||||
## Snapshots
|
||||
|
||||
Every commit that lands on `main` publishes every package to the Gitea registry,
|
||||
@@ -391,6 +419,170 @@ To track snapshots in another project:
|
||||
pnpm add @bitsquare/nopy@main
|
||||
```
|
||||
|
||||
> Always map the **scope**, never set a bare `registry=`. The Gitea registry
|
||||
> serves `@bitsquare` packages and does not proxy npmjs, so a global
|
||||
> `--registry` sends `commander`, `execa`, `zod` and everything else to a
|
||||
> registry that has never heard of them. The CLI's own `self-update` builds
|
||||
> `--@bitsquare:registry=<url>` for the same reason.
|
||||
|
||||
## Resolving from Gitea in this repo
|
||||
|
||||
This repository ships a root [`.npmrc`](.npmrc) that maps the scope:
|
||||
|
||||
```ini
|
||||
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
```
|
||||
|
||||
So any npm or pnpm command run from inside the repo resolves `@bitsquare/*` from
|
||||
Gitea, with no flags — including a global install, since npm reads the project
|
||||
`.npmrc` for those too:
|
||||
|
||||
```sh
|
||||
npm install -g @bitsquare/nopy@main # the newest snapshot, no flags needed
|
||||
```
|
||||
|
||||
This is not a trade against npmjs. Gitea is a strict **superset** of it for this
|
||||
scope: `release.yml` publishes to both, `publish-snapshot.yml` pushes a `main`
|
||||
snapshot to Gitea on every push, and today three of the four packages exist
|
||||
*only* there. Pointing the scope at Gitea gains the snapshots and loses nothing.
|
||||
|
||||
`.npmrc` is otherwise gitignored — the publish workflows write credentials into
|
||||
`.npmrc-gitea` / `.npmrc-release` — so `.gitignore` carries a `!/.npmrc`
|
||||
negation for the root file specifically. **It contains the scope mapping and
|
||||
nothing else.** Reads are anonymous; no token belongs in a committed file.
|
||||
|
||||
It cannot affect `pnpm install`: every `@bitsquare` dependency in the workspace
|
||||
is a `workspace:*` range that resolves to a `link:`, so nothing in the tree is
|
||||
ever fetched from that scope. Verified with `pnpm install --frozen-lockfile`.
|
||||
|
||||
Two consequences worth knowing:
|
||||
|
||||
- **An untagged install resolves to nothing.** Gitea currently publishes no
|
||||
`latest` dist-tag, so `npm i -g @bitsquare/nopy` finds no version — and npm
|
||||
reports that by printing nothing and exiting 0. Always name a tag (`@main`,
|
||||
`@next`) until the first `0.x` release lands. See
|
||||
[Why 0.x and not 1.0.0-alphaN](#why-0x-and-not-100-alphan).
|
||||
- **Bare lookups now answer for Gitea.** `npm view @bitsquare/nopy …` run from
|
||||
the repo queries Gitea. Pass `--registry https://registry.npmjs.org/` when you
|
||||
specifically mean npmjs.
|
||||
|
||||
To see both registries at once — which versions exist where, and which are on
|
||||
Gitea only and therefore still testable and still un-published:
|
||||
|
||||
```sh
|
||||
pnpm run registry:status
|
||||
pnpm run registry:status -- --json
|
||||
```
|
||||
|
||||
Working **outside** the repo, set the same mapping globally once:
|
||||
|
||||
```sh
|
||||
npm config set @bitsquare:registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
npm config delete @bitsquare:registry # back to npmjs
|
||||
```
|
||||
|
||||
`nopy self-update` reads that key too (`npm config get @bitsquare:registry`), so
|
||||
a CLI installed from Gitea keeps checking Gitea for its own updates with nothing
|
||||
else configured.
|
||||
|
||||
### Why the publish jobs delete it
|
||||
|
||||
A scoped mapping is not just another way to say `--registry`. For a **scoped**
|
||||
package npm resolves `@scope:registry` *before* `registry`, so the scoped key
|
||||
wins no matter how the plain one was set — including on the command line. And a
|
||||
project `.npmrc` outranks the userconfig the workflows write.
|
||||
|
||||
Left in place, that combination silently redirects the npmjs release lane:
|
||||
|
||||
```console
|
||||
$ pnpm publish --tag latest --access public --registry https://registry.npmjs.org/ --dry-run
|
||||
📦 @bitsquare/nopy@0.5.0 → https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
```
|
||||
|
||||
Not hypothetical — that is the workflow's own command, measured. The
|
||||
`npm view … --registry <npmjs>` idempotency guard inverts the same way: it
|
||||
answers from Gitea, finds the version already there, and **skips the npmjs
|
||||
publish entirely**. A release that reports success and shipped nothing.
|
||||
|
||||
Both publish workflows therefore `rm -f .npmrc` right after checkout, and every
|
||||
publish and lookup names its registry as `--@bitsquare:registry=<url>`. Either
|
||||
fix alone is sufficient — both are verified independently — and the pair means a
|
||||
command added later cannot quietly inherit the wrong registry. Nothing else in
|
||||
the job is affected: every `@bitsquare` range in the workspace is `workspace:*`,
|
||||
so no install resolves through that scope.
|
||||
|
||||
## Testing a snapshot before you release
|
||||
|
||||
Every push to `main` publishes a snapshot, so the rehearsal for a release is to
|
||||
install one the way a stranger would:
|
||||
|
||||
```sh
|
||||
pnpm run try:snapshot # @main from Gitea
|
||||
pnpm run try:snapshot -- --tag latest # a release, from Gitea
|
||||
pnpm run try:snapshot -- --registry https://registry.npmjs.org/
|
||||
pnpm run try:snapshot -- --keep # keep the directory
|
||||
```
|
||||
|
||||
`scripts/try-snapshot.mjs` builds a throwaway project in a temp directory,
|
||||
points the `@bitsquare` scope at the registry, installs `@bitsquare/nopy` and
|
||||
`@bitsquare/cubes-core` at that tag, and then:
|
||||
|
||||
- asserts the installed `nopy` declares a **concrete** `nopy-cube` version
|
||||
rather than a leaked `workspace:*` range;
|
||||
- prints the three resolved versions, so you can see which commit you are on;
|
||||
- runs `nopy --version`;
|
||||
- runs `nopy install -P -D` with stdin closed and asserts the cube-selection
|
||||
prompt listed cubes from the bundle — which only happens if the loader
|
||||
resolved the package out of `node_modules` and imported every manifest.
|
||||
|
||||
It uses **npm**, not pnpm, on purpose: npm is the client that rejects a leaked
|
||||
`workspace:` range, so a clean install here is the stronger proof. This is the
|
||||
check `verify-pack.mjs` cannot be — that one inspects a local tarball, this one
|
||||
goes to the real registry and runs the real binary.
|
||||
|
||||
The directory is deleted on success and left behind on failure, with its path
|
||||
printed.
|
||||
|
||||
## Upgrading an installed CLI
|
||||
|
||||
Both CLIs can update themselves:
|
||||
|
||||
```sh
|
||||
nopy self-update
|
||||
keyman self-update
|
||||
```
|
||||
|
||||
Each derives its channel from the version it is running — a `-main.` prerelease
|
||||
came from the snapshot workflow, any other prerelease from `next`, a clean
|
||||
version from `latest` — so an upgrade keeps you on the channel you installed
|
||||
from instead of quietly moving you to another one. The registry comes from
|
||||
`npm config get @bitsquare:registry`, so an install from Gitea checks Gitea
|
||||
without any further configuration. The package manager is detected from the
|
||||
install path (npm, pnpm, yarn or bun), so the update does not leave two copies
|
||||
on the `PATH`.
|
||||
|
||||
```sh
|
||||
nopy self-update --dry-run # print the command, change nothing
|
||||
nopy self-update --force # reinstall even when up to date
|
||||
nopy self-update --channel next # switch channel
|
||||
nopy self-update --registry <url> # check somewhere else
|
||||
```
|
||||
|
||||
Once a day each CLI checks its channel at startup and prints a one-line hint to
|
||||
**stderr** when something newer exists — never stdout, so `--json` and
|
||||
`--print-only` stay machine-readable. Results are cached in
|
||||
`~/.nopy/update-check.json` and `~/.keyman/update-check.json`; an unreachable
|
||||
registry gets 1.5 seconds and is then ignored. The check is off whenever `CI` is
|
||||
set, and `NOPY_NO_UPDATE_CHECK=1` / `KEYMAN_NO_UPDATE_CHECK=1` turn it off
|
||||
explicitly. `NOPY_REGISTRY`, `NOPY_REGISTRY_TOKEN` and `NOPY_PACKAGE_MANAGER`
|
||||
(and the `KEYMAN_` equivalents) override the three things it detects.
|
||||
|
||||
The logic lives in `packages/nopy/src/nopy.update.ts` and
|
||||
`packages/keyman/src/keyman.update.ts` — two near-identical copies. keyman
|
||||
shares no internal library with nopy by design, and a fifth workspace package
|
||||
for ~250 lines would add another edge to the publish order for nothing. If a
|
||||
third CLI appears, extract it then.
|
||||
|
||||
## Design decisions
|
||||
|
||||
**Every publish is idempotent.** Each step asks the registry whether that exact
|
||||
@@ -452,6 +644,19 @@ node scripts/publish-order.mjs # the order to release in
|
||||
node scripts/linked-deps.mjs packages/nopy # what must be on the registry first
|
||||
```
|
||||
|
||||
See what is on each registry, and which versions Gitea has that npmjs does not:
|
||||
|
||||
```sh
|
||||
pnpm run registry:status
|
||||
```
|
||||
|
||||
Rehearse an install against a registry that has actually been published to —
|
||||
see [Testing a snapshot](#testing-a-snapshot-before-you-release):
|
||||
|
||||
```sh
|
||||
pnpm run try:snapshot
|
||||
```
|
||||
|
||||
Rehearse an install the way a stranger gets one, without publishing anything.
|
||||
Use **npm**, not pnpm: npm is the one that rejects a leaked `workspace:` range,
|
||||
so a clean install here is the real proof.
|
||||
@@ -475,14 +680,25 @@ nopy --help
|
||||
npm unlink -g @bitsquare/nopy
|
||||
```
|
||||
|
||||
Check that a version is not already taken before you tag:
|
||||
Check that a version is not already taken before you tag. The repo's `.npmrc`
|
||||
points the scope at Gitea, so the bare lookup answers for Gitea and npmjs is the
|
||||
one that needs the explicit flag:
|
||||
|
||||
```sh
|
||||
npm view @bitsquare/nopy@1.2.0 version # npmjs
|
||||
npm view @bitsquare/nopy@1.2.0 version \
|
||||
--registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
npm view @bitsquare/nopy@1.2.0 version # Gitea
|
||||
npm view @bitsquare/nopy@1.2.0 version --registry https://registry.npmjs.org/ # npmjs
|
||||
```
|
||||
|
||||
Or both registries, every package, in one table:
|
||||
|
||||
```sh
|
||||
pnpm run registry:status
|
||||
```
|
||||
|
||||
> `npm view <name>@<version>` exits 1 for a version that does not exist, so it is
|
||||
> a sound check. `npm view <name>` — no version — is **not**: against a registry
|
||||
> with no `latest` tag it prints nothing and exits 0.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
|
||||
@@ -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 .",
|
||||
|
||||
@@ -18,9 +18,9 @@ Then name it in `.nopyrc.json`:
|
||||
}
|
||||
```
|
||||
|
||||
`nopy` resolves the package from the directory of the config file that named it,
|
||||
reads `nopy.cubes` out of its `package.json`, and scans those directories exactly
|
||||
as it scans a `cubeDirs` entry. Nothing has to be linked or copied.
|
||||
`nopy` resolves the package from the directory of the config file that named it
|
||||
and scans its `cubes/` directory exactly as it scans a `cubeDirs` entry. Nothing
|
||||
has to be linked or copied.
|
||||
|
||||
## What is in it
|
||||
|
||||
|
||||
@@ -1,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
export * from './keyman.main.js';
|
||||
export type {
|
||||
Channel,
|
||||
CommandRunner,
|
||||
PackageManager,
|
||||
SelfUpdateResult,
|
||||
UpdateCache,
|
||||
UpdateStatus,
|
||||
} from './keyman.update.js';
|
||||
export {
|
||||
buildSelfUpdateCommand,
|
||||
channelForVersion,
|
||||
checkForUpdate,
|
||||
DEFAULT_CHECK_INTERVAL_MS,
|
||||
detectPackageManager,
|
||||
fetchChannelVersion,
|
||||
formatCommand,
|
||||
formatUpdateNotice,
|
||||
getUpdateCachePath,
|
||||
isUpdateCheckDisabled,
|
||||
NPMJS_REGISTRY,
|
||||
normalizeRegistry,
|
||||
PACKAGE_NAME,
|
||||
readUpdateCache,
|
||||
resolveRegistry,
|
||||
selfUpdate,
|
||||
updateNotice,
|
||||
writeUpdateCache,
|
||||
} from './keyman.update.js';
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { keyman } from './keyman.main.js';
|
||||
import type { Channel } from './keyman.update.js';
|
||||
import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js';
|
||||
|
||||
const { version, buildInfo } = createRequire(import.meta.url)('../package.json') as {
|
||||
version: string;
|
||||
buildInfo?: { commit?: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* What `--version` prints. `version` itself stays untouched everywhere else —
|
||||
* the commit is an annotation, stamped into `package.json` on the runner by the
|
||||
* publish workflows and absent when running from source.
|
||||
*/
|
||||
const versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
/** Reads `--flag value` out of argv, or undefined when the flag is absent */
|
||||
function flagValue(name: string): string | undefined {
|
||||
const index = args.indexOf(name);
|
||||
return index === -1 ? undefined : args[index + 1];
|
||||
}
|
||||
|
||||
if (args.includes('--print-config')) {
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
@@ -12,4 +33,50 @@ if (args.includes('--print-config')) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args.includes('--version') || args.includes('-V')) {
|
||||
console.log(versionLabel);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === 'self-update' || args[0] === 'upgrade' || args.includes('--self-update')) {
|
||||
const dryRun = args.includes('--dry-run') || args.includes('-n');
|
||||
try {
|
||||
const result = await selfUpdate({
|
||||
currentVersion: version,
|
||||
channel: flagValue('--channel') as Channel | undefined,
|
||||
registry: flagValue('--registry'),
|
||||
dryRun,
|
||||
force: args.includes('--force') || args.includes('-f'),
|
||||
});
|
||||
|
||||
const { status } = result;
|
||||
console.log(`Installed: ${status.current}`);
|
||||
console.log(`Channel: ${status.channel}`);
|
||||
console.log(`Registry: ${status.registry}`);
|
||||
console.log(`Available: ${status.latest ?? 'unknown'}`);
|
||||
console.log('');
|
||||
|
||||
if (result.ran) {
|
||||
console.log(`Updated to ${status.latest}.`);
|
||||
} else if (dryRun) {
|
||||
console.log(`Would run: ${formatCommand(result.command)}`);
|
||||
} else if (status.latest === null) {
|
||||
console.error(`Could not reach ${status.registry} — nothing was changed.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('Already up to date.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Printed to stderr so it never mixes into machine-read output.
|
||||
const notice = await updateNotice({ currentVersion: version });
|
||||
if (notice) {
|
||||
console.error(`\n${notice}\n`);
|
||||
}
|
||||
|
||||
keyman();
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Update checking and self-update for the keyman CLI
|
||||
*
|
||||
* A near-copy of nopy's `nopy.update` module, differing only in the package it
|
||||
* names and the environment variables it reads. The two CLIs share no internal
|
||||
* library — keyman deliberately stands alone — and a fifth workspace package
|
||||
* for ~250 lines would buy another edge in the publish order for nothing. If a
|
||||
* third CLI ever appears, extract it then.
|
||||
*
|
||||
* @module keyman.update
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import semver from 'semver';
|
||||
|
||||
/** The published package this CLI ships as */
|
||||
export const PACKAGE_NAME = '@bitsquare/keyman';
|
||||
|
||||
/** The npm scope the package lives under, used for the registry config key */
|
||||
export const SCOPE = '@bitsquare';
|
||||
|
||||
/** Where packages resolve from when nothing says otherwise */
|
||||
export const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
|
||||
|
||||
/** Directory under the user's home holding the update-check cache */
|
||||
export const UPDATE_CACHE_DIR = '.keyman';
|
||||
|
||||
/** File name of the update-check cache */
|
||||
export const UPDATE_CACHE_FILE = 'update-check.json';
|
||||
|
||||
/** How long a cached check is considered fresh */
|
||||
export const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** How long the background check may block the CLI */
|
||||
export const DEFAULT_FETCH_TIMEOUT_MS = 1500;
|
||||
|
||||
/** How long `npm config get` may take before the registry falls back to npmjs */
|
||||
export const DEFAULT_CONFIG_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* A dist-tag this project publishes under.
|
||||
*
|
||||
* `latest` is a release, `next` a prerelease (`0.6.0-rc.1`), `main` a snapshot
|
||||
* built from a commit on `main` and published to Gitea only.
|
||||
*/
|
||||
export type Channel = 'latest' | 'next' | 'main';
|
||||
|
||||
/** A package manager that can install a global binary */
|
||||
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||
|
||||
/** Runs a command and resolves with its trimmed stdout */
|
||||
export type CommandRunner = (file: string, args: string[]) => Promise<string>;
|
||||
|
||||
/** The result of an update check */
|
||||
export interface UpdateStatus {
|
||||
/** The version currently running */
|
||||
current: string;
|
||||
/** The version the channel points at, or null if it could not be determined */
|
||||
latest: string | null;
|
||||
/** The channel the current version implies */
|
||||
channel: Channel;
|
||||
/** The registry the check went to */
|
||||
registry: string;
|
||||
/** Whether `latest` is strictly newer than `current` */
|
||||
updateAvailable: boolean;
|
||||
/** Whether the answer came from cache rather than the network */
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
/** The on-disk update-check cache */
|
||||
export interface UpdateCache {
|
||||
/** ISO timestamp of the check */
|
||||
checkedAt: string;
|
||||
/** The channel that was checked */
|
||||
channel: Channel;
|
||||
/** The registry that was checked */
|
||||
registry: string;
|
||||
/** The version the channel pointed at, or null if the lookup found nothing */
|
||||
latest: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the release channel from a version string.
|
||||
*
|
||||
* @param version - a semver version, typically this package's own
|
||||
* @returns the dist-tag that version would have been published under
|
||||
*/
|
||||
export function channelForVersion(version: string): Channel {
|
||||
const parsed = semver.parse(version, { loose: true });
|
||||
|
||||
if (!parsed || parsed.prerelease.length === 0) {
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
return parsed.prerelease.some((part) => part === 'main') ? 'main' : 'next';
|
||||
}
|
||||
|
||||
/** Normalises a registry URL to the trailing-slash form the packument path is appended to */
|
||||
export function normalizeRegistry(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
}
|
||||
|
||||
/** Runs a command through execa and returns its stdout */
|
||||
const defaultRunner: CommandRunner = async (file, args) => {
|
||||
const { stdout } = await execa(file, args, { timeout: DEFAULT_CONFIG_TIMEOUT_MS });
|
||||
return stdout;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the registry `@bitsquare` packages come from.
|
||||
*
|
||||
* `KEYMAN_REGISTRY` wins, then npm's own scoped-registry config, then npmjs.
|
||||
*/
|
||||
export async function resolveRegistry(
|
||||
options: { env?: NodeJS.ProcessEnv; run?: CommandRunner } = {}
|
||||
): Promise<string> {
|
||||
const env = options.env ?? process.env;
|
||||
|
||||
const override = env.KEYMAN_REGISTRY?.trim();
|
||||
if (override) {
|
||||
return normalizeRegistry(override);
|
||||
}
|
||||
|
||||
const run = options.run ?? defaultRunner;
|
||||
try {
|
||||
const stdout = (await run('npm', ['config', 'get', `${SCOPE}:registry`])).trim();
|
||||
// npm prints the string "undefined" for an unset key rather than nothing.
|
||||
if (stdout && stdout !== 'undefined' && stdout !== 'null') {
|
||||
return normalizeRegistry(stdout);
|
||||
}
|
||||
} catch {
|
||||
// npm not on PATH, or the config is unreadable.
|
||||
}
|
||||
|
||||
return NPMJS_REGISTRY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version a dist-tag points at, straight from the registry.
|
||||
*
|
||||
* @returns the version, or null if the registry or the tag has nothing
|
||||
*/
|
||||
export async function fetchChannelVersion(options: {
|
||||
registry: string;
|
||||
channel: Channel;
|
||||
packageName?: string;
|
||||
timeoutMs?: number;
|
||||
token?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<string | null> {
|
||||
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
||||
const packageName = options.packageName ?? PACKAGE_NAME;
|
||||
const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
accept: 'application/vnd.npm.install-v1+json, application/json',
|
||||
};
|
||||
if (options.token) {
|
||||
headers.authorization = `Bearer ${options.token}`;
|
||||
}
|
||||
|
||||
const response = await doFetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { 'dist-tags'?: Record<string, string> };
|
||||
return body['dist-tags']?.[options.channel] ?? null;
|
||||
}
|
||||
|
||||
/** Path of the update-check cache file */
|
||||
export function getUpdateCachePath(homedir: string = os.homedir()): string {
|
||||
return path.join(homedir, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the update-check cache.
|
||||
*
|
||||
* @returns the cache, or null if it is missing or unreadable
|
||||
*/
|
||||
export function readUpdateCache(cachePath: string = getUpdateCachePath()): UpdateCache | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as UpdateCache;
|
||||
return typeof parsed?.checkedAt === 'string' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the update-check cache. Best effort — a read-only home costs a check, not a failure */
|
||||
export function writeUpdateCache(
|
||||
cache: UpdateCache,
|
||||
cachePath: string = getUpdateCachePath()
|
||||
): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
|
||||
} catch {
|
||||
// Ignored on purpose.
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the startup check should be skipped entirely */
|
||||
export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const flag = env.KEYMAN_NO_UPDATE_CHECK?.trim().toLowerCase();
|
||||
if (flag && flag !== '0' && flag !== 'false') {
|
||||
return true;
|
||||
}
|
||||
return Boolean(env.CI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a newer version exists on the current channel.
|
||||
*
|
||||
* Answers from cache when a check happened recently for the same channel and
|
||||
* registry; a failed lookup degrades to the cached answer rather than none.
|
||||
*/
|
||||
export async function checkForUpdate(options: {
|
||||
currentVersion: string;
|
||||
channel?: Channel;
|
||||
registry?: string;
|
||||
force?: boolean;
|
||||
intervalMs?: number;
|
||||
cachePath?: string;
|
||||
now?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
run?: CommandRunner;
|
||||
}): Promise<UpdateStatus> {
|
||||
const {
|
||||
currentVersion,
|
||||
force = false,
|
||||
intervalMs = DEFAULT_CHECK_INTERVAL_MS,
|
||||
cachePath = getUpdateCachePath(),
|
||||
now = Date.now(),
|
||||
env = process.env,
|
||||
} = options;
|
||||
|
||||
const channel = options.channel ?? channelForVersion(currentVersion);
|
||||
const registry = normalizeRegistry(
|
||||
options.registry ?? (await resolveRegistry({ env, run: options.run }))
|
||||
);
|
||||
|
||||
const cache = readUpdateCache(cachePath);
|
||||
const applicable = cache && cache.channel === channel && cache.registry === registry;
|
||||
const age = cache ? now - Date.parse(cache.checkedAt) : Number.POSITIVE_INFINITY;
|
||||
const fresh = applicable && Number.isFinite(age) && age >= 0 && age < intervalMs;
|
||||
|
||||
if (!force && fresh && cache) {
|
||||
return status(currentVersion, cache.latest, channel, registry, true);
|
||||
}
|
||||
|
||||
try {
|
||||
const latest = await fetchChannelVersion({
|
||||
registry,
|
||||
channel,
|
||||
timeoutMs: options.timeoutMs,
|
||||
token: env.KEYMAN_REGISTRY_TOKEN?.trim() || undefined,
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
writeUpdateCache(
|
||||
{ checkedAt: new Date(now).toISOString(), channel, registry, latest },
|
||||
cachePath
|
||||
);
|
||||
return status(currentVersion, latest, channel, registry, false);
|
||||
} catch {
|
||||
return status(
|
||||
currentVersion,
|
||||
applicable && cache ? cache.latest : null,
|
||||
channel,
|
||||
registry,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assembles an {@link UpdateStatus}, deciding whether the remote version wins */
|
||||
function status(
|
||||
current: string,
|
||||
latest: string | null,
|
||||
channel: Channel,
|
||||
registry: string,
|
||||
fromCache: boolean
|
||||
): UpdateStatus {
|
||||
const updateAvailable = Boolean(
|
||||
latest && semver.valid(latest) && semver.valid(current) && semver.gt(latest, current)
|
||||
);
|
||||
return { current, latest, channel, registry, updateAvailable, fromCache };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects which package manager installed this CLI, so `self-update` re-runs
|
||||
* the same one rather than leaving two copies on the PATH.
|
||||
*/
|
||||
export function detectPackageManager(
|
||||
options: { execPath?: string; env?: NodeJS.ProcessEnv } = {}
|
||||
): PackageManager {
|
||||
const env = options.env ?? process.env;
|
||||
|
||||
const override = env.KEYMAN_PACKAGE_MANAGER?.trim().toLowerCase();
|
||||
if (override === 'npm' || override === 'pnpm' || override === 'yarn' || override === 'bun') {
|
||||
return override;
|
||||
}
|
||||
|
||||
const from = (options.execPath ?? process.argv[1] ?? '').replace(/\\/g, '/').toLowerCase();
|
||||
if (from.includes('/pnpm/')) return 'pnpm';
|
||||
if (from.includes('/.bun/')) return 'bun';
|
||||
if (from.includes('/.yarn/') || from.includes('/yarn/')) return 'yarn';
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the command that installs a given channel globally.
|
||||
*
|
||||
* The registry is passed as a **scoped** override rather than `--registry`,
|
||||
* because the Gitea registry serves `@bitsquare` packages and does not proxy
|
||||
* npmjs — a global `--registry` would send every dependency to a registry that
|
||||
* has never heard of them.
|
||||
*/
|
||||
export function buildSelfUpdateCommand(options: {
|
||||
packageManager: PackageManager;
|
||||
channel: Channel;
|
||||
registry: string;
|
||||
packageName?: string;
|
||||
}): { file: string; args: string[] } {
|
||||
const packageName = options.packageName ?? PACKAGE_NAME;
|
||||
const spec = `${packageName}@${options.channel}`;
|
||||
|
||||
const registryArgs =
|
||||
normalizeRegistry(options.registry) === NPMJS_REGISTRY
|
||||
? []
|
||||
: [`--${SCOPE}:registry=${normalizeRegistry(options.registry)}`];
|
||||
|
||||
switch (options.packageManager) {
|
||||
case 'pnpm':
|
||||
return { file: 'pnpm', args: ['add', '--global', spec, ...registryArgs] };
|
||||
case 'yarn':
|
||||
return { file: 'yarn', args: ['global', 'add', spec, ...registryArgs] };
|
||||
case 'bun':
|
||||
return { file: 'bun', args: ['add', '--global', spec, ...registryArgs] };
|
||||
default:
|
||||
return { file: 'npm', args: ['install', '--global', spec, ...registryArgs] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a command as the shell line a user could paste */
|
||||
export function formatCommand(command: { file: string; args: string[] }): string {
|
||||
return [command.file, ...command.args].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the hint printed at startup when an update exists.
|
||||
*
|
||||
* @returns the notice, or null when there is nothing to say
|
||||
*/
|
||||
export function formatUpdateNotice(
|
||||
status: UpdateStatus,
|
||||
packageManager?: PackageManager
|
||||
): string | null {
|
||||
if (!status.updateAvailable || !status.latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: packageManager ?? detectPackageManager(),
|
||||
channel: status.channel,
|
||||
registry: status.registry,
|
||||
});
|
||||
|
||||
const channelNote = status.channel === 'latest' ? '' : ` (${status.channel})`;
|
||||
return [
|
||||
`Update available: ${status.current} -> ${status.latest}${channelNote}`,
|
||||
`Run "keyman self-update" or "${formatCommand(command)}"`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The startup path: returns the notice to print, or null.
|
||||
*
|
||||
* Never throws and never blocks for longer than the fetch timeout.
|
||||
*/
|
||||
export async function updateNotice(options: {
|
||||
currentVersion: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cachePath?: string;
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
now?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
run?: CommandRunner;
|
||||
}): Promise<string | null> {
|
||||
const env = options.env ?? process.env;
|
||||
if (isUpdateCheckDisabled(env)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await checkForUpdate({ ...options, env });
|
||||
return formatUpdateNotice(status, detectPackageManager({ env }));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of a {@link selfUpdate} run */
|
||||
export interface SelfUpdateResult {
|
||||
/** The status the decision was based on */
|
||||
status: UpdateStatus;
|
||||
/** The command that was run, or would have been run */
|
||||
command: { file: string; args: string[] };
|
||||
/** Whether the install actually ran */
|
||||
ran: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the newest version on the current channel.
|
||||
*
|
||||
* @param options.dryRun - print the command instead of running it
|
||||
* @param options.force - reinstall even when already up to date
|
||||
*/
|
||||
export async function selfUpdate(options: {
|
||||
currentVersion: string;
|
||||
channel?: Channel;
|
||||
registry?: string;
|
||||
packageManager?: PackageManager;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cachePath?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
run?: CommandRunner;
|
||||
spawn?: (file: string, args: string[]) => Promise<unknown>;
|
||||
}): Promise<SelfUpdateResult> {
|
||||
const env = options.env ?? process.env;
|
||||
|
||||
// Always ignore the cache here: the user asked, so the answer has to be current.
|
||||
const status = await checkForUpdate({
|
||||
currentVersion: options.currentVersion,
|
||||
channel: options.channel,
|
||||
registry: options.registry,
|
||||
force: true,
|
||||
cachePath: options.cachePath,
|
||||
env,
|
||||
fetchImpl: options.fetchImpl,
|
||||
run: options.run,
|
||||
});
|
||||
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: options.packageManager ?? detectPackageManager({ env }),
|
||||
channel: status.channel,
|
||||
registry: status.registry,
|
||||
});
|
||||
|
||||
if (options.dryRun || (!status.updateAvailable && !options.force)) {
|
||||
return { status, command, ran: false };
|
||||
}
|
||||
|
||||
const spawn =
|
||||
options.spawn ?? ((file: string, args: string[]) => execa(file, args, { stdio: 'inherit' }));
|
||||
await spawn(command.file, command.args);
|
||||
|
||||
return { status, command, ran: true };
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
/**
|
||||
* Tests for keyman.update module
|
||||
*
|
||||
* Every network call, clock read and spawn is injected, so nothing here
|
||||
* reaches a registry or the user's home directory.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildSelfUpdateCommand,
|
||||
type Channel,
|
||||
channelForVersion,
|
||||
checkForUpdate,
|
||||
detectPackageManager,
|
||||
fetchChannelVersion,
|
||||
formatCommand,
|
||||
formatUpdateNotice,
|
||||
getUpdateCachePath,
|
||||
isUpdateCheckDisabled,
|
||||
NPMJS_REGISTRY,
|
||||
normalizeRegistry,
|
||||
readUpdateCache,
|
||||
resolveRegistry,
|
||||
selfUpdate,
|
||||
type UpdateCache,
|
||||
updateNotice,
|
||||
writeUpdateCache,
|
||||
} from '../src/keyman.update.js';
|
||||
|
||||
const GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
|
||||
|
||||
let tmpDir: string;
|
||||
let cachePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-update-'));
|
||||
cachePath = path.join(tmpDir, 'update-check.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A fetch stand-in returning the given dist-tags */
|
||||
function fakeFetch(distTags: Record<string, string>, ok = true): typeof fetch {
|
||||
return (async () =>
|
||||
({
|
||||
ok,
|
||||
json: async () => ({ 'dist-tags': distTags }),
|
||||
}) as Response) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('channelForVersion', () => {
|
||||
it('maps a clean release to latest', () => {
|
||||
expect(channelForVersion('0.5.0')).toBe('latest');
|
||||
expect(channelForVersion('1.2.3')).toBe('latest');
|
||||
});
|
||||
|
||||
it('maps a snapshot to main', () => {
|
||||
expect(channelForVersion('0.5.0-main.14.g6ecb2c3')).toBe('main');
|
||||
});
|
||||
|
||||
it('maps any other prerelease to next', () => {
|
||||
expect(channelForVersion('0.6.0-rc.1')).toBe('next');
|
||||
expect(channelForVersion('1.0.0-alpha5')).toBe('next');
|
||||
});
|
||||
|
||||
it('treats an unparseable version as latest', () => {
|
||||
expect(channelForVersion('not-a-version')).toBe('latest');
|
||||
expect(channelForVersion('')).toBe('latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRegistry', () => {
|
||||
it('adds a trailing slash', () => {
|
||||
expect(normalizeRegistry('https://example.com/npm')).toBe('https://example.com/npm/');
|
||||
});
|
||||
|
||||
it('leaves an existing trailing slash alone', () => {
|
||||
expect(normalizeRegistry(GITEA)).toBe(GITEA);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(normalizeRegistry(' https://example.com/npm ')).toBe('https://example.com/npm/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRegistry', () => {
|
||||
it('prefers the KEYMAN_REGISTRY override', async () => {
|
||||
const run = vi.fn();
|
||||
const registry = await resolveRegistry({
|
||||
env: { KEYMAN_REGISTRY: 'https://example.com/npm' },
|
||||
run,
|
||||
});
|
||||
expect(registry).toBe('https://example.com/npm/');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to npm config', async () => {
|
||||
const run = vi.fn(async () => GITEA);
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(GITEA);
|
||||
expect(run).toHaveBeenCalledWith('npm', ['config', 'get', '@bitsquare:registry']);
|
||||
});
|
||||
|
||||
it('treats npm printing "undefined" as unset', async () => {
|
||||
const run = vi.fn(async () => 'undefined');
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||
});
|
||||
|
||||
it('treats npm printing "null" as unset', async () => {
|
||||
const run = vi.fn(async () => 'null');
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||
});
|
||||
|
||||
it('treats empty output as unset', async () => {
|
||||
const run = vi.fn(async () => ' ');
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||
});
|
||||
|
||||
it('falls back to npmjs when npm is missing', async () => {
|
||||
const run = vi.fn(async () => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||
});
|
||||
|
||||
it('ignores a blank override', async () => {
|
||||
const run = vi.fn(async () => GITEA);
|
||||
expect(await resolveRegistry({ env: { KEYMAN_REGISTRY: ' ' }, run })).toBe(GITEA);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChannelVersion', () => {
|
||||
it('reads the requested dist-tag', async () => {
|
||||
const version = await fetchChannelVersion({
|
||||
registry: GITEA,
|
||||
channel: 'main',
|
||||
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234', latest: '0.5.0' }),
|
||||
});
|
||||
expect(version).toBe('0.5.0-main.14.gabc1234');
|
||||
});
|
||||
|
||||
it('returns null when the tag does not exist', async () => {
|
||||
const version = await fetchChannelVersion({
|
||||
registry: GITEA,
|
||||
channel: 'latest',
|
||||
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234' }),
|
||||
});
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on a non-ok response', async () => {
|
||||
const version = await fetchChannelVersion({
|
||||
registry: GITEA,
|
||||
channel: 'latest',
|
||||
fetchImpl: fakeFetch({}, false),
|
||||
});
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the packument has no dist-tags at all', async () => {
|
||||
const fetchImpl = (async () =>
|
||||
({ ok: true, json: async () => ({}) }) as Response) as unknown as typeof fetch;
|
||||
expect(await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl })).toBeNull();
|
||||
});
|
||||
|
||||
it('url-encodes the scoped package name onto the registry', async () => {
|
||||
const seen: string[] = [];
|
||||
const fetchImpl = (async (url: string) => {
|
||||
seen.push(url);
|
||||
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
// No trailing slash on purpose: it must be normalised before joining.
|
||||
await fetchChannelVersion({
|
||||
registry: 'https://example.com/npm',
|
||||
channel: 'latest',
|
||||
fetchImpl,
|
||||
});
|
||||
expect(seen[0]).toBe('https://example.com/npm/%40bitsquare%2Fkeyman');
|
||||
});
|
||||
|
||||
it('sends a bearer token when one is given', async () => {
|
||||
let headers: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||
headers = init.headers as Record<string, string>;
|
||||
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchChannelVersion({ registry: GITEA, channel: 'latest', token: 'secret', fetchImpl });
|
||||
expect(headers.authorization).toBe('Bearer secret');
|
||||
});
|
||||
|
||||
it('omits the authorization header when no token is given', async () => {
|
||||
let headers: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||
headers = init.headers as Record<string, string>;
|
||||
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl });
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the update cache', () => {
|
||||
it('round-trips', () => {
|
||||
const cache: UpdateCache = {
|
||||
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.6.0',
|
||||
};
|
||||
writeUpdateCache(cache, cachePath);
|
||||
expect(readUpdateCache(cachePath)).toEqual(cache);
|
||||
});
|
||||
|
||||
it('creates the containing directory', () => {
|
||||
const nested = path.join(tmpDir, 'a', 'b', 'update-check.json');
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: null,
|
||||
},
|
||||
nested
|
||||
);
|
||||
expect(fs.existsSync(nested)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a missing file as null', () => {
|
||||
expect(readUpdateCache(path.join(tmpDir, 'absent.json'))).toBeNull();
|
||||
});
|
||||
|
||||
it('reads malformed JSON as null', () => {
|
||||
fs.writeFileSync(cachePath, '{ not json', 'utf-8');
|
||||
expect(readUpdateCache(cachePath)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a file without a checkedAt stamp', () => {
|
||||
fs.writeFileSync(cachePath, JSON.stringify({ latest: '9.9.9' }), 'utf-8');
|
||||
expect(readUpdateCache(cachePath)).toBeNull();
|
||||
});
|
||||
|
||||
it('swallows a write it cannot perform', () => {
|
||||
// A path whose parent is a file, not a directory.
|
||||
const blocked = path.join(cachePath, 'nested.json');
|
||||
fs.writeFileSync(cachePath, '{}', 'utf-8');
|
||||
expect(() =>
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: null,
|
||||
},
|
||||
blocked
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('defaults to a path under the home directory', () => {
|
||||
expect(getUpdateCachePath('/home/someone')).toBe('/home/someone/.keyman/update-check.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUpdateCheckDisabled', () => {
|
||||
it('is off by default', () => {
|
||||
expect(isUpdateCheckDisabled({})).toBe(false);
|
||||
});
|
||||
|
||||
it('honours KEYMAN_NO_UPDATE_CHECK', () => {
|
||||
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '1' })).toBe(true);
|
||||
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'yes' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats 0 and false as not disabled', () => {
|
||||
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '0' })).toBe(false);
|
||||
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'false' })).toBe(false);
|
||||
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('disables itself in CI', () => {
|
||||
expect(isUpdateCheckDisabled({ CI: 'true' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForUpdate', () => {
|
||||
const base = {
|
||||
currentVersion: '0.5.0',
|
||||
registry: NPMJS_REGISTRY,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
now: Date.parse('2026-07-29T12:00:00.000Z'),
|
||||
};
|
||||
|
||||
it('reports a newer version on the channel', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status).toMatchObject({
|
||||
current: '0.5.0',
|
||||
latest: '0.6.0',
|
||||
channel: 'latest',
|
||||
updateAvailable: true,
|
||||
fromCache: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no update when the channel matches', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||
});
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat an older published version as an update', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.4.0' }),
|
||||
});
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('derives the channel from the running version', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
currentVersion: '0.5.0-main.13.gabc1234',
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gdef5678', latest: '0.5.0' }),
|
||||
});
|
||||
expect(status.channel).toBe('main');
|
||||
expect(status.latest).toBe('0.5.0-main.14.gdef5678');
|
||||
expect(status.updateAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('writes what it found to the cache', async () => {
|
||||
await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }) });
|
||||
expect(readUpdateCache(cachePath)).toEqual({
|
||||
checkedAt: '2026-07-29T12:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.6.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers from a fresh cache without touching the network', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.7.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const fetchImpl = vi.fn();
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
expect(status.latest).toBe('0.7.0');
|
||||
expect(status.fromCache).toBe(true);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refetches once the cache goes stale', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-27T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.7.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.8.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.8.0');
|
||||
expect(status.fromCache).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a cache written for a different channel', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||
channel: 'next',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '9.9.9',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.6.0');
|
||||
});
|
||||
|
||||
it('ignores a cache written for a different registry', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: GITEA,
|
||||
latest: '9.9.9',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.6.0');
|
||||
});
|
||||
|
||||
it('ignores a cache stamped in the future', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2027-01-01T00:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '9.9.9',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.6.0');
|
||||
});
|
||||
|
||||
it('ignores a cache with an unparseable stamp', async () => {
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
checkedAt: 'whenever',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '9.9.9',
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.6.0');
|
||||
});
|
||||
|
||||
it('refetches when forced, even with a fresh cache', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.7.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
force: true,
|
||||
fetchImpl: fakeFetch({ latest: '0.9.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.9.0');
|
||||
expect(status.fromCache).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the cached answer when the network fails', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-20T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.7.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error('offline');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
|
||||
expect(status.latest).toBe('0.7.0');
|
||||
expect(status.updateAvailable).toBe(true);
|
||||
expect(status.fromCache).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing when the network fails and no cache applies', async () => {
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error('offline');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
|
||||
expect(status.latest).toBeNull();
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the registry when none is given', async () => {
|
||||
const status = await checkForUpdate({
|
||||
currentVersion: '0.5.0',
|
||||
cachePath,
|
||||
env: {},
|
||||
run: async () => GITEA,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.registry).toBe(GITEA);
|
||||
});
|
||||
|
||||
it('passes a registry token from the environment through', async () => {
|
||||
let headers: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||
headers = init.headers as Record<string, string>;
|
||||
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.6.0' } }) } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
env: { KEYMAN_REGISTRY_TOKEN: 'tok' },
|
||||
fetchImpl,
|
||||
});
|
||||
expect(headers.authorization).toBe('Bearer tok');
|
||||
});
|
||||
|
||||
it('does not compare against an unparseable current version', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
currentVersion: 'dev',
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectPackageManager', () => {
|
||||
it('honours the environment override', () => {
|
||||
expect(
|
||||
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' })
|
||||
).toBe('pnpm');
|
||||
expect(
|
||||
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' })
|
||||
).toBe('yarn');
|
||||
expect(
|
||||
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' })
|
||||
).toBe('bun');
|
||||
expect(
|
||||
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' })
|
||||
).toBe('npm');
|
||||
});
|
||||
|
||||
it('ignores an unrecognised override', () => {
|
||||
expect(
|
||||
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'cargo' }, execPath: '/usr/lib/x' })
|
||||
).toBe('npm');
|
||||
});
|
||||
|
||||
it('recognises a pnpm global install', () => {
|
||||
expect(
|
||||
detectPackageManager({
|
||||
env: {},
|
||||
execPath: '/Users/x/Library/pnpm/global/5/node_modules/.bin/keyman',
|
||||
})
|
||||
).toBe('pnpm');
|
||||
});
|
||||
|
||||
it('recognises a bun global install', () => {
|
||||
expect(
|
||||
detectPackageManager({
|
||||
env: {},
|
||||
execPath: '/Users/x/.bun/install/global/node_modules/keyman',
|
||||
})
|
||||
).toBe('bun');
|
||||
});
|
||||
|
||||
it('recognises a yarn global install', () => {
|
||||
expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/keyman' })).toBe('yarn');
|
||||
});
|
||||
|
||||
it('defaults to npm', () => {
|
||||
expect(
|
||||
detectPackageManager({
|
||||
env: {},
|
||||
execPath: '/usr/local/lib/node_modules/@bitsquare/keyman/dist/keyman.cli.js',
|
||||
})
|
||||
).toBe('npm');
|
||||
});
|
||||
|
||||
it('handles a windows-style path and an empty path', () => {
|
||||
expect(
|
||||
detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\keyman.exe' })
|
||||
).toBe('pnpm');
|
||||
expect(detectPackageManager({ env: {}, execPath: '' })).toBe('npm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSelfUpdateCommand', () => {
|
||||
it('builds an npm global install without a registry flag for npmjs', () => {
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: 'npm',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
});
|
||||
expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@latest');
|
||||
});
|
||||
|
||||
it('adds a scoped registry override for a non-npmjs registry', () => {
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: 'npm',
|
||||
channel: 'main',
|
||||
registry: GITEA,
|
||||
});
|
||||
// Scoped, not `--registry`: Gitea does not proxy npmjs, so the transitive
|
||||
// dependencies have to keep resolving from npmjs.
|
||||
expect(formatCommand(command)).toBe(
|
||||
`npm install --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
|
||||
);
|
||||
expect(command.args).not.toContain('--registry');
|
||||
});
|
||||
|
||||
it('normalises a registry given without a trailing slash', () => {
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: 'npm',
|
||||
channel: 'latest',
|
||||
registry: 'https://registry.npmjs.org',
|
||||
});
|
||||
expect(command.args).toEqual(['install', '--global', '@bitsquare/keyman@latest']);
|
||||
});
|
||||
|
||||
it('builds for pnpm, yarn and bun', () => {
|
||||
expect(
|
||||
formatCommand(
|
||||
buildSelfUpdateCommand({
|
||||
packageManager: 'pnpm',
|
||||
channel: 'next',
|
||||
registry: NPMJS_REGISTRY,
|
||||
})
|
||||
)
|
||||
).toBe('pnpm add --global @bitsquare/keyman@next');
|
||||
expect(
|
||||
formatCommand(
|
||||
buildSelfUpdateCommand({
|
||||
packageManager: 'yarn',
|
||||
channel: 'next',
|
||||
registry: NPMJS_REGISTRY,
|
||||
})
|
||||
)
|
||||
).toBe('yarn global add @bitsquare/keyman@next');
|
||||
expect(
|
||||
formatCommand(
|
||||
buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY })
|
||||
)
|
||||
).toBe('bun add --global @bitsquare/keyman@next');
|
||||
});
|
||||
|
||||
it('accepts an explicit package name', () => {
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: 'npm',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
packageName: '@bitsquare/nopy',
|
||||
});
|
||||
expect(formatCommand(command)).toBe('npm install --global @bitsquare/nopy@latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUpdateNotice', () => {
|
||||
const status = {
|
||||
current: '0.5.0',
|
||||
latest: '0.6.0',
|
||||
channel: 'latest' as Channel,
|
||||
registry: NPMJS_REGISTRY,
|
||||
updateAvailable: true,
|
||||
fromCache: false,
|
||||
};
|
||||
|
||||
it('names both versions and the command', () => {
|
||||
const notice = formatUpdateNotice(status, 'npm');
|
||||
expect(notice).toContain('0.5.0 -> 0.6.0');
|
||||
expect(notice).toContain('keyman self-update');
|
||||
expect(notice).toContain('npm install --global @bitsquare/keyman@latest');
|
||||
});
|
||||
|
||||
it('names a non-default channel', () => {
|
||||
expect(formatUpdateNotice({ ...status, channel: 'main' }, 'npm')).toContain('(main)');
|
||||
});
|
||||
|
||||
it('says nothing when there is no update', () => {
|
||||
expect(formatUpdateNotice({ ...status, updateAvailable: false }, 'npm')).toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing when the latest version is unknown', () => {
|
||||
expect(formatUpdateNotice({ ...status, latest: null }, 'npm')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects the package manager when none is given', () => {
|
||||
expect(formatUpdateNotice(status)).toContain('@bitsquare/keyman@latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNotice', () => {
|
||||
it('returns a notice when an update exists', async () => {
|
||||
const notice = await updateNotice({
|
||||
currentVersion: '0.5.0',
|
||||
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY },
|
||||
cachePath,
|
||||
now: Date.parse('2026-07-29T12:00:00.000Z'),
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(notice).toContain('0.5.0 -> 0.6.0');
|
||||
});
|
||||
|
||||
it('returns null when the check is disabled', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
const notice = await updateNotice({
|
||||
currentVersion: '0.5.0',
|
||||
env: { KEYMAN_NO_UPDATE_CHECK: '1' },
|
||||
cachePath,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
expect(notice).toBeNull();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null rather than throwing when everything fails', async () => {
|
||||
const notice = await updateNotice({
|
||||
currentVersion: '0.5.0',
|
||||
env: {},
|
||||
cachePath,
|
||||
run: async () => {
|
||||
throw new Error('no npm');
|
||||
},
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('offline');
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
expect(notice).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selfUpdate', () => {
|
||||
const base = {
|
||||
currentVersion: '0.5.0',
|
||||
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY } as NodeJS.ProcessEnv,
|
||||
packageManager: 'npm' as const,
|
||||
};
|
||||
|
||||
it('runs the install when a newer version exists', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
spawn,
|
||||
});
|
||||
expect(result.ran).toBe(true);
|
||||
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--global', '@bitsquare/keyman@latest']);
|
||||
});
|
||||
|
||||
it('does nothing when already up to date', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||
spawn,
|
||||
});
|
||||
expect(result.ran).toBe(false);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reinstalls when forced', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
force: true,
|
||||
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||
spawn,
|
||||
});
|
||||
expect(result.ran).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the command without running it on a dry run', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
dryRun: true,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
spawn,
|
||||
});
|
||||
expect(result.ran).toBe(false);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(formatCommand(result.command)).toBe('npm install --global @bitsquare/keyman@latest');
|
||||
});
|
||||
|
||||
it('ignores a fresh cache, because the user asked', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: new Date().toISOString(),
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.5.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
spawn: async () => undefined,
|
||||
});
|
||||
expect(result.status.latest).toBe('0.6.0');
|
||||
expect(result.ran).toBe(true);
|
||||
});
|
||||
|
||||
it('follows an explicit channel and registry', async () => {
|
||||
const result = await selfUpdate({
|
||||
currentVersion: '0.5.0',
|
||||
env: {},
|
||||
packageManager: 'pnpm',
|
||||
channel: 'main',
|
||||
registry: GITEA,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ main: '0.5.0-main.20.gaaaaaaa' }),
|
||||
spawn: async () => undefined,
|
||||
});
|
||||
expect(formatCommand(result.command)).toBe(
|
||||
`pnpm add --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run when the registry could not be reached', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('offline');
|
||||
}) as unknown as typeof fetch,
|
||||
spawn,
|
||||
});
|
||||
expect(result.status.latest).toBeNull();
|
||||
expect(result.ran).toBe(false);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitsquare/nopy-cube",
|
||||
"version": "1.0.0-alpha0",
|
||||
"version": "0.5.0",
|
||||
"description": "Authoring types for nopy cubes: the Manifest factory and the Cube contract.",
|
||||
"keywords": [
|
||||
"nopy",
|
||||
|
||||
+86
-25
@@ -302,12 +302,12 @@ All three are unioned and scanned the same way. A directory is a cube when it ho
|
||||
|
||||
#### Cube packages
|
||||
|
||||
A cube package is an ordinary npm package that ships cube directories and points at them from its own `package.json`:
|
||||
A cube package is an ordinary npm package that ships its cubes in a `cubes/` directory at its root. That is the whole contract — no nopy-specific `package.json` field is required. A bundle whose cubes live elsewhere (compiled into `dist/cubes`, say) overrides the location:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@bitsquare/cubes-core",
|
||||
"nopy": { "cubes": ["./cubes"] }
|
||||
"name": "@acme/cubes-web",
|
||||
"nopy": { "cubes": ["./dist/cubes"] }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -321,7 +321,7 @@ pnpm add -D @bitsquare/cubes-core
|
||||
{ "cubePackages": ["@bitsquare/cubes-core"] }
|
||||
```
|
||||
|
||||
Naming a package is a statement that cubes are expected from it, so anything wrong is an error that aborts the run rather than a silent skip: the package is not installed, it declares no `nopy.cubes`, or an entry points at a directory that does not exist or lies outside the package.
|
||||
Naming a package is a statement that cubes are expected from it, so anything wrong is an error that aborts the run rather than a silent skip: the package is not installed, it has neither a `cubes/` directory nor a `nopy.cubes` override, its `nopy.cubes` is malformed, or an entry points at a directory that does not exist or lies outside the package.
|
||||
|
||||
#### Ids are claimed globally
|
||||
|
||||
@@ -333,36 +333,97 @@ Writing cubes to publish is covered in [CUBE-BUNDLES.md](docs/CUBE-BUNDLES.md).
|
||||
|
||||
### Installation
|
||||
|
||||
This package is part of a yarn workspace monorepo. Install from the repository root:
|
||||
|
||||
```bash
|
||||
# From repository root (/ansiblings)
|
||||
yarn install
|
||||
yarn workspace @bitsquare/nopy build
|
||||
npm install -g @bitsquare/nopy
|
||||
```
|
||||
|
||||
To use the `nopy` command globally, you can:
|
||||
The cubes live in a separate bundle, installed into whichever project describes
|
||||
your infrastructure and named in its `.nopyrc.json`:
|
||||
|
||||
1. **Use yarn workspace command**:
|
||||
```bash
|
||||
pnpm add -D @bitsquare/cubes-core
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn workspace @bitsquare/nopy nopy
|
||||
```
|
||||
```json
|
||||
{ "hosts": ["your-host"], "cubePackages": ["@bitsquare/cubes-core"] }
|
||||
```
|
||||
|
||||
2. **Link the package globally**:
|
||||
#### Channels
|
||||
|
||||
```bash
|
||||
cd packages/nopy
|
||||
npm link
|
||||
# Now you can use 'nopy' from anywhere
|
||||
nopy install
|
||||
```
|
||||
Three dist-tags are published, and the one you install from is the one you stay
|
||||
on until you ask otherwise:
|
||||
|
||||
3. **Use via npm scripts** (from packages/nopy directory):
|
||||
| Channel | What it is | Registry |
|
||||
| -------- | ----------------------------------------- | ------------ |
|
||||
| `latest` | the current release — the default | npmjs, Gitea |
|
||||
| `next` | a prerelease (`0.6.0-rc.1`) | npmjs, Gitea |
|
||||
| `main` | a snapshot of every commit on `main` | Gitea only |
|
||||
|
||||
```bash
|
||||
yarn nopy
|
||||
```
|
||||
```bash
|
||||
npm install -g @bitsquare/nopy # latest
|
||||
npm install -g @bitsquare/nopy@next # prereleases
|
||||
```
|
||||
|
||||
Snapshots come from the Gitea registry. Point the **scope** at it rather than
|
||||
setting a bare `registry=`, because that registry serves `@bitsquare` packages
|
||||
only and does not proxy npmjs — everything else must keep resolving from npmjs:
|
||||
|
||||
```bash
|
||||
npm install -g @bitsquare/nopy@main \
|
||||
--@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
```
|
||||
|
||||
Or, persistently, in `~/.npmrc`:
|
||||
|
||||
```ini
|
||||
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
|
||||
```
|
||||
|
||||
Reading from Gitea needs no token while the repository is public.
|
||||
|
||||
### Upgrading
|
||||
|
||||
```bash
|
||||
nopy self-update
|
||||
```
|
||||
|
||||
That checks the channel your installed version came from, on the registry your
|
||||
npm config points at, and re-runs the package manager that installed you (npm,
|
||||
pnpm, yarn or bun — detected from the install path). Options:
|
||||
|
||||
```bash
|
||||
nopy self-update --dry-run # print the command, change nothing
|
||||
nopy self-update --force # reinstall even when up to date
|
||||
nopy self-update --channel next # switch channel
|
||||
nopy self-update --registry <url> # check somewhere else
|
||||
```
|
||||
|
||||
The plain package-manager equivalent works too. Prefer `@latest` over
|
||||
`npm update -g`, which resolves against the range recorded at install time:
|
||||
|
||||
```bash
|
||||
npm install -g @bitsquare/nopy@latest
|
||||
```
|
||||
|
||||
Once a day, `nopy` checks its channel in the background and prints a one-line
|
||||
hint to **stderr** when a newer version exists — never to stdout, so `--json`
|
||||
and `--print-only` output stay clean. The answer is cached in
|
||||
`~/.nopy/update-check.json`; a registry that is slow or unreachable is given
|
||||
1.5 seconds and then ignored.
|
||||
|
||||
| Variable | Effect |
|
||||
| ----------------------- | --------------------------------------------- |
|
||||
| `NOPY_NO_UPDATE_CHECK=1`| disable the startup check (also off when `CI` is set) |
|
||||
| `NOPY_REGISTRY` | check a specific registry |
|
||||
| `NOPY_REGISTRY_TOKEN` | bearer token, for a private registry |
|
||||
| `NOPY_PACKAGE_MANAGER` | force `npm`/`pnpm`/`yarn`/`bun` for the install |
|
||||
|
||||
### Running from a checkout
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter @bitsquare/nopy run nopy # runs the CLI from source via tsx
|
||||
```
|
||||
|
||||
### Basic Commands
|
||||
|
||||
|
||||
+873
-398
File diff suppressed because it is too large
Load Diff
@@ -22,13 +22,13 @@ The rest of this document is for writing one.
|
||||
|
||||
## What a bundle is
|
||||
|
||||
An ordinary npm package that ships cube directories and points at them from its
|
||||
own `package.json`. There is no build step, no plugin API and no entry point —
|
||||
nopy reads the directories off disk and imports each `manifest.mjs` directly.
|
||||
An ordinary npm package that ships its cubes in a `cubes/` directory. There is no
|
||||
build step, no plugin API and no entry point — nopy reads the directory off disk
|
||||
and imports each `manifest.mjs` directly.
|
||||
|
||||
```
|
||||
@acme/cubes-web
|
||||
├── package.json nopy.cubes → ["./cubes"]
|
||||
├── package.json no nopy block needed
|
||||
├── README.md
|
||||
└── cubes/
|
||||
├── nginx/
|
||||
@@ -50,7 +50,6 @@ special-cased.
|
||||
"name": "@acme/cubes-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"nopy": { "cubes": ["./cubes"] },
|
||||
"files": ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"],
|
||||
"publishConfig": { "access": "public" },
|
||||
"dependencies": {
|
||||
@@ -60,10 +59,24 @@ special-cased.
|
||||
}
|
||||
```
|
||||
|
||||
**`nopy.cubes`** is the only field nopy requires. It is an array of directories,
|
||||
relative to the package root, each scanned recursively for cubes. Several
|
||||
entries are fine; a single `["./cubes"]` is the norm. Every entry must exist and
|
||||
must stay inside the package — a path escaping the root is refused, not resolved.
|
||||
**Nothing declares the cubes.** `cubes/` at the package root is the convention,
|
||||
scanned recursively, and a bundle that follows it needs no nopy-specific field at
|
||||
all. Naming the package in `cubePackages` is already the statement that cubes are
|
||||
expected from it.
|
||||
|
||||
**`nopy.cubes`** overrides that, for the bundle whose cubes are somewhere else — a
|
||||
package compiled from TypeScript sources into `dist/cubes`, say, or one shipping
|
||||
two separate trees:
|
||||
|
||||
```json
|
||||
"nopy": { "cubes": ["./dist/cubes", "./contrib"] }
|
||||
```
|
||||
|
||||
It is an array of directories relative to the package root. Every entry must
|
||||
exist and must stay inside the package — a path escaping the root is refused, not
|
||||
resolved. Present-but-malformed (an empty array, a bare string, non-strings) is an
|
||||
error rather than a fall back to the default: saying something that does not parse
|
||||
is not the same as saying nothing.
|
||||
|
||||
**`type: "module"`** matters: manifests are ESM. Without it a `manifest.mjs` still
|
||||
loads (the extension carries the day), but anything it imports relatively will
|
||||
@@ -222,7 +235,7 @@ Nothing bundle-specific: `npm publish` (or `pnpm publish`) with a version bump.
|
||||
Some things worth deciding once:
|
||||
|
||||
- **Version the bundle independently of nopy.** There is no compatibility check
|
||||
between the two — the loader reads whatever `nopy.cubes` points at. Document
|
||||
between the two — the loader scans whatever directories it finds. Document
|
||||
the nopy version you test against in your README.
|
||||
- **Renaming or removing an id is breaking.** It invalidates recorded sessions
|
||||
and breaks any manifest listing it as a dependency, including manifests in
|
||||
@@ -254,8 +267,9 @@ For how this repository releases its own packages, see
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| `Cube package 'X' is not installed (looked up from …)` | Not installed, or installed somewhere other than the config that named it. The path in the message is where the lookup started. |
|
||||
| `Cube package 'X' declares no cubes` | Missing or malformed `nopy.cubes` in the package's `package.json`. It must be a non-empty array of strings. |
|
||||
| `'./cubes' does not exist in …` | The directory was not packed. Check `files` and `npm pack --dry-run`. |
|
||||
| `Cube package 'X' has no cubes/ directory in …` | No `cubes/` at the package root and no `nopy.cubes` pointing elsewhere. Usually the directory was not packed — check `files` and `npm pack --dry-run`. |
|
||||
| `"nopy": { "cubes": … } must be a non-empty array of strings` | The override is present but malformed. Fix it, or omit it entirely to use `./cubes`. |
|
||||
| `'…' does not exist in …` | A `nopy.cubes` entry pointing at a directory the tarball does not contain. |
|
||||
| `'…' points outside the package` | A `nopy.cubes` entry escaping the package root. Not allowed. |
|
||||
| `Duplicate cube id 'X' from N sources:` | Two or more cubes claiming one id; the message lists each source. Rename one — there is no precedence rule to lean on. |
|
||||
| `ERR_MODULE_NOT_FOUND` for `zod` or `@bitsquare/nopy-cube` | The bundle did not declare them as dependencies. The resolve-hook fallback covers loose local cubes, not published packages. |
|
||||
|
||||
@@ -141,7 +141,6 @@ A cube bundle is an npm package with a `nopy` field:
|
||||
"name": "@acme/cubes-net",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"nopy": { "cubes": ["./cubes"] },
|
||||
"files": ["cubes", "README.md", "LICENSE"],
|
||||
"keywords": ["nopy", "nopy-cubes", "pyinfra"],
|
||||
"dependencies": {
|
||||
@@ -154,10 +153,16 @@ A cube bundle is an npm package with a `nopy` field:
|
||||
|
||||
Rules:
|
||||
|
||||
- `nopy.cubes` — directories relative to the package root, scanned exactly like
|
||||
`cubeDirs` entries. Required; a package listed in `cubePackages` without a
|
||||
`nopy` field is an error, not a silent skip. Listing it means the user expects
|
||||
cubes from it.
|
||||
- **Cube location is a convention, `<root>/cubes`.** *(Amended after Phase 5;
|
||||
originally `nopy.cubes` was a required field.)* The field bought nothing a
|
||||
convention does not: it is not a discovery marker — naming the package in
|
||||
`cubePackages` already is one — and it says nothing about whether the
|
||||
directories were actually packed, which is the failure authors really hit.
|
||||
`nopy.cubes` remains as an **override**, directories relative to the package
|
||||
root, for the bundle whose cubes are elsewhere (`dist/cubes` after a build).
|
||||
Absent means the default; present-and-malformed is an error rather than a fall
|
||||
back. Finding no cube directory at all is still an error, not a silent skip:
|
||||
listing a package means the user expects cubes from it.
|
||||
- Both dependencies are **regular dependencies, not peers**, and both are
|
||||
load-bearing: a manifest imports `Manifest` from `@bitsquare/nopy-cube` and `z`
|
||||
from `zod`. `@bitsquare/nopy-cube` peer-depends on zod, so the bundle's copy is
|
||||
@@ -255,7 +260,8 @@ Errors (each aborts the run, consistent with the existing `errors` contract):
|
||||
|
||||
- package not found on any candidate path
|
||||
- `package.json` unparseable
|
||||
- no `nopy.cubes`, or it is not a non-empty array of strings
|
||||
- neither a `cubes/` directory nor a `nopy.cubes` override
|
||||
- `nopy.cubes` present but not a non-empty array of strings
|
||||
- a `nopy.cubes` entry escapes the package root, or does not exist
|
||||
|
||||
### Wiring
|
||||
@@ -625,7 +631,8 @@ runner alike.
|
||||
- resolves through a symlinked package directory (mimicking pnpm)
|
||||
- resolves from the declaring config's directory, not `cwd`
|
||||
- missing package → error naming the spec
|
||||
- package without `nopy.cubes` → error
|
||||
- package without `nopy.cubes` → falls back to `cubes/`
|
||||
- package with neither → error; malformed `nopy.cubes` → error, no fall back
|
||||
- `nopy.cubes` entry that does not exist, and one that escapes the root → errors
|
||||
- last-wins dedupe when parent and child config both name a package
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,13 +8,23 @@ import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import type { CubePackageRef } from '../nopy.config.js';
|
||||
|
||||
/**
|
||||
* Where a bundle's cubes live when its `package.json` does not say otherwise.
|
||||
*
|
||||
* Convention over configuration: shipping `cubes/` at the package root needs no
|
||||
* `nopy` block at all. `nopy.cubes` remains as an override for the bundle whose
|
||||
* cubes sit somewhere else — one compiled from TypeScript into `dist/cubes`,
|
||||
* say — so the escape hatch survives without every author paying for it.
|
||||
*/
|
||||
const DEFAULT_CUBE_DIRS = ['./cubes'];
|
||||
|
||||
/** An installed cube package, located and validated. */
|
||||
export interface CubePackage {
|
||||
/** The name it was requested under. */
|
||||
name: string;
|
||||
/** Absolute path to the package root. */
|
||||
root: string;
|
||||
/** Absolute paths to its cube directories, from `nopy.cubes`. */
|
||||
/** Absolute paths to its cube directories, from `nopy.cubes` or the default. */
|
||||
dirs: string[];
|
||||
}
|
||||
|
||||
@@ -40,7 +50,8 @@ function findPackageRoot(ref: CubePackageRef): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every named package to its cube directories.
|
||||
* Resolves every named package to its cube directories — {@link DEFAULT_CUBE_DIRS}
|
||||
* unless its `package.json` overrides them with `nopy.cubes`.
|
||||
*
|
||||
* Anything wrong is an error rather than a silent skip: naming a package in
|
||||
* `cubePackages` is a statement that cubes are expected from it, and errors
|
||||
@@ -75,27 +86,40 @@ export function resolveCubePackages(refs: CubePackageRef[]): {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absent is the ordinary case and means the convention. Present-but-wrong
|
||||
// is a different thing entirely — the author meant to say something and it
|
||||
// did not parse — so it stays an error rather than falling back silently.
|
||||
const declared = manifest.nopy?.cubes;
|
||||
const defaulted = declared === undefined;
|
||||
|
||||
if (
|
||||
!Array.isArray(declared) ||
|
||||
declared.length === 0 ||
|
||||
!declared.every((entry) => typeof entry === 'string')
|
||||
!defaulted &&
|
||||
(!Array.isArray(declared) ||
|
||||
declared.length === 0 ||
|
||||
!declared.every((entry) => typeof entry === 'string'))
|
||||
) {
|
||||
errors.push(
|
||||
`Cube package '${ref.spec}' declares no cubes. ` +
|
||||
`Expected "nopy": { "cubes": ["./cubes"] } in ${root}/package.json.`
|
||||
`Cube package '${ref.spec}': "nopy": { "cubes": … } in ${root}/package.json ` +
|
||||
`must be a non-empty array of strings. Omit it to use the default, ./cubes.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dirs: string[] = [];
|
||||
for (const entry of declared as string[]) {
|
||||
for (const entry of defaulted ? DEFAULT_CUBE_DIRS : (declared as string[])) {
|
||||
const dir = path.resolve(root, entry);
|
||||
|
||||
if (dir !== root && !dir.startsWith(root + path.sep)) {
|
||||
errors.push(`Cube package '${ref.spec}': '${entry}' points outside the package.`);
|
||||
} else if (!fs.existsSync(dir)) {
|
||||
errors.push(`Cube package '${ref.spec}': '${entry}' does not exist in ${root}.`);
|
||||
// Naming the entry would be misleading when nobody wrote one; say what
|
||||
// was looked for and what would change it instead.
|
||||
errors.push(
|
||||
defaulted
|
||||
? `Cube package '${ref.spec}' has no cubes/ directory in ${root}, and its ` +
|
||||
`package.json declares no "nopy": { "cubes": [...] } pointing elsewhere.`
|
||||
: `Cube package '${ref.spec}': '${entry}' does not exist in ${root}.`
|
||||
);
|
||||
} else {
|
||||
dirs.push(dir);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ export {
|
||||
outputExecutionPlan,
|
||||
summarizeResults,
|
||||
} from './nopy.executor.js';
|
||||
// Graceful exit
|
||||
export {
|
||||
CANCELLED_EXIT_CODE,
|
||||
exitWithFarewell,
|
||||
FAREWELL,
|
||||
installGracefulExit,
|
||||
isCancellation,
|
||||
restoreTerminal,
|
||||
} from './nopy.exit.js';
|
||||
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
|
||||
// History management
|
||||
export {
|
||||
@@ -66,6 +75,35 @@ export {
|
||||
export type { AuthSession, CubeSession, NopySession } from './nopy.session.js';
|
||||
// Session management
|
||||
export { createSession, listSessions, loadSession, saveSession } from './nopy.session.js';
|
||||
export type {
|
||||
Channel,
|
||||
CommandRunner,
|
||||
PackageManager,
|
||||
SelfUpdateResult,
|
||||
UpdateCache,
|
||||
UpdateStatus,
|
||||
} from './nopy.update.js';
|
||||
// Update checking and self-update
|
||||
export {
|
||||
buildSelfUpdateCommand,
|
||||
channelForVersion,
|
||||
checkForUpdate,
|
||||
DEFAULT_CHECK_INTERVAL_MS,
|
||||
detectPackageManager,
|
||||
fetchChannelVersion,
|
||||
formatCommand,
|
||||
formatUpdateNotice,
|
||||
getUpdateCachePath,
|
||||
isUpdateCheckDisabled,
|
||||
NPMJS_REGISTRY,
|
||||
normalizeRegistry,
|
||||
PACKAGE_NAME,
|
||||
readUpdateCache,
|
||||
resolveRegistry,
|
||||
selfUpdate,
|
||||
updateNotice,
|
||||
writeUpdateCache,
|
||||
} from './nopy.update.js';
|
||||
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
|
||||
// Workflow
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from './nopy.config.js';
|
||||
import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js';
|
||||
import {
|
||||
clearHistory,
|
||||
formatHistoryList,
|
||||
@@ -16,14 +17,41 @@ import {
|
||||
listHistory,
|
||||
} from './nopy.history.js';
|
||||
import { nopy } from './nopy.main.js';
|
||||
import type { Channel } from './nopy.update.js';
|
||||
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
|
||||
|
||||
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
|
||||
const { version, buildInfo } = createRequire(import.meta.url)('../package.json') as {
|
||||
version: string;
|
||||
buildInfo?: { commit?: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* What `--version` prints. `version` itself stays untouched everywhere else —
|
||||
* the commit is an annotation, stamped into `package.json` on the runner by the
|
||||
* publish workflows and absent when running from source.
|
||||
*/
|
||||
const versionLabel = buildInfo?.commit ? `${version} (${buildInfo.commit})` : version;
|
||||
|
||||
/**
|
||||
* Prints the update hint to stderr, so it never lands in `--json` output or in
|
||||
* a `--print-only` command list being piped somewhere.
|
||||
*/
|
||||
async function printUpdateNotice(): Promise<void> {
|
||||
const notice = await updateNotice({ currentVersion: version });
|
||||
if (notice) {
|
||||
console.error(`\n${notice}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Before anything can open a prompt: a cancelled TUI leaves through
|
||||
// nopy.exit, not through node's default unhandled-rejection trace.
|
||||
installGracefulExit();
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('nopy')
|
||||
.version(version)
|
||||
.version(versionLabel)
|
||||
.description('A CLI tool for pyinfra script management and execution.')
|
||||
.addHelpText(
|
||||
'after',
|
||||
@@ -63,6 +91,8 @@ program
|
||||
.option('-j, --json', 'Output results as JSON')
|
||||
.option('--no-history', 'Do not save this session to history')
|
||||
.action(async (options) => {
|
||||
await printUpdateNotice();
|
||||
|
||||
// Loaded lazily so that --help/--version work outside a configured project.
|
||||
const execConfig = loadConfig().execution ?? {};
|
||||
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
|
||||
@@ -109,6 +139,11 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
// A prompt the user backed out of is not a failed run: inquirer rejects
|
||||
// cleanly, so unlike the enquirer case this arrives here rather than at
|
||||
// the process-level handler.
|
||||
if (isCancellation(error)) exitWithFarewell();
|
||||
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
@@ -150,4 +185,45 @@ program
|
||||
console.log('Session history cleared.');
|
||||
});
|
||||
|
||||
program
|
||||
.command('self-update')
|
||||
.description('Update nopy to the newest version on your channel')
|
||||
.alias('upgrade')
|
||||
.option('-n, --dry-run', 'Show the install command without running it')
|
||||
.option('-f, --force', 'Reinstall even when already up to date')
|
||||
.option('--channel <tag>', 'Check a specific channel (latest, next, main)')
|
||||
.option('--registry <url>', 'Install from a specific registry')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await selfUpdate({
|
||||
currentVersion: version,
|
||||
channel: options.channel as Channel | undefined,
|
||||
registry: options.registry,
|
||||
dryRun: options.dryRun,
|
||||
force: options.force,
|
||||
});
|
||||
|
||||
const { status } = result;
|
||||
console.log(`Installed: ${status.current}`);
|
||||
console.log(`Channel: ${status.channel}`);
|
||||
console.log(`Registry: ${status.registry}`);
|
||||
console.log(`Available: ${status.latest ?? 'unknown'}`);
|
||||
console.log('');
|
||||
|
||||
if (result.ran) {
|
||||
console.log(`Updated to ${status.latest}.`);
|
||||
} else if (options.dryRun) {
|
||||
console.log(`Would run: ${formatCommand(result.command)}`);
|
||||
} else if (status.latest === null) {
|
||||
console.error(`Could not reach ${status.registry} — nothing was changed.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('Already up to date.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* What happens when the user walks out of the TUI instead of finishing it.
|
||||
* @module nopy.exit
|
||||
*/
|
||||
|
||||
/** Parting words. Printed whenever a run ends because the user asked it to. */
|
||||
export const FAREWELL = 'Bye Bye Honeypie';
|
||||
|
||||
/** Conventional exit code for "terminated by SIGINT" — 128 + 2. */
|
||||
export const CANCELLED_EXIT_CODE = 130;
|
||||
|
||||
/** ETX: the byte a raw-mode terminal delivers for Ctrl-C. */
|
||||
const ETX = '\x03';
|
||||
|
||||
/** Undoes `ansi.cursor.hide()`, which every enquirer prompt writes on start. */
|
||||
const SHOW_CURSOR = '\x1B[?25h';
|
||||
|
||||
/**
|
||||
* Error names the two prompt libraries use for "the user called it off".
|
||||
*
|
||||
* `ExitPromptError` is what `@inquirer/core` rejects with on Ctrl-C;
|
||||
* `CancelPromptError` is the same thing reached from outside the prompt.
|
||||
*/
|
||||
const CANCEL_ERROR_NAMES = new Set(['ExitPromptError', 'CancelPromptError']);
|
||||
|
||||
/**
|
||||
* Whether a thrown value is the user cancelling rather than something failing.
|
||||
*
|
||||
* Three shapes, one per way out of a prompt:
|
||||
*
|
||||
* - `ERR_USE_AFTER_CLOSE` — enquirer's teardown exploding. Ctrl-C in raw mode
|
||||
* reaches *both* node's readline, which closes the interface because it has
|
||||
* no `SIGINT` listener, and enquirer's own keypress queue, which then cancels
|
||||
* the prompt and calls `rl.pause()` on the interface node has already closed.
|
||||
* Node >= 22 throws there rather than ignoring it. The throw happens inside
|
||||
* `Prompt.close()`, i.e. *before* `emit('cancel')`, so `prompt.run()` never
|
||||
* settles and the `try/catch` around it in `nopy.prompts` never runs — the
|
||||
* rejection surfaces with nothing awaiting it, which is why this has to be
|
||||
* caught at the process level.
|
||||
* - `ExitPromptError` — inquirer, which does reject cleanly and whose rejection
|
||||
* travels up the normal call chain.
|
||||
* - a bare `''` or an ETX byte — enquirer rejecting a cancelled prompt with the
|
||||
* keypress that cancelled it, on the runs where the teardown does not throw.
|
||||
*/
|
||||
export function isCancellation(error: unknown): boolean {
|
||||
if (error === '' || error === ETX) return true;
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
|
||||
const { name, code } = error as { name?: unknown; code?: unknown };
|
||||
return (
|
||||
code === 'ERR_USE_AFTER_CLOSE' || (typeof name === 'string' && CANCEL_ERROR_NAMES.has(name))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the terminal back the way it was found.
|
||||
*
|
||||
* A prompt owns the terminal while it runs: stdin is in raw mode and the cursor
|
||||
* is hidden. Exiting from under it leaves the shell with no cursor and no echo,
|
||||
* so this runs on every abnormal exit, cancelled or crashed. Best-effort by
|
||||
* design — a destroyed stdin throws on `setRawMode`, and a failure to tidy up
|
||||
* must not replace the message explaining why we are leaving.
|
||||
*/
|
||||
export function restoreTerminal(): void {
|
||||
try {
|
||||
if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false);
|
||||
if (process.stdout.isTTY) process.stdout.write(SHOW_CURSOR);
|
||||
} catch {
|
||||
// Nothing useful to do about a terminal that will not be restored.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Says goodbye and leaves.
|
||||
*
|
||||
* The farewell goes to **stderr**, for the same reason the update hint does:
|
||||
* `--json` and `--print-only` stay machine-readable no matter how the run ends.
|
||||
*
|
||||
* `process.exit` rather than letting the loop drain, because the prompt that
|
||||
* was cancelled is still holding stdin — after the teardown above threw, its
|
||||
* promise is pending forever and nothing else will end the process.
|
||||
*/
|
||||
export function exitWithFarewell(code: number = CANCELLED_EXIT_CODE): never {
|
||||
restoreTerminal();
|
||||
process.stderr.write(`\n${FAREWELL}\n`);
|
||||
return process.exit(code) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a genuine crash, having first handed the terminal back.
|
||||
*
|
||||
* Deliberately as loud as node's own default — the stack, not a summary. The
|
||||
* only thing being taken over is *when* it prints, so that {@link
|
||||
* restoreTerminal} gets to run first.
|
||||
*/
|
||||
function reportFatal(error: unknown): never {
|
||||
restoreTerminal();
|
||||
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
||||
return process.exit(1) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the process-level handlers that turn a Ctrl-C into {@link FAREWELL}.
|
||||
*
|
||||
* Two entry points, because Ctrl-C arrives differently depending on who owns
|
||||
* the terminal. During a prompt, stdin is in raw mode: the process gets no
|
||||
* `SIGINT` at all, the keypress goes to the prompt library, and the failure
|
||||
* comes back as an unhandled rejection. Everywhere else — cube loading, a
|
||||
* pyinfra run — the signal arrives normally.
|
||||
*
|
||||
* Returns a disposer, which the CLI ignores and the tests do not.
|
||||
*/
|
||||
export function installGracefulExit(): () => void {
|
||||
const onSignal = () => exitWithFarewell();
|
||||
const onFatal = (reason: unknown) => {
|
||||
if (isCancellation(reason)) {
|
||||
exitWithFarewell();
|
||||
return;
|
||||
}
|
||||
reportFatal(reason);
|
||||
};
|
||||
|
||||
process.on('SIGINT', onSignal);
|
||||
process.on('uncaughtException', onFatal);
|
||||
process.on('unhandledRejection', onFatal);
|
||||
|
||||
return () => {
|
||||
process.off('SIGINT', onSignal);
|
||||
process.off('uncaughtException', onFatal);
|
||||
process.off('unhandledRejection', onFatal);
|
||||
};
|
||||
}
|
||||
@@ -83,7 +83,9 @@ export async function AuthSelection(useAuthKey?: boolean): Promise<{
|
||||
if (useAuthKey) return { authMethod: 'ssh-key' };
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
// `select`, not `list`: inquirer 14 dropped the legacy name and rejects
|
||||
// an unknown type outright.
|
||||
type: 'select',
|
||||
name: 'authMethod',
|
||||
message: 'Select authentication method:',
|
||||
choices: ['ssh-key', 'password'],
|
||||
@@ -118,7 +120,7 @@ export async function PasswordSelection(username: string): Promise<string> {
|
||||
export async function HostSelection(hosts: string[]): Promise<string> {
|
||||
const selectedHost = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
type: 'select',
|
||||
name: 'host',
|
||||
message: 'Select host from inventory',
|
||||
choices: ['docker', 'vagrant', ...hosts, 'custom'],
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* Update checking and self-update for the nopy CLI
|
||||
*
|
||||
* The channel a user is on is never stored anywhere — it is derived from the
|
||||
* version they are running, which is the one piece of state that is always
|
||||
* correct. A `-main.` prerelease came from the snapshot workflow, any other
|
||||
* prerelease came out under `next`, and a clean version came out under
|
||||
* `latest`. Upgrading therefore keeps you on the channel you installed from
|
||||
* instead of silently moving you to a different one.
|
||||
*
|
||||
* @module nopy.update
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import semver from 'semver';
|
||||
|
||||
/** The published package this CLI ships as */
|
||||
export const PACKAGE_NAME = '@bitsquare/nopy';
|
||||
|
||||
/** The npm scope the package lives under, used for the registry config key */
|
||||
export const SCOPE = '@bitsquare';
|
||||
|
||||
/** Where packages resolve from when nothing says otherwise */
|
||||
export const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
|
||||
|
||||
/** Directory under the user's home holding the update-check cache */
|
||||
export const UPDATE_CACHE_DIR = '.nopy';
|
||||
|
||||
/** File name of the update-check cache */
|
||||
export const UPDATE_CACHE_FILE = 'update-check.json';
|
||||
|
||||
/** How long a cached check is considered fresh */
|
||||
export const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How long the background check may block the CLI.
|
||||
*
|
||||
* Short on purpose: this runs before the first prompt, so a slow or
|
||||
* unreachable registry has to cost a moment, not a session.
|
||||
*/
|
||||
export const DEFAULT_FETCH_TIMEOUT_MS = 1500;
|
||||
|
||||
/** How long `npm config get` may take before the registry falls back to npmjs */
|
||||
export const DEFAULT_CONFIG_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* A dist-tag this project publishes under.
|
||||
*
|
||||
* `latest` is a release, `next` a prerelease (`0.6.0-rc.1`), `main` a snapshot
|
||||
* built from a commit on `main` and published to Gitea only.
|
||||
*/
|
||||
export type Channel = 'latest' | 'next' | 'main';
|
||||
|
||||
/** A package manager that can install a global binary */
|
||||
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||
|
||||
/** Runs a command and resolves with its trimmed stdout */
|
||||
export type CommandRunner = (file: string, args: string[]) => Promise<string>;
|
||||
|
||||
/** The result of an update check */
|
||||
export interface UpdateStatus {
|
||||
/** The version currently running */
|
||||
current: string;
|
||||
/** The version the channel points at, or null if it could not be determined */
|
||||
latest: string | null;
|
||||
/** The channel the current version implies */
|
||||
channel: Channel;
|
||||
/** The registry the check went to */
|
||||
registry: string;
|
||||
/** Whether `latest` is strictly newer than `current` */
|
||||
updateAvailable: boolean;
|
||||
/** Whether the answer came from cache rather than the network */
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
/** The on-disk update-check cache */
|
||||
export interface UpdateCache {
|
||||
/** ISO timestamp of the check */
|
||||
checkedAt: string;
|
||||
/** The channel that was checked */
|
||||
channel: Channel;
|
||||
/** The registry that was checked */
|
||||
registry: string;
|
||||
/** The version the channel pointed at, or null if the lookup found nothing */
|
||||
latest: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the release channel from a version string.
|
||||
*
|
||||
* @param version - a semver version, typically this package's own
|
||||
* @returns the dist-tag that version would have been published under
|
||||
*/
|
||||
export function channelForVersion(version: string): Channel {
|
||||
const parsed = semver.parse(version, { loose: true });
|
||||
|
||||
// An unparseable version is treated as a release: the worst case is that a
|
||||
// check goes to `latest` and finds nothing newer.
|
||||
if (!parsed || parsed.prerelease.length === 0) {
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
return parsed.prerelease.some((part) => part === 'main') ? 'main' : 'next';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a registry URL to the trailing-slash form the packument path is
|
||||
* appended to.
|
||||
*/
|
||||
export function normalizeRegistry(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
}
|
||||
|
||||
/** Runs a command through execa and returns its stdout */
|
||||
const defaultRunner: CommandRunner = async (file, args) => {
|
||||
const { stdout } = await execa(file, args, { timeout: DEFAULT_CONFIG_TIMEOUT_MS });
|
||||
return stdout;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the registry `@bitsquare` packages come from.
|
||||
*
|
||||
* `NOPY_REGISTRY` wins, then npm's own scoped-registry config — asking npm is
|
||||
* what makes a global install from Gitea check Gitea for its updates without
|
||||
* anything else being configured — and npmjs is the fallback.
|
||||
*
|
||||
* @returns a registry URL in trailing-slash form
|
||||
*/
|
||||
export async function resolveRegistry(
|
||||
options: { env?: NodeJS.ProcessEnv; run?: CommandRunner } = {}
|
||||
): Promise<string> {
|
||||
const env = options.env ?? process.env;
|
||||
|
||||
const override = env.NOPY_REGISTRY?.trim();
|
||||
if (override) {
|
||||
return normalizeRegistry(override);
|
||||
}
|
||||
|
||||
const run = options.run ?? defaultRunner;
|
||||
try {
|
||||
const stdout = (await run('npm', ['config', 'get', `${SCOPE}:registry`])).trim();
|
||||
// npm prints the string "undefined" for an unset key rather than nothing.
|
||||
if (stdout && stdout !== 'undefined' && stdout !== 'null') {
|
||||
return normalizeRegistry(stdout);
|
||||
}
|
||||
} catch {
|
||||
// npm not on PATH, or the config is unreadable. Neither is worth failing a
|
||||
// deployment over.
|
||||
}
|
||||
|
||||
return NPMJS_REGISTRY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version a dist-tag points at, straight from the registry.
|
||||
*
|
||||
* Deliberately a plain `fetch` of the packument rather than shelling out to
|
||||
* `npm view`: it is one request, it honours a timeout, and it cannot be slowed
|
||||
* down by npm's own startup.
|
||||
*
|
||||
* @returns the version, or null if the registry or the tag has nothing
|
||||
*/
|
||||
export async function fetchChannelVersion(options: {
|
||||
registry: string;
|
||||
channel: Channel;
|
||||
packageName?: string;
|
||||
timeoutMs?: number;
|
||||
token?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<string | null> {
|
||||
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
||||
const packageName = options.packageName ?? PACKAGE_NAME;
|
||||
const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
// The abbreviated packument where the registry supports it; Gitea ignores
|
||||
// this and sends the full document, which parses the same.
|
||||
accept: 'application/vnd.npm.install-v1+json, application/json',
|
||||
};
|
||||
if (options.token) {
|
||||
headers.authorization = `Bearer ${options.token}`;
|
||||
}
|
||||
|
||||
const response = await doFetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { 'dist-tags'?: Record<string, string> };
|
||||
return body['dist-tags']?.[options.channel] ?? null;
|
||||
}
|
||||
|
||||
/** Path of the update-check cache file */
|
||||
export function getUpdateCachePath(homedir: string = os.homedir()): string {
|
||||
return path.join(homedir, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the update-check cache.
|
||||
*
|
||||
* @returns the cache, or null if it is missing or unreadable
|
||||
*/
|
||||
export function readUpdateCache(cachePath: string = getUpdateCachePath()): UpdateCache | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as UpdateCache;
|
||||
// A hand-edited or half-written file must not be trusted into the compare.
|
||||
return typeof parsed?.checkedAt === 'string' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the update-check cache. Best effort — a read-only home directory
|
||||
* costs a network check per run, not a failure.
|
||||
*/
|
||||
export function writeUpdateCache(
|
||||
cache: UpdateCache,
|
||||
cachePath: string = getUpdateCachePath()
|
||||
): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
|
||||
} catch {
|
||||
// Ignored on purpose.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the startup check should be skipped entirely.
|
||||
*
|
||||
* `NOPY_NO_UPDATE_CHECK` is the explicit opt-out; `CI` covers the case nobody
|
||||
* remembers to opt out of.
|
||||
*/
|
||||
export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const flag = env.NOPY_NO_UPDATE_CHECK?.trim().toLowerCase();
|
||||
if (flag && flag !== '0' && flag !== 'false') {
|
||||
return true;
|
||||
}
|
||||
return Boolean(env.CI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a newer version exists on the current channel.
|
||||
*
|
||||
* Answers from cache when a check happened recently for the same channel and
|
||||
* registry; otherwise asks the registry and refreshes the cache. A failed
|
||||
* lookup falls back to whatever the cache last saw, so a flaky network degrades
|
||||
* to a stale answer rather than no answer.
|
||||
*/
|
||||
export async function checkForUpdate(options: {
|
||||
currentVersion: string;
|
||||
channel?: Channel;
|
||||
registry?: string;
|
||||
force?: boolean;
|
||||
intervalMs?: number;
|
||||
cachePath?: string;
|
||||
now?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
run?: CommandRunner;
|
||||
}): Promise<UpdateStatus> {
|
||||
const {
|
||||
currentVersion,
|
||||
force = false,
|
||||
intervalMs = DEFAULT_CHECK_INTERVAL_MS,
|
||||
cachePath = getUpdateCachePath(),
|
||||
now = Date.now(),
|
||||
env = process.env,
|
||||
} = options;
|
||||
|
||||
const channel = options.channel ?? channelForVersion(currentVersion);
|
||||
const registry = normalizeRegistry(
|
||||
options.registry ?? (await resolveRegistry({ env, run: options.run }))
|
||||
);
|
||||
|
||||
const cache = readUpdateCache(cachePath);
|
||||
// A cache entry for a different channel or registry answers a different
|
||||
// question, so it is never fresh for this one.
|
||||
const applicable = cache && cache.channel === channel && cache.registry === registry;
|
||||
const age = cache ? now - Date.parse(cache.checkedAt) : Number.POSITIVE_INFINITY;
|
||||
const fresh = applicable && Number.isFinite(age) && age >= 0 && age < intervalMs;
|
||||
|
||||
if (!force && fresh && cache) {
|
||||
return status(currentVersion, cache.latest, channel, registry, true);
|
||||
}
|
||||
|
||||
try {
|
||||
const latest = await fetchChannelVersion({
|
||||
registry,
|
||||
channel,
|
||||
timeoutMs: options.timeoutMs,
|
||||
token: env.NOPY_REGISTRY_TOKEN?.trim() || undefined,
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
writeUpdateCache(
|
||||
{ checkedAt: new Date(now).toISOString(), channel, registry, latest },
|
||||
cachePath
|
||||
);
|
||||
return status(currentVersion, latest, channel, registry, false);
|
||||
} catch {
|
||||
// Offline, timed out, or the registry returned something unparseable.
|
||||
return status(
|
||||
currentVersion,
|
||||
applicable && cache ? cache.latest : null,
|
||||
channel,
|
||||
registry,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assembles an {@link UpdateStatus}, deciding whether the remote version wins */
|
||||
function status(
|
||||
current: string,
|
||||
latest: string | null,
|
||||
channel: Channel,
|
||||
registry: string,
|
||||
fromCache: boolean
|
||||
): UpdateStatus {
|
||||
const updateAvailable = Boolean(
|
||||
latest && semver.valid(latest) && semver.valid(current) && semver.gt(latest, current)
|
||||
);
|
||||
return { current, latest, channel, registry, updateAvailable, fromCache };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects which package manager installed this CLI, so `self-update` re-runs
|
||||
* the same one rather than leaving two copies on the PATH.
|
||||
*
|
||||
* The install path is the evidence: pnpm and bun keep globals under their own
|
||||
* directory, npm does not.
|
||||
*/
|
||||
export function detectPackageManager(
|
||||
options: { execPath?: string; env?: NodeJS.ProcessEnv } = {}
|
||||
): PackageManager {
|
||||
const env = options.env ?? process.env;
|
||||
|
||||
const override = env.NOPY_PACKAGE_MANAGER?.trim().toLowerCase();
|
||||
if (override === 'npm' || override === 'pnpm' || override === 'yarn' || override === 'bun') {
|
||||
return override;
|
||||
}
|
||||
|
||||
const from = (options.execPath ?? process.argv[1] ?? '').replace(/\\/g, '/').toLowerCase();
|
||||
if (from.includes('/pnpm/')) return 'pnpm';
|
||||
if (from.includes('/.bun/')) return 'bun';
|
||||
if (from.includes('/.yarn/') || from.includes('/yarn/')) return 'yarn';
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the command that installs a given channel globally.
|
||||
*
|
||||
* The registry is passed as a **scoped** override rather than `--registry`.
|
||||
* That is load-bearing for Gitea: its npm registry serves `@bitsquare`
|
||||
* packages and does not proxy npmjs, so a global `--registry` would send
|
||||
* `commander`, `execa` and every other dependency to a registry that has never
|
||||
* heard of them.
|
||||
*/
|
||||
export function buildSelfUpdateCommand(options: {
|
||||
packageManager: PackageManager;
|
||||
channel: Channel;
|
||||
registry: string;
|
||||
packageName?: string;
|
||||
}): { file: string; args: string[] } {
|
||||
const packageName = options.packageName ?? PACKAGE_NAME;
|
||||
const spec = `${packageName}@${options.channel}`;
|
||||
|
||||
const registryArgs =
|
||||
normalizeRegistry(options.registry) === NPMJS_REGISTRY
|
||||
? []
|
||||
: [`--${SCOPE}:registry=${normalizeRegistry(options.registry)}`];
|
||||
|
||||
switch (options.packageManager) {
|
||||
case 'pnpm':
|
||||
return { file: 'pnpm', args: ['add', '--global', spec, ...registryArgs] };
|
||||
case 'yarn':
|
||||
return { file: 'yarn', args: ['global', 'add', spec, ...registryArgs] };
|
||||
case 'bun':
|
||||
return { file: 'bun', args: ['add', '--global', spec, ...registryArgs] };
|
||||
default:
|
||||
return { file: 'npm', args: ['install', '--global', spec, ...registryArgs] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a command as the shell line a user could paste */
|
||||
export function formatCommand(command: { file: string; args: string[] }): string {
|
||||
return [command.file, ...command.args].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the one-line hint printed at startup when an update exists.
|
||||
*
|
||||
* @returns the notice, or null when there is nothing to say
|
||||
*/
|
||||
export function formatUpdateNotice(
|
||||
status: UpdateStatus,
|
||||
packageManager?: PackageManager
|
||||
): string | null {
|
||||
if (!status.updateAvailable || !status.latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: packageManager ?? detectPackageManager(),
|
||||
channel: status.channel,
|
||||
registry: status.registry,
|
||||
});
|
||||
|
||||
const channelNote = status.channel === 'latest' ? '' : ` (${status.channel})`;
|
||||
return [
|
||||
`Update available: ${status.current} -> ${status.latest}${channelNote}`,
|
||||
`Run "nopy self-update" or "${formatCommand(command)}"`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The startup path: returns the notice to print, or null.
|
||||
*
|
||||
* Never throws and never blocks for longer than the fetch timeout, because it
|
||||
* sits in front of every command the user actually asked for.
|
||||
*/
|
||||
export async function updateNotice(options: {
|
||||
currentVersion: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cachePath?: string;
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
now?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
run?: CommandRunner;
|
||||
}): Promise<string | null> {
|
||||
const env = options.env ?? process.env;
|
||||
if (isUpdateCheckDisabled(env)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await checkForUpdate({ ...options, env });
|
||||
return formatUpdateNotice(status, detectPackageManager({ env }));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of a {@link selfUpdate} run */
|
||||
export interface SelfUpdateResult {
|
||||
/** The status the decision was based on */
|
||||
status: UpdateStatus;
|
||||
/** The command that was run, or would have been run */
|
||||
command: { file: string; args: string[] };
|
||||
/** Whether the install actually ran */
|
||||
ran: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the newest version on the current channel.
|
||||
*
|
||||
* @param options.dryRun - print the command instead of running it
|
||||
* @param options.force - reinstall even when already up to date
|
||||
*/
|
||||
export async function selfUpdate(options: {
|
||||
currentVersion: string;
|
||||
channel?: Channel;
|
||||
registry?: string;
|
||||
packageManager?: PackageManager;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cachePath?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
run?: CommandRunner;
|
||||
spawn?: (file: string, args: string[]) => Promise<unknown>;
|
||||
}): Promise<SelfUpdateResult> {
|
||||
const env = options.env ?? process.env;
|
||||
|
||||
// Always ignore the cache here: the user asked, so the answer has to be current.
|
||||
const status = await checkForUpdate({
|
||||
currentVersion: options.currentVersion,
|
||||
channel: options.channel,
|
||||
registry: options.registry,
|
||||
force: true,
|
||||
cachePath: options.cachePath,
|
||||
env,
|
||||
fetchImpl: options.fetchImpl,
|
||||
run: options.run,
|
||||
});
|
||||
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: options.packageManager ?? detectPackageManager({ env }),
|
||||
channel: status.channel,
|
||||
registry: status.registry,
|
||||
});
|
||||
|
||||
if (options.dryRun || (!status.updateAvailable && !options.force)) {
|
||||
return { status, command, ran: false };
|
||||
}
|
||||
|
||||
const spawn =
|
||||
options.spawn ?? ((file: string, args: string[]) => execa(file, args, { stdio: 'inherit' }));
|
||||
await spawn(command.file, command.args);
|
||||
|
||||
return { status, command, ran: true };
|
||||
}
|
||||
@@ -108,19 +108,48 @@ describe('resolveCubePackages', () => {
|
||||
expect(errors[0]).toMatch(/cannot read/);
|
||||
});
|
||||
|
||||
it('reports a package that declares no cubes', () => {
|
||||
install(tmpDir, 'plain', {});
|
||||
it('falls back to cubes/ when the package declares nothing', () => {
|
||||
// The convention. A bundle that ships cubes/ at its root needs no `nopy`
|
||||
// block at all, and one with an unrelated `nopy` block still gets it.
|
||||
const plain = install(tmpDir, 'plain', {});
|
||||
const other = install(tmpDir, 'other', { nopy: { somethingElse: true } });
|
||||
|
||||
const { packages, errors } = resolveCubePackages(
|
||||
['plain', 'other'].map((spec) => ({ spec, from: tmpDir }))
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(packages.map((pkg) => pkg.dirs)).toEqual([
|
||||
[path.join(plain, 'cubes')],
|
||||
[path.join(other, 'cubes')],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a package with neither a declaration nor a cubes/ directory', () => {
|
||||
install(tmpDir, 'bare', {}, []);
|
||||
|
||||
const { packages, errors } = resolveCubePackages([{ spec: 'bare', from: tmpDir }]);
|
||||
|
||||
expect(packages).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatch(/has no cubes\/ directory/);
|
||||
expect(errors[0]).toMatch(/declares no "nopy"/);
|
||||
});
|
||||
|
||||
it('reports a malformed declaration instead of falling back to the default', () => {
|
||||
// Each of these ships a usable cubes/ directory. Saying something that does
|
||||
// not parse is not the same as saying nothing, so none of them resolve.
|
||||
install(tmpDir, 'empty', { nopy: { cubes: [] } });
|
||||
install(tmpDir, 'wrong-type', { nopy: { cubes: 'cubes' } });
|
||||
install(tmpDir, 'not-strings', { nopy: { cubes: [1] } });
|
||||
|
||||
const { packages, errors } = resolveCubePackages(
|
||||
['plain', 'empty', 'wrong-type', 'not-strings'].map((spec) => ({ spec, from: tmpDir }))
|
||||
['empty', 'wrong-type', 'not-strings'].map((spec) => ({ spec, from: tmpDir }))
|
||||
);
|
||||
|
||||
expect(packages).toEqual([]);
|
||||
expect(errors).toHaveLength(4);
|
||||
for (const error of errors) expect(error).toMatch(/declares no cubes/);
|
||||
expect(errors).toHaveLength(3);
|
||||
for (const error of errors) expect(error).toMatch(/must be a non-empty array of strings/);
|
||||
});
|
||||
|
||||
it('reports a cube directory that does not exist', () => {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Tests for nopy.exit.
|
||||
*
|
||||
* The handlers are invoked by calling the listener `installGracefulExit`
|
||||
* registered, not by `process.emit()`-ing the event: vitest listens for
|
||||
* `unhandledRejection` and `uncaughtException` itself and would report a
|
||||
* synthetic one as a failure of the test file.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CANCELLED_EXIT_CODE,
|
||||
exitWithFarewell,
|
||||
FAREWELL,
|
||||
installGracefulExit,
|
||||
isCancellation,
|
||||
restoreTerminal,
|
||||
} from '../src/nopy.exit.js';
|
||||
|
||||
let exit: ReturnType<typeof vi.spyOn>;
|
||||
let stderr: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** Pretends stdin/stdout are the terminal a prompt would have taken over. */
|
||||
function fakeTerminal(opts: { isTTY: boolean; isRaw?: boolean }) {
|
||||
const setRawMode = vi.fn();
|
||||
const original = {
|
||||
isTTY: process.stdin.isTTY,
|
||||
isRaw: process.stdin.isRaw,
|
||||
setRawMode: process.stdin.setRawMode,
|
||||
outTTY: process.stdout.isTTY,
|
||||
};
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: opts.isTTY, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'isRaw', { value: opts.isRaw ?? false, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'setRawMode', { value: setRawMode, configurable: true });
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: opts.isTTY, configurable: true });
|
||||
|
||||
const restore = () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: original.isTTY, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'isRaw', { value: original.isRaw, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'setRawMode', {
|
||||
value: original.setRawMode,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: original.outTTY, configurable: true });
|
||||
};
|
||||
|
||||
return { setRawMode, restore };
|
||||
}
|
||||
|
||||
/** The listener `installGracefulExit` most recently added for `event`. */
|
||||
const lastListener = (event: string) =>
|
||||
process.listeners(event as 'SIGINT').at(-1) as (reason?: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isCancellation', () => {
|
||||
it('recognises enquirer tearing down a readline node already closed', () => {
|
||||
const err = Object.assign(new Error('readline was closed'), { code: 'ERR_USE_AFTER_CLOSE' });
|
||||
|
||||
expect(isCancellation(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises the inquirer cancellations', () => {
|
||||
const exitPrompt = Object.assign(new Error('User force closed the prompt'), {
|
||||
name: 'ExitPromptError',
|
||||
});
|
||||
const cancelPrompt = Object.assign(new Error('Prompt was canceled'), {
|
||||
name: 'CancelPromptError',
|
||||
});
|
||||
|
||||
expect(isCancellation(exitPrompt)).toBe(true);
|
||||
expect(isCancellation(cancelPrompt)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises the bare values enquirer rejects a cancelled prompt with', () => {
|
||||
expect(isCancellation('')).toBe(true);
|
||||
expect(isCancellation('\x03')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a genuine failure alone', () => {
|
||||
expect(isCancellation(new Error('pyinfra exited 1'))).toBe(false);
|
||||
expect(isCancellation(Object.assign(new Error('nope'), { code: 'ENOENT' }))).toBe(false);
|
||||
expect(isCancellation('boom')).toBe(false);
|
||||
expect(isCancellation(undefined)).toBe(false);
|
||||
expect(isCancellation(null)).toBe(false);
|
||||
expect(isCancellation(7)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreTerminal', () => {
|
||||
it('leaves raw mode and shows the cursor again', () => {
|
||||
const terminal = fakeTerminal({ isTTY: true, isRaw: true });
|
||||
|
||||
restoreTerminal();
|
||||
|
||||
expect(terminal.setRawMode).toHaveBeenCalledWith(false);
|
||||
expect(process.stdout.write).toHaveBeenCalledWith('\x1B[?25h');
|
||||
terminal.restore();
|
||||
});
|
||||
|
||||
it('touches nothing when the output is not a terminal', () => {
|
||||
const terminal = fakeTerminal({ isTTY: false });
|
||||
|
||||
restoreTerminal();
|
||||
|
||||
expect(terminal.setRawMode).not.toHaveBeenCalled();
|
||||
expect(process.stdout.write).not.toHaveBeenCalled();
|
||||
terminal.restore();
|
||||
});
|
||||
|
||||
it('survives a stdin that refuses to leave raw mode', () => {
|
||||
const terminal = fakeTerminal({ isTTY: true, isRaw: true });
|
||||
terminal.setRawMode.mockImplementation(() => {
|
||||
throw new Error('stdin destroyed');
|
||||
});
|
||||
|
||||
expect(() => restoreTerminal()).not.toThrow();
|
||||
terminal.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitWithFarewell', () => {
|
||||
it('says goodbye on stderr and exits 130', () => {
|
||||
exitWithFarewell();
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
|
||||
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
|
||||
});
|
||||
|
||||
it('accepts a different exit code', () => {
|
||||
exitWithFarewell(0);
|
||||
|
||||
expect(exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installGracefulExit', () => {
|
||||
let dispose: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
dispose = installGracefulExit();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('says goodbye on SIGINT', () => {
|
||||
lastListener('SIGINT')();
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
|
||||
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
|
||||
});
|
||||
|
||||
it('says goodbye for the rejection nothing is awaiting', () => {
|
||||
// The shape of the real crash: enquirer's Ctrl-C teardown, which leaves
|
||||
// `prompt.run()` pending forever, so no `catch` in nopy.prompts sees it.
|
||||
lastListener('unhandledRejection')(
|
||||
Object.assign(new Error('readline was closed'), { code: 'ERR_USE_AFTER_CLOSE' })
|
||||
);
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(`\n${FAREWELL}\n`);
|
||||
expect(exit).toHaveBeenCalledWith(CANCELLED_EXIT_CODE);
|
||||
});
|
||||
|
||||
it('still reports a real crash, loudly, and exits 1', () => {
|
||||
const boom = new Error('everything is on fire');
|
||||
|
||||
lastListener('uncaughtException')(boom);
|
||||
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(boom.stack);
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('reports a thrown non-error too', () => {
|
||||
lastListener('uncaughtException')('just a string');
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith('just a string');
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('reports an error with no stack by its message', () => {
|
||||
const stackless = new Error('no stack here');
|
||||
stackless.stack = undefined;
|
||||
|
||||
lastListener('uncaughtException')(stackless);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith('no stack here');
|
||||
});
|
||||
|
||||
it('hands the process back on dispose', () => {
|
||||
const before = {
|
||||
SIGINT: process.listenerCount('SIGINT'),
|
||||
uncaughtException: process.listenerCount('uncaughtException'),
|
||||
unhandledRejection: process.listenerCount('unhandledRejection'),
|
||||
};
|
||||
|
||||
dispose();
|
||||
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT - 1);
|
||||
expect(process.listenerCount('uncaughtException')).toBe(before.uncaughtException - 1);
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before.unhandledRejection - 1);
|
||||
|
||||
// The afterEach disposer runs a second time; make it a no-op.
|
||||
dispose = () => {};
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,32 @@ describe('HostSelection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('question types', () => {
|
||||
it('declares only types the installed inquirer actually ships', async () => {
|
||||
// Checked against the real module, not the mock: `list` was accepted for
|
||||
// years and inquirer 14 dropped it, which took out host selection entirely
|
||||
// — a failure no amount of mocked prompting can see.
|
||||
const actual = await vi.importActual<typeof import('inquirer')>('inquirer');
|
||||
const supported = Object.keys(actual.createPromptModule().prompts);
|
||||
|
||||
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
|
||||
await HostSelection(['web-1']);
|
||||
inquirerPrompt.mockResolvedValue({ authMethod: 'password', username: 'u', password: 'p' });
|
||||
await AuthSelection(false);
|
||||
inquirerPrompt.mockResolvedValue({ password: 'p' });
|
||||
await PasswordSelection('deploy');
|
||||
|
||||
const declared = new Set(
|
||||
inquirerPrompt.mock.calls.flatMap(([asked]: [Record<string, any>[]]) =>
|
||||
asked.map((q) => q.type ?? 'input')
|
||||
)
|
||||
);
|
||||
|
||||
expect(declared.size).toBeGreaterThan(0);
|
||||
expect(supported).toEqual(expect.arrayContaining([...declared]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('VariableAssignment', () => {
|
||||
const schema = z.object({
|
||||
port: z.number().default(8080).describe('Listen port'),
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
/**
|
||||
* Tests for nopy.update module
|
||||
*
|
||||
* Every network call, clock read and spawn is injected, so nothing here
|
||||
* reaches a registry or the user's home directory.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildSelfUpdateCommand,
|
||||
type Channel,
|
||||
channelForVersion,
|
||||
checkForUpdate,
|
||||
detectPackageManager,
|
||||
fetchChannelVersion,
|
||||
formatCommand,
|
||||
formatUpdateNotice,
|
||||
getUpdateCachePath,
|
||||
isUpdateCheckDisabled,
|
||||
NPMJS_REGISTRY,
|
||||
normalizeRegistry,
|
||||
readUpdateCache,
|
||||
resolveRegistry,
|
||||
selfUpdate,
|
||||
type UpdateCache,
|
||||
updateNotice,
|
||||
writeUpdateCache,
|
||||
} from '../src/nopy.update.js';
|
||||
|
||||
const GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
|
||||
|
||||
let tmpDir: string;
|
||||
let cachePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-update-'));
|
||||
cachePath = path.join(tmpDir, 'update-check.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A fetch stand-in returning the given dist-tags */
|
||||
function fakeFetch(distTags: Record<string, string>, ok = true): typeof fetch {
|
||||
return (async () =>
|
||||
({
|
||||
ok,
|
||||
json: async () => ({ 'dist-tags': distTags }),
|
||||
}) as Response) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('channelForVersion', () => {
|
||||
it('maps a clean release to latest', () => {
|
||||
expect(channelForVersion('0.5.0')).toBe('latest');
|
||||
expect(channelForVersion('1.2.3')).toBe('latest');
|
||||
});
|
||||
|
||||
it('maps a snapshot to main', () => {
|
||||
expect(channelForVersion('0.5.0-main.14.g6ecb2c3')).toBe('main');
|
||||
});
|
||||
|
||||
it('maps any other prerelease to next', () => {
|
||||
expect(channelForVersion('0.6.0-rc.1')).toBe('next');
|
||||
expect(channelForVersion('1.0.0-alpha5')).toBe('next');
|
||||
});
|
||||
|
||||
it('treats an unparseable version as latest', () => {
|
||||
expect(channelForVersion('not-a-version')).toBe('latest');
|
||||
expect(channelForVersion('')).toBe('latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRegistry', () => {
|
||||
it('adds a trailing slash', () => {
|
||||
expect(normalizeRegistry('https://example.com/npm')).toBe('https://example.com/npm/');
|
||||
});
|
||||
|
||||
it('leaves an existing trailing slash alone', () => {
|
||||
expect(normalizeRegistry(GITEA)).toBe(GITEA);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(normalizeRegistry(' https://example.com/npm ')).toBe('https://example.com/npm/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRegistry', () => {
|
||||
it('prefers the NOPY_REGISTRY override', async () => {
|
||||
const run = vi.fn();
|
||||
const registry = await resolveRegistry({
|
||||
env: { NOPY_REGISTRY: 'https://example.com/npm' },
|
||||
run,
|
||||
});
|
||||
expect(registry).toBe('https://example.com/npm/');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to npm config', async () => {
|
||||
const run = vi.fn(async () => GITEA);
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(GITEA);
|
||||
expect(run).toHaveBeenCalledWith('npm', ['config', 'get', '@bitsquare:registry']);
|
||||
});
|
||||
|
||||
it('treats npm printing "undefined" as unset', async () => {
|
||||
const run = vi.fn(async () => 'undefined');
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||
});
|
||||
|
||||
it('treats npm printing "null" as unset', async () => {
|
||||
const run = vi.fn(async () => 'null');
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||
});
|
||||
|
||||
it('treats empty output as unset', async () => {
|
||||
const run = vi.fn(async () => ' ');
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||
});
|
||||
|
||||
it('falls back to npmjs when npm is missing', async () => {
|
||||
const run = vi.fn(async () => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
|
||||
});
|
||||
|
||||
it('ignores a blank override', async () => {
|
||||
const run = vi.fn(async () => GITEA);
|
||||
expect(await resolveRegistry({ env: { NOPY_REGISTRY: ' ' }, run })).toBe(GITEA);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChannelVersion', () => {
|
||||
it('reads the requested dist-tag', async () => {
|
||||
const version = await fetchChannelVersion({
|
||||
registry: GITEA,
|
||||
channel: 'main',
|
||||
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234', latest: '0.5.0' }),
|
||||
});
|
||||
expect(version).toBe('0.5.0-main.14.gabc1234');
|
||||
});
|
||||
|
||||
it('returns null when the tag does not exist', async () => {
|
||||
const version = await fetchChannelVersion({
|
||||
registry: GITEA,
|
||||
channel: 'latest',
|
||||
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234' }),
|
||||
});
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on a non-ok response', async () => {
|
||||
const version = await fetchChannelVersion({
|
||||
registry: GITEA,
|
||||
channel: 'latest',
|
||||
fetchImpl: fakeFetch({}, false),
|
||||
});
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the packument has no dist-tags at all', async () => {
|
||||
const fetchImpl = (async () =>
|
||||
({ ok: true, json: async () => ({}) }) as Response) as unknown as typeof fetch;
|
||||
expect(await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl })).toBeNull();
|
||||
});
|
||||
|
||||
it('url-encodes the scoped package name onto the registry', async () => {
|
||||
const seen: string[] = [];
|
||||
const fetchImpl = (async (url: string) => {
|
||||
seen.push(url);
|
||||
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
// No trailing slash on purpose: it must be normalised before joining.
|
||||
await fetchChannelVersion({
|
||||
registry: 'https://example.com/npm',
|
||||
channel: 'latest',
|
||||
fetchImpl,
|
||||
});
|
||||
expect(seen[0]).toBe('https://example.com/npm/%40bitsquare%2Fnopy');
|
||||
});
|
||||
|
||||
it('sends a bearer token when one is given', async () => {
|
||||
let headers: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||
headers = init.headers as Record<string, string>;
|
||||
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchChannelVersion({ registry: GITEA, channel: 'latest', token: 'secret', fetchImpl });
|
||||
expect(headers.authorization).toBe('Bearer secret');
|
||||
});
|
||||
|
||||
it('omits the authorization header when no token is given', async () => {
|
||||
let headers: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||
headers = init.headers as Record<string, string>;
|
||||
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl });
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the update cache', () => {
|
||||
it('round-trips', () => {
|
||||
const cache: UpdateCache = {
|
||||
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.6.0',
|
||||
};
|
||||
writeUpdateCache(cache, cachePath);
|
||||
expect(readUpdateCache(cachePath)).toEqual(cache);
|
||||
});
|
||||
|
||||
it('creates the containing directory', () => {
|
||||
const nested = path.join(tmpDir, 'a', 'b', 'update-check.json');
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: null,
|
||||
},
|
||||
nested
|
||||
);
|
||||
expect(fs.existsSync(nested)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a missing file as null', () => {
|
||||
expect(readUpdateCache(path.join(tmpDir, 'absent.json'))).toBeNull();
|
||||
});
|
||||
|
||||
it('reads malformed JSON as null', () => {
|
||||
fs.writeFileSync(cachePath, '{ not json', 'utf-8');
|
||||
expect(readUpdateCache(cachePath)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a file without a checkedAt stamp', () => {
|
||||
fs.writeFileSync(cachePath, JSON.stringify({ latest: '9.9.9' }), 'utf-8');
|
||||
expect(readUpdateCache(cachePath)).toBeNull();
|
||||
});
|
||||
|
||||
it('swallows a write it cannot perform', () => {
|
||||
// A path whose parent is a file, not a directory.
|
||||
const blocked = path.join(cachePath, 'nested.json');
|
||||
fs.writeFileSync(cachePath, '{}', 'utf-8');
|
||||
expect(() =>
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T00:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: null,
|
||||
},
|
||||
blocked
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('defaults to a path under the home directory', () => {
|
||||
expect(getUpdateCachePath('/home/someone')).toBe('/home/someone/.nopy/update-check.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUpdateCheckDisabled', () => {
|
||||
it('is off by default', () => {
|
||||
expect(isUpdateCheckDisabled({})).toBe(false);
|
||||
});
|
||||
|
||||
it('honours NOPY_NO_UPDATE_CHECK', () => {
|
||||
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: '1' })).toBe(true);
|
||||
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: 'yes' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats 0 and false as not disabled', () => {
|
||||
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: '0' })).toBe(false);
|
||||
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: 'false' })).toBe(false);
|
||||
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('disables itself in CI', () => {
|
||||
expect(isUpdateCheckDisabled({ CI: 'true' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForUpdate', () => {
|
||||
const base = {
|
||||
currentVersion: '0.5.0',
|
||||
registry: NPMJS_REGISTRY,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
now: Date.parse('2026-07-29T12:00:00.000Z'),
|
||||
};
|
||||
|
||||
it('reports a newer version on the channel', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status).toMatchObject({
|
||||
current: '0.5.0',
|
||||
latest: '0.6.0',
|
||||
channel: 'latest',
|
||||
updateAvailable: true,
|
||||
fromCache: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no update when the channel matches', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||
});
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat an older published version as an update', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.4.0' }),
|
||||
});
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('derives the channel from the running version', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
currentVersion: '0.5.0-main.13.gabc1234',
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gdef5678', latest: '0.5.0' }),
|
||||
});
|
||||
expect(status.channel).toBe('main');
|
||||
expect(status.latest).toBe('0.5.0-main.14.gdef5678');
|
||||
expect(status.updateAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('writes what it found to the cache', async () => {
|
||||
await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }) });
|
||||
expect(readUpdateCache(cachePath)).toEqual({
|
||||
checkedAt: '2026-07-29T12:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.6.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers from a fresh cache without touching the network', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.7.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const fetchImpl = vi.fn();
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
expect(status.latest).toBe('0.7.0');
|
||||
expect(status.fromCache).toBe(true);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refetches once the cache goes stale', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-27T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.7.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.8.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.8.0');
|
||||
expect(status.fromCache).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a cache written for a different channel', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||
channel: 'next',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '9.9.9',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.6.0');
|
||||
});
|
||||
|
||||
it('ignores a cache written for a different registry', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: GITEA,
|
||||
latest: '9.9.9',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.6.0');
|
||||
});
|
||||
|
||||
it('ignores a cache stamped in the future', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2027-01-01T00:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '9.9.9',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.6.0');
|
||||
});
|
||||
|
||||
it('ignores a cache with an unparseable stamp', async () => {
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
checkedAt: 'whenever',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '9.9.9',
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.6.0');
|
||||
});
|
||||
|
||||
it('refetches when forced, even with a fresh cache', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-29T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.7.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
force: true,
|
||||
fetchImpl: fakeFetch({ latest: '0.9.0' }),
|
||||
});
|
||||
expect(status.latest).toBe('0.9.0');
|
||||
expect(status.fromCache).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the cached answer when the network fails', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: '2026-07-20T11:00:00.000Z',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.7.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error('offline');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
|
||||
expect(status.latest).toBe('0.7.0');
|
||||
expect(status.updateAvailable).toBe(true);
|
||||
expect(status.fromCache).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing when the network fails and no cache applies', async () => {
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error('offline');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
|
||||
expect(status.latest).toBeNull();
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the registry when none is given', async () => {
|
||||
const status = await checkForUpdate({
|
||||
currentVersion: '0.5.0',
|
||||
cachePath,
|
||||
env: {},
|
||||
run: async () => GITEA,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.registry).toBe(GITEA);
|
||||
});
|
||||
|
||||
it('passes a registry token from the environment through', async () => {
|
||||
let headers: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init: RequestInit) => {
|
||||
headers = init.headers as Record<string, string>;
|
||||
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.6.0' } }) } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await checkForUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
env: { NOPY_REGISTRY_TOKEN: 'tok' },
|
||||
fetchImpl,
|
||||
});
|
||||
expect(headers.authorization).toBe('Bearer tok');
|
||||
});
|
||||
|
||||
it('does not compare against an unparseable current version', async () => {
|
||||
const status = await checkForUpdate({
|
||||
...base,
|
||||
currentVersion: 'dev',
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectPackageManager', () => {
|
||||
it('honours the environment override', () => {
|
||||
expect(
|
||||
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' })
|
||||
).toBe('pnpm');
|
||||
expect(
|
||||
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' })
|
||||
).toBe('yarn');
|
||||
expect(
|
||||
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' })
|
||||
).toBe('bun');
|
||||
expect(
|
||||
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' })
|
||||
).toBe('npm');
|
||||
});
|
||||
|
||||
it('ignores an unrecognised override', () => {
|
||||
expect(
|
||||
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'cargo' }, execPath: '/usr/lib/x' })
|
||||
).toBe('npm');
|
||||
});
|
||||
|
||||
it('recognises a pnpm global install', () => {
|
||||
expect(
|
||||
detectPackageManager({
|
||||
env: {},
|
||||
execPath: '/Users/x/Library/pnpm/global/5/node_modules/.bin/nopy',
|
||||
})
|
||||
).toBe('pnpm');
|
||||
});
|
||||
|
||||
it('recognises a bun global install', () => {
|
||||
expect(
|
||||
detectPackageManager({ env: {}, execPath: '/Users/x/.bun/install/global/node_modules/nopy' })
|
||||
).toBe('bun');
|
||||
});
|
||||
|
||||
it('recognises a yarn global install', () => {
|
||||
expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/nopy' })).toBe('yarn');
|
||||
});
|
||||
|
||||
it('defaults to npm', () => {
|
||||
expect(
|
||||
detectPackageManager({
|
||||
env: {},
|
||||
execPath: '/usr/local/lib/node_modules/@bitsquare/nopy/dist/nopy.cli.js',
|
||||
})
|
||||
).toBe('npm');
|
||||
});
|
||||
|
||||
it('handles a windows-style path and an empty path', () => {
|
||||
expect(
|
||||
detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\nopy.exe' })
|
||||
).toBe('pnpm');
|
||||
expect(detectPackageManager({ env: {}, execPath: '' })).toBe('npm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSelfUpdateCommand', () => {
|
||||
it('builds an npm global install without a registry flag for npmjs', () => {
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: 'npm',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
});
|
||||
expect(formatCommand(command)).toBe('npm install --global @bitsquare/nopy@latest');
|
||||
});
|
||||
|
||||
it('adds a scoped registry override for a non-npmjs registry', () => {
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: 'npm',
|
||||
channel: 'main',
|
||||
registry: GITEA,
|
||||
});
|
||||
// Scoped, not `--registry`: Gitea does not proxy npmjs, so the transitive
|
||||
// dependencies have to keep resolving from npmjs.
|
||||
expect(formatCommand(command)).toBe(
|
||||
`npm install --global @bitsquare/nopy@main --@bitsquare:registry=${GITEA}`
|
||||
);
|
||||
expect(command.args).not.toContain('--registry');
|
||||
});
|
||||
|
||||
it('normalises a registry given without a trailing slash', () => {
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: 'npm',
|
||||
channel: 'latest',
|
||||
registry: 'https://registry.npmjs.org',
|
||||
});
|
||||
expect(command.args).toEqual(['install', '--global', '@bitsquare/nopy@latest']);
|
||||
});
|
||||
|
||||
it('builds for pnpm, yarn and bun', () => {
|
||||
expect(
|
||||
formatCommand(
|
||||
buildSelfUpdateCommand({
|
||||
packageManager: 'pnpm',
|
||||
channel: 'next',
|
||||
registry: NPMJS_REGISTRY,
|
||||
})
|
||||
)
|
||||
).toBe('pnpm add --global @bitsquare/nopy@next');
|
||||
expect(
|
||||
formatCommand(
|
||||
buildSelfUpdateCommand({
|
||||
packageManager: 'yarn',
|
||||
channel: 'next',
|
||||
registry: NPMJS_REGISTRY,
|
||||
})
|
||||
)
|
||||
).toBe('yarn global add @bitsquare/nopy@next');
|
||||
expect(
|
||||
formatCommand(
|
||||
buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY })
|
||||
)
|
||||
).toBe('bun add --global @bitsquare/nopy@next');
|
||||
});
|
||||
|
||||
it('accepts an explicit package name', () => {
|
||||
const command = buildSelfUpdateCommand({
|
||||
packageManager: 'npm',
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
packageName: '@bitsquare/keyman',
|
||||
});
|
||||
expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUpdateNotice', () => {
|
||||
const status = {
|
||||
current: '0.5.0',
|
||||
latest: '0.6.0',
|
||||
channel: 'latest' as Channel,
|
||||
registry: NPMJS_REGISTRY,
|
||||
updateAvailable: true,
|
||||
fromCache: false,
|
||||
};
|
||||
|
||||
it('names both versions and the command', () => {
|
||||
const notice = formatUpdateNotice(status, 'npm');
|
||||
expect(notice).toContain('0.5.0 -> 0.6.0');
|
||||
expect(notice).toContain('nopy self-update');
|
||||
expect(notice).toContain('npm install --global @bitsquare/nopy@latest');
|
||||
});
|
||||
|
||||
it('names a non-default channel', () => {
|
||||
expect(formatUpdateNotice({ ...status, channel: 'main' }, 'npm')).toContain('(main)');
|
||||
});
|
||||
|
||||
it('says nothing when there is no update', () => {
|
||||
expect(formatUpdateNotice({ ...status, updateAvailable: false }, 'npm')).toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing when the latest version is unknown', () => {
|
||||
expect(formatUpdateNotice({ ...status, latest: null }, 'npm')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects the package manager when none is given', () => {
|
||||
expect(formatUpdateNotice(status)).toContain('@bitsquare/nopy@latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNotice', () => {
|
||||
it('returns a notice when an update exists', async () => {
|
||||
const notice = await updateNotice({
|
||||
currentVersion: '0.5.0',
|
||||
env: { NOPY_REGISTRY: NPMJS_REGISTRY },
|
||||
cachePath,
|
||||
now: Date.parse('2026-07-29T12:00:00.000Z'),
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
});
|
||||
expect(notice).toContain('0.5.0 -> 0.6.0');
|
||||
});
|
||||
|
||||
it('returns null when the check is disabled', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
const notice = await updateNotice({
|
||||
currentVersion: '0.5.0',
|
||||
env: { NOPY_NO_UPDATE_CHECK: '1' },
|
||||
cachePath,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
expect(notice).toBeNull();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null rather than throwing when everything fails', async () => {
|
||||
const notice = await updateNotice({
|
||||
currentVersion: '0.5.0',
|
||||
env: {},
|
||||
cachePath,
|
||||
run: async () => {
|
||||
throw new Error('no npm');
|
||||
},
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('offline');
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
expect(notice).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selfUpdate', () => {
|
||||
const base = {
|
||||
currentVersion: '0.5.0',
|
||||
env: { NOPY_REGISTRY: NPMJS_REGISTRY } as NodeJS.ProcessEnv,
|
||||
packageManager: 'npm' as const,
|
||||
};
|
||||
|
||||
it('runs the install when a newer version exists', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
spawn,
|
||||
});
|
||||
expect(result.ran).toBe(true);
|
||||
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--global', '@bitsquare/nopy@latest']);
|
||||
});
|
||||
|
||||
it('does nothing when already up to date', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||
spawn,
|
||||
});
|
||||
expect(result.ran).toBe(false);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reinstalls when forced', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
force: true,
|
||||
fetchImpl: fakeFetch({ latest: '0.5.0' }),
|
||||
spawn,
|
||||
});
|
||||
expect(result.ran).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the command without running it on a dry run', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
dryRun: true,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
spawn,
|
||||
});
|
||||
expect(result.ran).toBe(false);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(formatCommand(result.command)).toBe('npm install --global @bitsquare/nopy@latest');
|
||||
});
|
||||
|
||||
it('ignores a fresh cache, because the user asked', async () => {
|
||||
writeUpdateCache(
|
||||
{
|
||||
checkedAt: new Date().toISOString(),
|
||||
channel: 'latest',
|
||||
registry: NPMJS_REGISTRY,
|
||||
latest: '0.5.0',
|
||||
},
|
||||
cachePath
|
||||
);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ latest: '0.6.0' }),
|
||||
spawn: async () => undefined,
|
||||
});
|
||||
expect(result.status.latest).toBe('0.6.0');
|
||||
expect(result.ran).toBe(true);
|
||||
});
|
||||
|
||||
it('follows an explicit channel and registry', async () => {
|
||||
const result = await selfUpdate({
|
||||
currentVersion: '0.5.0',
|
||||
env: {},
|
||||
packageManager: 'pnpm',
|
||||
channel: 'main',
|
||||
registry: GITEA,
|
||||
cachePath,
|
||||
fetchImpl: fakeFetch({ main: '0.5.0-main.20.gaaaaaaa' }),
|
||||
spawn: async () => undefined,
|
||||
});
|
||||
expect(formatCommand(result.command)).toBe(
|
||||
`pnpm add --global @bitsquare/nopy@main --@bitsquare:registry=${GITEA}`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run when the registry could not be reached', async () => {
|
||||
const spawn = vi.fn(async () => undefined);
|
||||
const result = await selfUpdate({
|
||||
...base,
|
||||
cachePath,
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('offline');
|
||||
}) as unknown as typeof fetch,
|
||||
spawn,
|
||||
});
|
||||
expect(result.status.latest).toBeNull();
|
||||
expect(result.ran).toBe(false);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Generated
+17
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Reports what each publishable package looks like on Gitea versus npmjs.
|
||||
*
|
||||
* The two registries are deliberately not equivalent: `publish-snapshot.yml`
|
||||
* pushes a `main` snapshot to Gitea on every push to `main`, while `release.yml`
|
||||
* publishes a tagged version to both. Gitea is therefore a superset, and the
|
||||
* interesting question before a release is which versions exist *only* there —
|
||||
* those are the ones that can still be tested and un-published.
|
||||
*
|
||||
* It also flags a missing `latest` dist-tag, which is worth a line of output
|
||||
* because npm reports it by printing nothing and exiting 0. `npm view <name>`
|
||||
* against a registry with no `latest` looks identical to a working lookup of an
|
||||
* empty package, which is how the whole 1.0.0-alphaN dist-tag problem stayed
|
||||
* invisible for as long as it did.
|
||||
*
|
||||
* node scripts/registry-status.mjs
|
||||
* node scripts/registry-status.mjs --json
|
||||
* node scripts/registry-status.mjs --registry http://localhost:4873/
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const PACKAGES_DIR = 'packages';
|
||||
const SCOPE = '@bitsquare';
|
||||
const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
|
||||
const FALLBACK_GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const asJson = argv.includes('--json');
|
||||
const flag = (name, fallback) => {
|
||||
const at = argv.indexOf(name);
|
||||
return at !== -1 && argv[at + 1] ? argv[at + 1] : fallback;
|
||||
};
|
||||
|
||||
const withSlash = (url) => (url.endsWith('/') ? url : `${url}/`);
|
||||
|
||||
/**
|
||||
* The scope mapping in the repo's own `.npmrc` is the single source of truth,
|
||||
* so this never drifts from what an actual install would do.
|
||||
*/
|
||||
function resolveGiteaRegistry() {
|
||||
try {
|
||||
const out = execFileSync('npm', ['config', 'get', `${SCOPE}:registry`], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10_000,
|
||||
}).trim();
|
||||
// npm prints the literal string "undefined" for an unset key.
|
||||
if (out && out !== 'undefined' && out !== 'null') return withSlash(out);
|
||||
} catch {
|
||||
// npm missing or unreadable config — the fallback is still correct.
|
||||
}
|
||||
return FALLBACK_GITEA;
|
||||
}
|
||||
|
||||
const GITEA_REGISTRY = withSlash(flag('--registry', resolveGiteaRegistry()));
|
||||
|
||||
/** Fetches a packument, normalising every failure into a shape the report can print. */
|
||||
async function packument(registry, name) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${registry}${encodeURIComponent(name)}`, {
|
||||
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
return { reachable: false, note: error.name === 'TimeoutError' ? 'timed out' : 'unreachable' };
|
||||
}
|
||||
|
||||
if (response.status === 404) return { reachable: true, published: false };
|
||||
if (!response.ok) return { reachable: true, note: `HTTP ${response.status}` };
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
return { reachable: true, note: 'unparseable response' };
|
||||
}
|
||||
|
||||
// Gitea answers a missing package with 200 + {"error": "Not found"} rather
|
||||
// than a 404, so the body has to be checked as well as the status.
|
||||
if (body.error) return { reachable: true, published: false };
|
||||
|
||||
return {
|
||||
reachable: true,
|
||||
published: true,
|
||||
tags: body['dist-tags'] ?? {},
|
||||
versions: Object.keys(body.versions ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
const packages = fs
|
||||
.readdirSync(PACKAGES_DIR)
|
||||
.map((name) => path.join(PACKAGES_DIR, name))
|
||||
.filter((dir) => fs.existsSync(path.join(dir, 'package.json')))
|
||||
.map((dir) => ({ dir, manifest: JSON.parse(fs.readFileSync(path.join(dir, 'package.json'))) }))
|
||||
.filter(({ manifest }) => !manifest.private)
|
||||
.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
|
||||
|
||||
const report = await Promise.all(
|
||||
packages.map(async ({ dir, manifest }) => {
|
||||
const [gitea, npmjs] = await Promise.all([
|
||||
packument(GITEA_REGISTRY, manifest.name),
|
||||
packument(NPMJS_REGISTRY, manifest.name),
|
||||
]);
|
||||
|
||||
const npmjsVersions = new Set(npmjs.versions ?? []);
|
||||
const giteaOnly = (gitea.versions ?? []).filter((v) => !npmjsVersions.has(v));
|
||||
|
||||
return {
|
||||
name: manifest.name,
|
||||
dir,
|
||||
local: manifest.version,
|
||||
gitea,
|
||||
npmjs,
|
||||
giteaOnly,
|
||||
localPublished: {
|
||||
gitea: (gitea.versions ?? []).includes(manifest.version),
|
||||
npmjs: npmjsVersions.has(manifest.version),
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (asJson) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ registries: { gitea: GITEA_REGISTRY, npmjs: NPMJS_REGISTRY }, report },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const describe = (result) => {
|
||||
if (!result.reachable) return `(${result.note})`;
|
||||
if (result.note) return `(${result.note})`;
|
||||
if (!result.published) return '(not published)';
|
||||
const tags = Object.entries(result.tags)
|
||||
.map(([tag, version]) => `${tag}=${version}`)
|
||||
.sort()
|
||||
.join(', ');
|
||||
const count = `${result.versions.length} version${result.versions.length === 1 ? '' : 's'}`;
|
||||
return `${count}${tags ? ` — ${tags}` : ' — no dist-tags'}`;
|
||||
};
|
||||
|
||||
console.log('Registry status\n');
|
||||
console.log(` gitea ${GITEA_REGISTRY}`);
|
||||
console.log(` npmjs ${NPMJS_REGISTRY}`);
|
||||
|
||||
for (const entry of report) {
|
||||
console.log(`\n${entry.name} (local ${entry.local})`);
|
||||
console.log(` gitea ${describe(entry.gitea)}`);
|
||||
console.log(` npmjs ${describe(entry.npmjs)}`);
|
||||
|
||||
if (entry.giteaOnly.length > 0) {
|
||||
console.log(` gitea only ${entry.giteaOnly.join(', ')}`);
|
||||
}
|
||||
|
||||
const notes = [];
|
||||
if (entry.gitea.published && !entry.gitea.tags.latest) {
|
||||
notes.push('no `latest` on gitea — an untagged install resolves to nothing, silently');
|
||||
}
|
||||
if (!entry.localPublished.gitea && !entry.localPublished.npmjs) {
|
||||
notes.push(`local ${entry.local} is on neither registry`);
|
||||
} else if (entry.localPublished.gitea && !entry.localPublished.npmjs) {
|
||||
notes.push(`local ${entry.local} is testable on gitea, not yet released to npmjs`);
|
||||
}
|
||||
for (const note of notes) console.log(` ! ${note}`);
|
||||
}
|
||||
|
||||
const testable = report.filter((entry) => entry.giteaOnly.length > 0);
|
||||
console.log(
|
||||
testable.length > 0
|
||||
? `\n${testable.length} of ${report.length} packages have versions on gitea that npmjs does not.` +
|
||||
'\nInstall one with an explicit tag, e.g. `npm i -g @bitsquare/nopy@main` — see README.PUBLISH.md.'
|
||||
: '\nEvery version on gitea is also on npmjs.'
|
||||
);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user