[feat] nopy update command and auto-update pipeline
This commit is contained in:
+82
-21
@@ -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
|
||||
|
||||
|
||||
+872
-398
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -66,6 +66,35 @@ export {
|
||||
export type { AuthSession, CubeSession, NopySession } from './nopy.session.js';
|
||||
// Session management
|
||||
export { createSession, listSessions, loadSession, saveSession } from './nopy.session.js';
|
||||
export type {
|
||||
Channel,
|
||||
CommandRunner,
|
||||
PackageManager,
|
||||
SelfUpdateResult,
|
||||
UpdateCache,
|
||||
UpdateStatus,
|
||||
} from './nopy.update.js';
|
||||
// Update checking and self-update
|
||||
export {
|
||||
buildSelfUpdateCommand,
|
||||
channelForVersion,
|
||||
checkForUpdate,
|
||||
DEFAULT_CHECK_INTERVAL_MS,
|
||||
detectPackageManager,
|
||||
fetchChannelVersion,
|
||||
formatCommand,
|
||||
formatUpdateNotice,
|
||||
getUpdateCachePath,
|
||||
isUpdateCheckDisabled,
|
||||
NPMJS_REGISTRY,
|
||||
normalizeRegistry,
|
||||
PACKAGE_NAME,
|
||||
readUpdateCache,
|
||||
resolveRegistry,
|
||||
selfUpdate,
|
||||
updateNotice,
|
||||
writeUpdateCache,
|
||||
} from './nopy.update.js';
|
||||
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
|
||||
// Workflow
|
||||
export {
|
||||
|
||||
@@ -16,9 +16,22 @@ import {
|
||||
listHistory,
|
||||
} from './nopy.history.js';
|
||||
import { nopy } from './nopy.main.js';
|
||||
import type { Channel } from './nopy.update.js';
|
||||
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
|
||||
|
||||
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
|
||||
|
||||
/**
|
||||
* Prints the update hint to stderr, so it never lands in `--json` output or in
|
||||
* a `--print-only` command list being piped somewhere.
|
||||
*/
|
||||
async function printUpdateNotice(): Promise<void> {
|
||||
const notice = await updateNotice({ currentVersion: version });
|
||||
if (notice) {
|
||||
console.error(`\n${notice}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
@@ -63,6 +76,8 @@ program
|
||||
.option('-j, --json', 'Output results as JSON')
|
||||
.option('--no-history', 'Do not save this session to history')
|
||||
.action(async (options) => {
|
||||
await printUpdateNotice();
|
||||
|
||||
// Loaded lazily so that --help/--version work outside a configured project.
|
||||
const execConfig = loadConfig().execution ?? {};
|
||||
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
|
||||
@@ -150,4 +165,45 @@ program
|
||||
console.log('Session history cleared.');
|
||||
});
|
||||
|
||||
program
|
||||
.command('self-update')
|
||||
.description('Update nopy to the newest version on your channel')
|
||||
.alias('upgrade')
|
||||
.option('-n, --dry-run', 'Show the install command without running it')
|
||||
.option('-f, --force', 'Reinstall even when already up to date')
|
||||
.option('--channel <tag>', 'Check a specific channel (latest, next, main)')
|
||||
.option('--registry <url>', 'Install from a specific registry')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await selfUpdate({
|
||||
currentVersion: version,
|
||||
channel: options.channel as Channel | undefined,
|
||||
registry: options.registry,
|
||||
dryRun: options.dryRun,
|
||||
force: options.force,
|
||||
});
|
||||
|
||||
const { status } = result;
|
||||
console.log(`Installed: ${status.current}`);
|
||||
console.log(`Channel: ${status.channel}`);
|
||||
console.log(`Registry: ${status.registry}`);
|
||||
console.log(`Available: ${status.latest ?? 'unknown'}`);
|
||||
console.log('');
|
||||
|
||||
if (result.ran) {
|
||||
console.log(`Updated to ${status.latest}.`);
|
||||
} else if (options.dryRun) {
|
||||
console.log(`Would run: ${formatCommand(result.command)}`);
|
||||
} else if (status.latest === null) {
|
||||
console.error(`Could not reach ${status.registry} — nothing was changed.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('Already up to date.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user