[feat] nopy update command and auto-update pipeline

This commit is contained in:
Benjamin Diedrichsen
2026-07-29 11:16:52 +02:00
parent 6ecb2c366f
commit ea08e76a2f
25 changed files with 4659 additions and 453 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@bitsquare/cubes-core",
"version": "1.0.0-alpha0",
"version": "0.5.0",
"description": "The core nopy cube bundle: apt, users, ssh, networking, services and runtimes.",
"keywords": [
"nopy",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@bitsquare/keyman",
"version": "1.0.0",
"version": "0.5.0",
"description": "A system to simplify ssh key management",
"keywords": [
"ssh",
@@ -55,10 +55,12 @@
"dependencies": {
"execa": "^10.0.0",
"inquirer": "^14.0.2",
"semver": "^7.8.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/semver": "^7.7.1",
"@vitest/coverage-v8": "^4.1.10",
"tsx": "^4.23.1",
"typescript": "^7.0.2",
+28
View File
@@ -1,3 +1,31 @@
#!/usr/bin/env node
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
export * from './keyman.main.js';
export type {
Channel,
CommandRunner,
PackageManager,
SelfUpdateResult,
UpdateCache,
UpdateStatus,
} from './keyman.update.js';
export {
buildSelfUpdateCommand,
channelForVersion,
checkForUpdate,
DEFAULT_CHECK_INTERVAL_MS,
detectPackageManager,
fetchChannelVersion,
formatCommand,
formatUpdateNotice,
getUpdateCachePath,
isUpdateCheckDisabled,
NPMJS_REGISTRY,
normalizeRegistry,
PACKAGE_NAME,
readUpdateCache,
resolveRegistry,
selfUpdate,
updateNotice,
writeUpdateCache,
} from './keyman.update.js';
+57
View File
@@ -1,10 +1,21 @@
#!/usr/bin/env node
import { createRequire } from 'node:module';
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { keyman } from './keyman.main.js';
import type { Channel } from './keyman.update.js';
import { formatCommand, selfUpdate, updateNotice } from './keyman.update.js';
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
const args = process.argv.slice(2);
/** Reads `--flag value` out of argv, or undefined when the flag is absent */
function flagValue(name: string): string | undefined {
const index = args.indexOf(name);
return index === -1 ? undefined : args[index + 1];
}
if (args.includes('--print-config')) {
const config = loadConfig();
const paths = resolveConfigPaths(config);
@@ -12,4 +23,50 @@ if (args.includes('--print-config')) {
process.exit(0);
}
if (args.includes('--version') || args.includes('-V')) {
console.log(version);
process.exit(0);
}
if (args[0] === 'self-update' || args[0] === 'upgrade' || args.includes('--self-update')) {
const dryRun = args.includes('--dry-run') || args.includes('-n');
try {
const result = await selfUpdate({
currentVersion: version,
channel: flagValue('--channel') as Channel | undefined,
registry: flagValue('--registry'),
dryRun,
force: args.includes('--force') || args.includes('-f'),
});
const { status } = result;
console.log(`Installed: ${status.current}`);
console.log(`Channel: ${status.channel}`);
console.log(`Registry: ${status.registry}`);
console.log(`Available: ${status.latest ?? 'unknown'}`);
console.log('');
if (result.ran) {
console.log(`Updated to ${status.latest}.`);
} else if (dryRun) {
console.log(`Would run: ${formatCommand(result.command)}`);
} else if (status.latest === null) {
console.error(`Could not reach ${status.registry} — nothing was changed.`);
process.exit(1);
} else {
console.log('Already up to date.');
}
} catch (error) {
console.error('Update failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
process.exit(0);
}
// Printed to stderr so it never mixes into machine-read output.
const notice = await updateNotice({ currentVersion: version });
if (notice) {
console.error(`\n${notice}\n`);
}
keyman();
+472
View File
@@ -0,0 +1,472 @@
/**
* Update checking and self-update for the keyman CLI
*
* A near-copy of nopy's `nopy.update` module, differing only in the package it
* names and the environment variables it reads. The two CLIs share no internal
* library — keyman deliberately stands alone — and a fifth workspace package
* for ~250 lines would buy another edge in the publish order for nothing. If a
* third CLI ever appears, extract it then.
*
* @module keyman.update
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execa } from 'execa';
import semver from 'semver';
/** The published package this CLI ships as */
export const PACKAGE_NAME = '@bitsquare/keyman';
/** The npm scope the package lives under, used for the registry config key */
export const SCOPE = '@bitsquare';
/** Where packages resolve from when nothing says otherwise */
export const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
/** Directory under the user's home holding the update-check cache */
export const UPDATE_CACHE_DIR = '.keyman';
/** File name of the update-check cache */
export const UPDATE_CACHE_FILE = 'update-check.json';
/** How long a cached check is considered fresh */
export const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** How long the background check may block the CLI */
export const DEFAULT_FETCH_TIMEOUT_MS = 1500;
/** How long `npm config get` may take before the registry falls back to npmjs */
export const DEFAULT_CONFIG_TIMEOUT_MS = 5000;
/**
* A dist-tag this project publishes under.
*
* `latest` is a release, `next` a prerelease (`0.6.0-rc.1`), `main` a snapshot
* built from a commit on `main` and published to Gitea only.
*/
export type Channel = 'latest' | 'next' | 'main';
/** A package manager that can install a global binary */
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
/** Runs a command and resolves with its trimmed stdout */
export type CommandRunner = (file: string, args: string[]) => Promise<string>;
/** The result of an update check */
export interface UpdateStatus {
/** The version currently running */
current: string;
/** The version the channel points at, or null if it could not be determined */
latest: string | null;
/** The channel the current version implies */
channel: Channel;
/** The registry the check went to */
registry: string;
/** Whether `latest` is strictly newer than `current` */
updateAvailable: boolean;
/** Whether the answer came from cache rather than the network */
fromCache: boolean;
}
/** The on-disk update-check cache */
export interface UpdateCache {
/** ISO timestamp of the check */
checkedAt: string;
/** The channel that was checked */
channel: Channel;
/** The registry that was checked */
registry: string;
/** The version the channel pointed at, or null if the lookup found nothing */
latest: string | null;
}
/**
* Derives the release channel from a version string.
*
* @param version - a semver version, typically this package's own
* @returns the dist-tag that version would have been published under
*/
export function channelForVersion(version: string): Channel {
const parsed = semver.parse(version, { loose: true });
if (!parsed || parsed.prerelease.length === 0) {
return 'latest';
}
return parsed.prerelease.some((part) => part === 'main') ? 'main' : 'next';
}
/** Normalises a registry URL to the trailing-slash form the packument path is appended to */
export function normalizeRegistry(url: string): string {
const trimmed = url.trim();
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
}
/** Runs a command through execa and returns its stdout */
const defaultRunner: CommandRunner = async (file, args) => {
const { stdout } = await execa(file, args, { timeout: DEFAULT_CONFIG_TIMEOUT_MS });
return stdout;
};
/**
* Resolves the registry `@bitsquare` packages come from.
*
* `KEYMAN_REGISTRY` wins, then npm's own scoped-registry config, then npmjs.
*/
export async function resolveRegistry(
options: { env?: NodeJS.ProcessEnv; run?: CommandRunner } = {}
): Promise<string> {
const env = options.env ?? process.env;
const override = env.KEYMAN_REGISTRY?.trim();
if (override) {
return normalizeRegistry(override);
}
const run = options.run ?? defaultRunner;
try {
const stdout = (await run('npm', ['config', 'get', `${SCOPE}:registry`])).trim();
// npm prints the string "undefined" for an unset key rather than nothing.
if (stdout && stdout !== 'undefined' && stdout !== 'null') {
return normalizeRegistry(stdout);
}
} catch {
// npm not on PATH, or the config is unreadable.
}
return NPMJS_REGISTRY;
}
/**
* Reads the version a dist-tag points at, straight from the registry.
*
* @returns the version, or null if the registry or the tag has nothing
*/
export async function fetchChannelVersion(options: {
registry: string;
channel: Channel;
packageName?: string;
timeoutMs?: number;
token?: string;
fetchImpl?: typeof fetch;
}): Promise<string | null> {
const doFetch = options.fetchImpl ?? globalThis.fetch;
const packageName = options.packageName ?? PACKAGE_NAME;
const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`;
const headers: Record<string, string> = {
accept: 'application/vnd.npm.install-v1+json, application/json',
};
if (options.token) {
headers.authorization = `Bearer ${options.token}`;
}
const response = await doFetch(url, {
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as { 'dist-tags'?: Record<string, string> };
return body['dist-tags']?.[options.channel] ?? null;
}
/** Path of the update-check cache file */
export function getUpdateCachePath(homedir: string = os.homedir()): string {
return path.join(homedir, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE);
}
/**
* Reads the update-check cache.
*
* @returns the cache, or null if it is missing or unreadable
*/
export function readUpdateCache(cachePath: string = getUpdateCachePath()): UpdateCache | null {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as UpdateCache;
return typeof parsed?.checkedAt === 'string' ? parsed : null;
} catch {
return null;
}
}
/** Writes the update-check cache. Best effort — a read-only home costs a check, not a failure */
export function writeUpdateCache(
cache: UpdateCache,
cachePath: string = getUpdateCachePath()
): void {
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
} catch {
// Ignored on purpose.
}
}
/** Whether the startup check should be skipped entirely */
export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
const flag = env.KEYMAN_NO_UPDATE_CHECK?.trim().toLowerCase();
if (flag && flag !== '0' && flag !== 'false') {
return true;
}
return Boolean(env.CI);
}
/**
* Checks whether a newer version exists on the current channel.
*
* Answers from cache when a check happened recently for the same channel and
* registry; a failed lookup degrades to the cached answer rather than none.
*/
export async function checkForUpdate(options: {
currentVersion: string;
channel?: Channel;
registry?: string;
force?: boolean;
intervalMs?: number;
cachePath?: string;
now?: number;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
fetchImpl?: typeof fetch;
run?: CommandRunner;
}): Promise<UpdateStatus> {
const {
currentVersion,
force = false,
intervalMs = DEFAULT_CHECK_INTERVAL_MS,
cachePath = getUpdateCachePath(),
now = Date.now(),
env = process.env,
} = options;
const channel = options.channel ?? channelForVersion(currentVersion);
const registry = normalizeRegistry(
options.registry ?? (await resolveRegistry({ env, run: options.run }))
);
const cache = readUpdateCache(cachePath);
const applicable = cache && cache.channel === channel && cache.registry === registry;
const age = cache ? now - Date.parse(cache.checkedAt) : Number.POSITIVE_INFINITY;
const fresh = applicable && Number.isFinite(age) && age >= 0 && age < intervalMs;
if (!force && fresh && cache) {
return status(currentVersion, cache.latest, channel, registry, true);
}
try {
const latest = await fetchChannelVersion({
registry,
channel,
timeoutMs: options.timeoutMs,
token: env.KEYMAN_REGISTRY_TOKEN?.trim() || undefined,
fetchImpl: options.fetchImpl,
});
writeUpdateCache(
{ checkedAt: new Date(now).toISOString(), channel, registry, latest },
cachePath
);
return status(currentVersion, latest, channel, registry, false);
} catch {
return status(
currentVersion,
applicable && cache ? cache.latest : null,
channel,
registry,
true
);
}
}
/** Assembles an {@link UpdateStatus}, deciding whether the remote version wins */
function status(
current: string,
latest: string | null,
channel: Channel,
registry: string,
fromCache: boolean
): UpdateStatus {
const updateAvailable = Boolean(
latest && semver.valid(latest) && semver.valid(current) && semver.gt(latest, current)
);
return { current, latest, channel, registry, updateAvailable, fromCache };
}
/**
* Detects which package manager installed this CLI, so `self-update` re-runs
* the same one rather than leaving two copies on the PATH.
*/
export function detectPackageManager(
options: { execPath?: string; env?: NodeJS.ProcessEnv } = {}
): PackageManager {
const env = options.env ?? process.env;
const override = env.KEYMAN_PACKAGE_MANAGER?.trim().toLowerCase();
if (override === 'npm' || override === 'pnpm' || override === 'yarn' || override === 'bun') {
return override;
}
const from = (options.execPath ?? process.argv[1] ?? '').replace(/\\/g, '/').toLowerCase();
if (from.includes('/pnpm/')) return 'pnpm';
if (from.includes('/.bun/')) return 'bun';
if (from.includes('/.yarn/') || from.includes('/yarn/')) return 'yarn';
return 'npm';
}
/**
* Builds the command that installs a given channel globally.
*
* The registry is passed as a **scoped** override rather than `--registry`,
* because the Gitea registry serves `@bitsquare` packages and does not proxy
* npmjs — a global `--registry` would send every dependency to a registry that
* has never heard of them.
*/
export function buildSelfUpdateCommand(options: {
packageManager: PackageManager;
channel: Channel;
registry: string;
packageName?: string;
}): { file: string; args: string[] } {
const packageName = options.packageName ?? PACKAGE_NAME;
const spec = `${packageName}@${options.channel}`;
const registryArgs =
normalizeRegistry(options.registry) === NPMJS_REGISTRY
? []
: [`--${SCOPE}:registry=${normalizeRegistry(options.registry)}`];
switch (options.packageManager) {
case 'pnpm':
return { file: 'pnpm', args: ['add', '--global', spec, ...registryArgs] };
case 'yarn':
return { file: 'yarn', args: ['global', 'add', spec, ...registryArgs] };
case 'bun':
return { file: 'bun', args: ['add', '--global', spec, ...registryArgs] };
default:
return { file: 'npm', args: ['install', '--global', spec, ...registryArgs] };
}
}
/** Renders a command as the shell line a user could paste */
export function formatCommand(command: { file: string; args: string[] }): string {
return [command.file, ...command.args].join(' ');
}
/**
* Renders the hint printed at startup when an update exists.
*
* @returns the notice, or null when there is nothing to say
*/
export function formatUpdateNotice(
status: UpdateStatus,
packageManager?: PackageManager
): string | null {
if (!status.updateAvailable || !status.latest) {
return null;
}
const command = buildSelfUpdateCommand({
packageManager: packageManager ?? detectPackageManager(),
channel: status.channel,
registry: status.registry,
});
const channelNote = status.channel === 'latest' ? '' : ` (${status.channel})`;
return [
`Update available: ${status.current} -> ${status.latest}${channelNote}`,
`Run "keyman self-update" or "${formatCommand(command)}"`,
].join('\n');
}
/**
* The startup path: returns the notice to print, or null.
*
* Never throws and never blocks for longer than the fetch timeout.
*/
export async function updateNotice(options: {
currentVersion: string;
env?: NodeJS.ProcessEnv;
cachePath?: string;
intervalMs?: number;
timeoutMs?: number;
now?: number;
fetchImpl?: typeof fetch;
run?: CommandRunner;
}): Promise<string | null> {
const env = options.env ?? process.env;
if (isUpdateCheckDisabled(env)) {
return null;
}
try {
const status = await checkForUpdate({ ...options, env });
return formatUpdateNotice(status, detectPackageManager({ env }));
} catch {
return null;
}
}
/** Outcome of a {@link selfUpdate} run */
export interface SelfUpdateResult {
/** The status the decision was based on */
status: UpdateStatus;
/** The command that was run, or would have been run */
command: { file: string; args: string[] };
/** Whether the install actually ran */
ran: boolean;
}
/**
* Installs the newest version on the current channel.
*
* @param options.dryRun - print the command instead of running it
* @param options.force - reinstall even when already up to date
*/
export async function selfUpdate(options: {
currentVersion: string;
channel?: Channel;
registry?: string;
packageManager?: PackageManager;
dryRun?: boolean;
force?: boolean;
env?: NodeJS.ProcessEnv;
cachePath?: string;
fetchImpl?: typeof fetch;
run?: CommandRunner;
spawn?: (file: string, args: string[]) => Promise<unknown>;
}): Promise<SelfUpdateResult> {
const env = options.env ?? process.env;
// Always ignore the cache here: the user asked, so the answer has to be current.
const status = await checkForUpdate({
currentVersion: options.currentVersion,
channel: options.channel,
registry: options.registry,
force: true,
cachePath: options.cachePath,
env,
fetchImpl: options.fetchImpl,
run: options.run,
});
const command = buildSelfUpdateCommand({
packageManager: options.packageManager ?? detectPackageManager({ env }),
channel: status.channel,
registry: status.registry,
});
if (options.dryRun || (!status.updateAvailable && !options.force)) {
return { status, command, ran: false };
}
const spawn =
options.spawn ?? ((file: string, args: string[]) => execa(file, args, { stdio: 'inherit' }));
await spawn(command.file, command.args);
return { status, command, ran: true };
}
+868
View File
@@ -0,0 +1,868 @@
/**
* Tests for keyman.update module
*
* Every network call, clock read and spawn is injected, so nothing here
* reaches a registry or the user's home directory.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildSelfUpdateCommand,
type Channel,
channelForVersion,
checkForUpdate,
detectPackageManager,
fetchChannelVersion,
formatCommand,
formatUpdateNotice,
getUpdateCachePath,
isUpdateCheckDisabled,
NPMJS_REGISTRY,
normalizeRegistry,
readUpdateCache,
resolveRegistry,
selfUpdate,
type UpdateCache,
updateNotice,
writeUpdateCache,
} from '../src/keyman.update.js';
const GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
let tmpDir: string;
let cachePath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-update-'));
cachePath = path.join(tmpDir, 'update-check.json');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
/** A fetch stand-in returning the given dist-tags */
function fakeFetch(distTags: Record<string, string>, ok = true): typeof fetch {
return (async () =>
({
ok,
json: async () => ({ 'dist-tags': distTags }),
}) as Response) as unknown as typeof fetch;
}
describe('channelForVersion', () => {
it('maps a clean release to latest', () => {
expect(channelForVersion('0.5.0')).toBe('latest');
expect(channelForVersion('1.2.3')).toBe('latest');
});
it('maps a snapshot to main', () => {
expect(channelForVersion('0.5.0-main.14.g6ecb2c3')).toBe('main');
});
it('maps any other prerelease to next', () => {
expect(channelForVersion('0.6.0-rc.1')).toBe('next');
expect(channelForVersion('1.0.0-alpha5')).toBe('next');
});
it('treats an unparseable version as latest', () => {
expect(channelForVersion('not-a-version')).toBe('latest');
expect(channelForVersion('')).toBe('latest');
});
});
describe('normalizeRegistry', () => {
it('adds a trailing slash', () => {
expect(normalizeRegistry('https://example.com/npm')).toBe('https://example.com/npm/');
});
it('leaves an existing trailing slash alone', () => {
expect(normalizeRegistry(GITEA)).toBe(GITEA);
});
it('trims surrounding whitespace', () => {
expect(normalizeRegistry(' https://example.com/npm ')).toBe('https://example.com/npm/');
});
});
describe('resolveRegistry', () => {
it('prefers the KEYMAN_REGISTRY override', async () => {
const run = vi.fn();
const registry = await resolveRegistry({
env: { KEYMAN_REGISTRY: 'https://example.com/npm' },
run,
});
expect(registry).toBe('https://example.com/npm/');
expect(run).not.toHaveBeenCalled();
});
it('falls back to npm config', async () => {
const run = vi.fn(async () => GITEA);
expect(await resolveRegistry({ env: {}, run })).toBe(GITEA);
expect(run).toHaveBeenCalledWith('npm', ['config', 'get', '@bitsquare:registry']);
});
it('treats npm printing "undefined" as unset', async () => {
const run = vi.fn(async () => 'undefined');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('treats npm printing "null" as unset', async () => {
const run = vi.fn(async () => 'null');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('treats empty output as unset', async () => {
const run = vi.fn(async () => ' ');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('falls back to npmjs when npm is missing', async () => {
const run = vi.fn(async () => {
throw new Error('ENOENT');
});
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('ignores a blank override', async () => {
const run = vi.fn(async () => GITEA);
expect(await resolveRegistry({ env: { KEYMAN_REGISTRY: ' ' }, run })).toBe(GITEA);
});
});
describe('fetchChannelVersion', () => {
it('reads the requested dist-tag', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'main',
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234', latest: '0.5.0' }),
});
expect(version).toBe('0.5.0-main.14.gabc1234');
});
it('returns null when the tag does not exist', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'latest',
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234' }),
});
expect(version).toBeNull();
});
it('returns null on a non-ok response', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'latest',
fetchImpl: fakeFetch({}, false),
});
expect(version).toBeNull();
});
it('returns null when the packument has no dist-tags at all', async () => {
const fetchImpl = (async () =>
({ ok: true, json: async () => ({}) }) as Response) as unknown as typeof fetch;
expect(await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl })).toBeNull();
});
it('url-encodes the scoped package name onto the registry', async () => {
const seen: string[] = [];
const fetchImpl = (async (url: string) => {
seen.push(url);
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
// No trailing slash on purpose: it must be normalised before joining.
await fetchChannelVersion({
registry: 'https://example.com/npm',
channel: 'latest',
fetchImpl,
});
expect(seen[0]).toBe('https://example.com/npm/%40bitsquare%2Fkeyman');
});
it('sends a bearer token when one is given', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
await fetchChannelVersion({ registry: GITEA, channel: 'latest', token: 'secret', fetchImpl });
expect(headers.authorization).toBe('Bearer secret');
});
it('omits the authorization header when no token is given', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl });
expect(headers.authorization).toBeUndefined();
});
});
describe('the update cache', () => {
it('round-trips', () => {
const cache: UpdateCache = {
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.6.0',
};
writeUpdateCache(cache, cachePath);
expect(readUpdateCache(cachePath)).toEqual(cache);
});
it('creates the containing directory', () => {
const nested = path.join(tmpDir, 'a', 'b', 'update-check.json');
writeUpdateCache(
{
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: null,
},
nested
);
expect(fs.existsSync(nested)).toBe(true);
});
it('reads a missing file as null', () => {
expect(readUpdateCache(path.join(tmpDir, 'absent.json'))).toBeNull();
});
it('reads malformed JSON as null', () => {
fs.writeFileSync(cachePath, '{ not json', 'utf-8');
expect(readUpdateCache(cachePath)).toBeNull();
});
it('rejects a file without a checkedAt stamp', () => {
fs.writeFileSync(cachePath, JSON.stringify({ latest: '9.9.9' }), 'utf-8');
expect(readUpdateCache(cachePath)).toBeNull();
});
it('swallows a write it cannot perform', () => {
// A path whose parent is a file, not a directory.
const blocked = path.join(cachePath, 'nested.json');
fs.writeFileSync(cachePath, '{}', 'utf-8');
expect(() =>
writeUpdateCache(
{
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: null,
},
blocked
)
).not.toThrow();
});
it('defaults to a path under the home directory', () => {
expect(getUpdateCachePath('/home/someone')).toBe('/home/someone/.keyman/update-check.json');
});
});
describe('isUpdateCheckDisabled', () => {
it('is off by default', () => {
expect(isUpdateCheckDisabled({})).toBe(false);
});
it('honours KEYMAN_NO_UPDATE_CHECK', () => {
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '1' })).toBe(true);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'yes' })).toBe(true);
});
it('treats 0 and false as not disabled', () => {
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '0' })).toBe(false);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: 'false' })).toBe(false);
expect(isUpdateCheckDisabled({ KEYMAN_NO_UPDATE_CHECK: '' })).toBe(false);
});
it('disables itself in CI', () => {
expect(isUpdateCheckDisabled({ CI: 'true' })).toBe(true);
});
});
describe('checkForUpdate', () => {
const base = {
currentVersion: '0.5.0',
registry: NPMJS_REGISTRY,
env: {} as NodeJS.ProcessEnv,
now: Date.parse('2026-07-29T12:00:00.000Z'),
};
it('reports a newer version on the channel', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status).toMatchObject({
current: '0.5.0',
latest: '0.6.0',
channel: 'latest',
updateAvailable: true,
fromCache: false,
});
});
it('reports no update when the channel matches', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
});
expect(status.updateAvailable).toBe(false);
});
it('does not treat an older published version as an update', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.4.0' }),
});
expect(status.updateAvailable).toBe(false);
});
it('derives the channel from the running version', async () => {
const status = await checkForUpdate({
...base,
currentVersion: '0.5.0-main.13.gabc1234',
cachePath,
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gdef5678', latest: '0.5.0' }),
});
expect(status.channel).toBe('main');
expect(status.latest).toBe('0.5.0-main.14.gdef5678');
expect(status.updateAvailable).toBe(true);
});
it('writes what it found to the cache', async () => {
await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }) });
expect(readUpdateCache(cachePath)).toEqual({
checkedAt: '2026-07-29T12:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.6.0',
});
});
it('answers from a fresh cache without touching the network', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const fetchImpl = vi.fn();
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(status.latest).toBe('0.7.0');
expect(status.fromCache).toBe(true);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('refetches once the cache goes stale', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-27T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.8.0' }),
});
expect(status.latest).toBe('0.8.0');
expect(status.fromCache).toBe(false);
});
it('ignores a cache written for a different channel', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'next',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache written for a different registry', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: GITEA,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache stamped in the future', async () => {
writeUpdateCache(
{
checkedAt: '2027-01-01T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache with an unparseable stamp', async () => {
fs.writeFileSync(
cachePath,
JSON.stringify({
checkedAt: 'whenever',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
}),
'utf-8'
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('refetches when forced, even with a fresh cache', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
force: true,
fetchImpl: fakeFetch({ latest: '0.9.0' }),
});
expect(status.latest).toBe('0.9.0');
expect(status.fromCache).toBe(false);
});
it('falls back to the cached answer when the network fails', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-20T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const fetchImpl = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
expect(status.latest).toBe('0.7.0');
expect(status.updateAvailable).toBe(true);
expect(status.fromCache).toBe(true);
});
it('reports nothing when the network fails and no cache applies', async () => {
const fetchImpl = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
expect(status.latest).toBeNull();
expect(status.updateAvailable).toBe(false);
});
it('resolves the registry when none is given', async () => {
const status = await checkForUpdate({
currentVersion: '0.5.0',
cachePath,
env: {},
run: async () => GITEA,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.registry).toBe(GITEA);
});
it('passes a registry token from the environment through', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.6.0' } }) } as Response;
}) as unknown as typeof fetch;
await checkForUpdate({
...base,
cachePath,
env: { KEYMAN_REGISTRY_TOKEN: 'tok' },
fetchImpl,
});
expect(headers.authorization).toBe('Bearer tok');
});
it('does not compare against an unparseable current version', async () => {
const status = await checkForUpdate({
...base,
currentVersion: 'dev',
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.updateAvailable).toBe(false);
});
});
describe('detectPackageManager', () => {
it('honours the environment override', () => {
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' })
).toBe('pnpm');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' })
).toBe('yarn');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' })
).toBe('bun');
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' })
).toBe('npm');
});
it('ignores an unrecognised override', () => {
expect(
detectPackageManager({ env: { KEYMAN_PACKAGE_MANAGER: 'cargo' }, execPath: '/usr/lib/x' })
).toBe('npm');
});
it('recognises a pnpm global install', () => {
expect(
detectPackageManager({
env: {},
execPath: '/Users/x/Library/pnpm/global/5/node_modules/.bin/keyman',
})
).toBe('pnpm');
});
it('recognises a bun global install', () => {
expect(
detectPackageManager({
env: {},
execPath: '/Users/x/.bun/install/global/node_modules/keyman',
})
).toBe('bun');
});
it('recognises a yarn global install', () => {
expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/keyman' })).toBe('yarn');
});
it('defaults to npm', () => {
expect(
detectPackageManager({
env: {},
execPath: '/usr/local/lib/node_modules/@bitsquare/keyman/dist/keyman.cli.js',
})
).toBe('npm');
});
it('handles a windows-style path and an empty path', () => {
expect(
detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\keyman.exe' })
).toBe('pnpm');
expect(detectPackageManager({ env: {}, execPath: '' })).toBe('npm');
});
});
describe('buildSelfUpdateCommand', () => {
it('builds an npm global install without a registry flag for npmjs', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@latest');
});
it('adds a scoped registry override for a non-npmjs registry', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'main',
registry: GITEA,
});
// Scoped, not `--registry`: Gitea does not proxy npmjs, so the transitive
// dependencies have to keep resolving from npmjs.
expect(formatCommand(command)).toBe(
`npm install --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
);
expect(command.args).not.toContain('--registry');
});
it('normalises a registry given without a trailing slash', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: 'https://registry.npmjs.org',
});
expect(command.args).toEqual(['install', '--global', '@bitsquare/keyman@latest']);
});
it('builds for pnpm, yarn and bun', () => {
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'pnpm',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('pnpm add --global @bitsquare/keyman@next');
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'yarn',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('yarn global add @bitsquare/keyman@next');
expect(
formatCommand(
buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY })
)
).toBe('bun add --global @bitsquare/keyman@next');
});
it('accepts an explicit package name', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
packageName: '@bitsquare/nopy',
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/nopy@latest');
});
});
describe('formatUpdateNotice', () => {
const status = {
current: '0.5.0',
latest: '0.6.0',
channel: 'latest' as Channel,
registry: NPMJS_REGISTRY,
updateAvailable: true,
fromCache: false,
};
it('names both versions and the command', () => {
const notice = formatUpdateNotice(status, 'npm');
expect(notice).toContain('0.5.0 -> 0.6.0');
expect(notice).toContain('keyman self-update');
expect(notice).toContain('npm install --global @bitsquare/keyman@latest');
});
it('names a non-default channel', () => {
expect(formatUpdateNotice({ ...status, channel: 'main' }, 'npm')).toContain('(main)');
});
it('says nothing when there is no update', () => {
expect(formatUpdateNotice({ ...status, updateAvailable: false }, 'npm')).toBeNull();
});
it('says nothing when the latest version is unknown', () => {
expect(formatUpdateNotice({ ...status, latest: null }, 'npm')).toBeNull();
});
it('detects the package manager when none is given', () => {
expect(formatUpdateNotice(status)).toContain('@bitsquare/keyman@latest');
});
});
describe('updateNotice', () => {
it('returns a notice when an update exists', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY },
cachePath,
now: Date.parse('2026-07-29T12:00:00.000Z'),
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(notice).toContain('0.5.0 -> 0.6.0');
});
it('returns null when the check is disabled', async () => {
const fetchImpl = vi.fn();
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { KEYMAN_NO_UPDATE_CHECK: '1' },
cachePath,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(notice).toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('returns null rather than throwing when everything fails', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: {},
cachePath,
run: async () => {
throw new Error('no npm');
},
fetchImpl: (async () => {
throw new Error('offline');
}) as unknown as typeof fetch,
});
expect(notice).toBeNull();
});
});
describe('selfUpdate', () => {
const base = {
currentVersion: '0.5.0',
env: { KEYMAN_REGISTRY: NPMJS_REGISTRY } as NodeJS.ProcessEnv,
packageManager: 'npm' as const,
};
it('runs the install when a newer version exists', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn,
});
expect(result.ran).toBe(true);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--global', '@bitsquare/keyman@latest']);
});
it('does nothing when already up to date', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
spawn,
});
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
it('reinstalls when forced', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
force: true,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
spawn,
});
expect(result.ran).toBe(true);
});
it('reports the command without running it on a dry run', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
dryRun: true,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn,
});
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
expect(formatCommand(result.command)).toBe('npm install --global @bitsquare/keyman@latest');
});
it('ignores a fresh cache, because the user asked', async () => {
writeUpdateCache(
{
checkedAt: new Date().toISOString(),
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.5.0',
},
cachePath
);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn: async () => undefined,
});
expect(result.status.latest).toBe('0.6.0');
expect(result.ran).toBe(true);
});
it('follows an explicit channel and registry', async () => {
const result = await selfUpdate({
currentVersion: '0.5.0',
env: {},
packageManager: 'pnpm',
channel: 'main',
registry: GITEA,
cachePath,
fetchImpl: fakeFetch({ main: '0.5.0-main.20.gaaaaaaa' }),
spawn: async () => undefined,
});
expect(formatCommand(result.command)).toBe(
`pnpm add --global @bitsquare/keyman@main --@bitsquare:registry=${GITEA}`
);
});
it('does not run when the registry could not be reached', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: (async () => {
throw new Error('offline');
}) as unknown as typeof fetch,
spawn,
});
expect(result.status.latest).toBeNull();
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@bitsquare/nopy-cube",
"version": "1.0.0-alpha0",
"version": "0.5.0",
"description": "Authoring types for nopy cubes: the Manifest factory and the Cube contract.",
"keywords": [
"nopy",
+82 -21
View File
@@ -333,36 +333,97 @@ Writing cubes to publish is covered in [CUBE-BUNDLES.md](docs/CUBE-BUNDLES.md).
### Installation
This package is part of a yarn workspace monorepo. Install from the repository root:
```bash
# From repository root (/ansiblings)
yarn install
yarn workspace @bitsquare/nopy build
npm install -g @bitsquare/nopy
```
To use the `nopy` command globally, you can:
The cubes live in a separate bundle, installed into whichever project describes
your infrastructure and named in its `.nopyrc.json`:
1. **Use yarn workspace command**:
```bash
pnpm add -D @bitsquare/cubes-core
```
```bash
yarn workspace @bitsquare/nopy nopy
```
```json
{ "hosts": ["your-host"], "cubePackages": ["@bitsquare/cubes-core"] }
```
2. **Link the package globally**:
#### Channels
```bash
cd packages/nopy
npm link
# Now you can use 'nopy' from anywhere
nopy install
```
Three dist-tags are published, and the one you install from is the one you stay
on until you ask otherwise:
3. **Use via npm scripts** (from packages/nopy directory):
| Channel | What it is | Registry |
| -------- | ----------------------------------------- | ------------ |
| `latest` | the current release — the default | npmjs, Gitea |
| `next` | a prerelease (`0.6.0-rc.1`) | npmjs, Gitea |
| `main` | a snapshot of every commit on `main` | Gitea only |
```bash
yarn nopy
```
```bash
npm install -g @bitsquare/nopy # latest
npm install -g @bitsquare/nopy@next # prereleases
```
Snapshots come from the Gitea registry. Point the **scope** at it rather than
setting a bare `registry=`, because that registry serves `@bitsquare` packages
only and does not proxy npmjs — everything else must keep resolving from npmjs:
```bash
npm install -g @bitsquare/nopy@main \
--@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
```
Or, persistently, in `~/.npmrc`:
```ini
@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
```
Reading from Gitea needs no token while the repository is public.
### Upgrading
```bash
nopy self-update
```
That checks the channel your installed version came from, on the registry your
npm config points at, and re-runs the package manager that installed you (npm,
pnpm, yarn or bun — detected from the install path). Options:
```bash
nopy self-update --dry-run # print the command, change nothing
nopy self-update --force # reinstall even when up to date
nopy self-update --channel next # switch channel
nopy self-update --registry <url> # check somewhere else
```
The plain package-manager equivalent works too. Prefer `@latest` over
`npm update -g`, which resolves against the range recorded at install time:
```bash
npm install -g @bitsquare/nopy@latest
```
Once a day, `nopy` checks its channel in the background and prints a one-line
hint to **stderr** when a newer version exists — never to stdout, so `--json`
and `--print-only` output stay clean. The answer is cached in
`~/.nopy/update-check.json`; a registry that is slow or unreachable is given
1.5 seconds and then ignored.
| Variable | Effect |
| ----------------------- | --------------------------------------------- |
| `NOPY_NO_UPDATE_CHECK=1`| disable the startup check (also off when `CI` is set) |
| `NOPY_REGISTRY` | check a specific registry |
| `NOPY_REGISTRY_TOKEN` | bearer token, for a private registry |
| `NOPY_PACKAGE_MANAGER` | force `npm`/`pnpm`/`yarn`/`bun` for the install |
### Running from a checkout
```bash
pnpm install
pnpm --filter @bitsquare/nopy run nopy # runs the CLI from source via tsx
```
### Basic Commands
+872 -398
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@bitsquare/nopy",
"version": "1.0.0-alpha5",
"version": "0.5.0",
"description": "A system to simplify pyinfra script management and execution.",
"keywords": [
"pyinfra",
@@ -61,11 +61,13 @@
"execa": "^10.0.0",
"fuzzy": "^0.1.3",
"inquirer": "^14.0.2",
"semver": "^7.8.5",
"zod": "^4.4.3",
"zx": "^8.8.5"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/semver": "^7.7.1",
"@vitest/coverage-v8": "^4.1.10",
"tsx": "^4.23.1",
"typescript": "^7.0.2",
+29
View File
@@ -66,6 +66,35 @@ export {
export type { AuthSession, CubeSession, NopySession } from './nopy.session.js';
// Session management
export { createSession, listSessions, loadSession, saveSession } from './nopy.session.js';
export type {
Channel,
CommandRunner,
PackageManager,
SelfUpdateResult,
UpdateCache,
UpdateStatus,
} from './nopy.update.js';
// Update checking and self-update
export {
buildSelfUpdateCommand,
channelForVersion,
checkForUpdate,
DEFAULT_CHECK_INTERVAL_MS,
detectPackageManager,
fetchChannelVersion,
formatCommand,
formatUpdateNotice,
getUpdateCachePath,
isUpdateCheckDisabled,
NPMJS_REGISTRY,
normalizeRegistry,
PACKAGE_NAME,
readUpdateCache,
resolveRegistry,
selfUpdate,
updateNotice,
writeUpdateCache,
} from './nopy.update.js';
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
// Workflow
export {
+56
View File
@@ -16,9 +16,22 @@ import {
listHistory,
} from './nopy.history.js';
import { nopy } from './nopy.main.js';
import type { Channel } from './nopy.update.js';
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
/**
* Prints the update hint to stderr, so it never lands in `--json` output or in
* a `--print-only` command list being piped somewhere.
*/
async function printUpdateNotice(): Promise<void> {
const notice = await updateNotice({ currentVersion: version });
if (notice) {
console.error(`\n${notice}\n`);
}
}
const program = new Command();
program
@@ -63,6 +76,8 @@ program
.option('-j, --json', 'Output results as JSON')
.option('--no-history', 'Do not save this session to history')
.action(async (options) => {
await printUpdateNotice();
// Loaded lazily so that --help/--version work outside a configured project.
const execConfig = loadConfig().execution ?? {};
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
@@ -150,4 +165,45 @@ program
console.log('Session history cleared.');
});
program
.command('self-update')
.description('Update nopy to the newest version on your channel')
.alias('upgrade')
.option('-n, --dry-run', 'Show the install command without running it')
.option('-f, --force', 'Reinstall even when already up to date')
.option('--channel <tag>', 'Check a specific channel (latest, next, main)')
.option('--registry <url>', 'Install from a specific registry')
.action(async (options) => {
try {
const result = await selfUpdate({
currentVersion: version,
channel: options.channel as Channel | undefined,
registry: options.registry,
dryRun: options.dryRun,
force: options.force,
});
const { status } = result;
console.log(`Installed: ${status.current}`);
console.log(`Channel: ${status.channel}`);
console.log(`Registry: ${status.registry}`);
console.log(`Available: ${status.latest ?? 'unknown'}`);
console.log('');
if (result.ran) {
console.log(`Updated to ${status.latest}.`);
} else if (options.dryRun) {
console.log(`Would run: ${formatCommand(result.command)}`);
} else if (status.latest === null) {
console.error(`Could not reach ${status.registry} — nothing was changed.`);
process.exit(1);
} else {
console.log('Already up to date.');
}
} catch (error) {
console.error('Update failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
program.parse();
+513
View File
@@ -0,0 +1,513 @@
/**
* Update checking and self-update for the nopy CLI
*
* The channel a user is on is never stored anywhere — it is derived from the
* version they are running, which is the one piece of state that is always
* correct. A `-main.` prerelease came from the snapshot workflow, any other
* prerelease came out under `next`, and a clean version came out under
* `latest`. Upgrading therefore keeps you on the channel you installed from
* instead of silently moving you to a different one.
*
* @module nopy.update
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execa } from 'execa';
import semver from 'semver';
/** The published package this CLI ships as */
export const PACKAGE_NAME = '@bitsquare/nopy';
/** The npm scope the package lives under, used for the registry config key */
export const SCOPE = '@bitsquare';
/** Where packages resolve from when nothing says otherwise */
export const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
/** Directory under the user's home holding the update-check cache */
export const UPDATE_CACHE_DIR = '.nopy';
/** File name of the update-check cache */
export const UPDATE_CACHE_FILE = 'update-check.json';
/** How long a cached check is considered fresh */
export const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/**
* How long the background check may block the CLI.
*
* Short on purpose: this runs before the first prompt, so a slow or
* unreachable registry has to cost a moment, not a session.
*/
export const DEFAULT_FETCH_TIMEOUT_MS = 1500;
/** How long `npm config get` may take before the registry falls back to npmjs */
export const DEFAULT_CONFIG_TIMEOUT_MS = 5000;
/**
* A dist-tag this project publishes under.
*
* `latest` is a release, `next` a prerelease (`0.6.0-rc.1`), `main` a snapshot
* built from a commit on `main` and published to Gitea only.
*/
export type Channel = 'latest' | 'next' | 'main';
/** A package manager that can install a global binary */
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
/** Runs a command and resolves with its trimmed stdout */
export type CommandRunner = (file: string, args: string[]) => Promise<string>;
/** The result of an update check */
export interface UpdateStatus {
/** The version currently running */
current: string;
/** The version the channel points at, or null if it could not be determined */
latest: string | null;
/** The channel the current version implies */
channel: Channel;
/** The registry the check went to */
registry: string;
/** Whether `latest` is strictly newer than `current` */
updateAvailable: boolean;
/** Whether the answer came from cache rather than the network */
fromCache: boolean;
}
/** The on-disk update-check cache */
export interface UpdateCache {
/** ISO timestamp of the check */
checkedAt: string;
/** The channel that was checked */
channel: Channel;
/** The registry that was checked */
registry: string;
/** The version the channel pointed at, or null if the lookup found nothing */
latest: string | null;
}
/**
* Derives the release channel from a version string.
*
* @param version - a semver version, typically this package's own
* @returns the dist-tag that version would have been published under
*/
export function channelForVersion(version: string): Channel {
const parsed = semver.parse(version, { loose: true });
// An unparseable version is treated as a release: the worst case is that a
// check goes to `latest` and finds nothing newer.
if (!parsed || parsed.prerelease.length === 0) {
return 'latest';
}
return parsed.prerelease.some((part) => part === 'main') ? 'main' : 'next';
}
/**
* Normalises a registry URL to the trailing-slash form the packument path is
* appended to.
*/
export function normalizeRegistry(url: string): string {
const trimmed = url.trim();
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
}
/** Runs a command through execa and returns its stdout */
const defaultRunner: CommandRunner = async (file, args) => {
const { stdout } = await execa(file, args, { timeout: DEFAULT_CONFIG_TIMEOUT_MS });
return stdout;
};
/**
* Resolves the registry `@bitsquare` packages come from.
*
* `NOPY_REGISTRY` wins, then npm's own scoped-registry config — asking npm is
* what makes a global install from Gitea check Gitea for its updates without
* anything else being configured — and npmjs is the fallback.
*
* @returns a registry URL in trailing-slash form
*/
export async function resolveRegistry(
options: { env?: NodeJS.ProcessEnv; run?: CommandRunner } = {}
): Promise<string> {
const env = options.env ?? process.env;
const override = env.NOPY_REGISTRY?.trim();
if (override) {
return normalizeRegistry(override);
}
const run = options.run ?? defaultRunner;
try {
const stdout = (await run('npm', ['config', 'get', `${SCOPE}:registry`])).trim();
// npm prints the string "undefined" for an unset key rather than nothing.
if (stdout && stdout !== 'undefined' && stdout !== 'null') {
return normalizeRegistry(stdout);
}
} catch {
// npm not on PATH, or the config is unreadable. Neither is worth failing a
// deployment over.
}
return NPMJS_REGISTRY;
}
/**
* Reads the version a dist-tag points at, straight from the registry.
*
* Deliberately a plain `fetch` of the packument rather than shelling out to
* `npm view`: it is one request, it honours a timeout, and it cannot be slowed
* down by npm's own startup.
*
* @returns the version, or null if the registry or the tag has nothing
*/
export async function fetchChannelVersion(options: {
registry: string;
channel: Channel;
packageName?: string;
timeoutMs?: number;
token?: string;
fetchImpl?: typeof fetch;
}): Promise<string | null> {
const doFetch = options.fetchImpl ?? globalThis.fetch;
const packageName = options.packageName ?? PACKAGE_NAME;
const url = `${normalizeRegistry(options.registry)}${encodeURIComponent(packageName)}`;
const headers: Record<string, string> = {
// The abbreviated packument where the registry supports it; Gitea ignores
// this and sends the full document, which parses the same.
accept: 'application/vnd.npm.install-v1+json, application/json',
};
if (options.token) {
headers.authorization = `Bearer ${options.token}`;
}
const response = await doFetch(url, {
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as { 'dist-tags'?: Record<string, string> };
return body['dist-tags']?.[options.channel] ?? null;
}
/** Path of the update-check cache file */
export function getUpdateCachePath(homedir: string = os.homedir()): string {
return path.join(homedir, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE);
}
/**
* Reads the update-check cache.
*
* @returns the cache, or null if it is missing or unreadable
*/
export function readUpdateCache(cachePath: string = getUpdateCachePath()): UpdateCache | null {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as UpdateCache;
// A hand-edited or half-written file must not be trusted into the compare.
return typeof parsed?.checkedAt === 'string' ? parsed : null;
} catch {
return null;
}
}
/**
* Writes the update-check cache. Best effort — a read-only home directory
* costs a network check per run, not a failure.
*/
export function writeUpdateCache(
cache: UpdateCache,
cachePath: string = getUpdateCachePath()
): void {
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
} catch {
// Ignored on purpose.
}
}
/**
* Whether the startup check should be skipped entirely.
*
* `NOPY_NO_UPDATE_CHECK` is the explicit opt-out; `CI` covers the case nobody
* remembers to opt out of.
*/
export function isUpdateCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
const flag = env.NOPY_NO_UPDATE_CHECK?.trim().toLowerCase();
if (flag && flag !== '0' && flag !== 'false') {
return true;
}
return Boolean(env.CI);
}
/**
* Checks whether a newer version exists on the current channel.
*
* Answers from cache when a check happened recently for the same channel and
* registry; otherwise asks the registry and refreshes the cache. A failed
* lookup falls back to whatever the cache last saw, so a flaky network degrades
* to a stale answer rather than no answer.
*/
export async function checkForUpdate(options: {
currentVersion: string;
channel?: Channel;
registry?: string;
force?: boolean;
intervalMs?: number;
cachePath?: string;
now?: number;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
fetchImpl?: typeof fetch;
run?: CommandRunner;
}): Promise<UpdateStatus> {
const {
currentVersion,
force = false,
intervalMs = DEFAULT_CHECK_INTERVAL_MS,
cachePath = getUpdateCachePath(),
now = Date.now(),
env = process.env,
} = options;
const channel = options.channel ?? channelForVersion(currentVersion);
const registry = normalizeRegistry(
options.registry ?? (await resolveRegistry({ env, run: options.run }))
);
const cache = readUpdateCache(cachePath);
// A cache entry for a different channel or registry answers a different
// question, so it is never fresh for this one.
const applicable = cache && cache.channel === channel && cache.registry === registry;
const age = cache ? now - Date.parse(cache.checkedAt) : Number.POSITIVE_INFINITY;
const fresh = applicable && Number.isFinite(age) && age >= 0 && age < intervalMs;
if (!force && fresh && cache) {
return status(currentVersion, cache.latest, channel, registry, true);
}
try {
const latest = await fetchChannelVersion({
registry,
channel,
timeoutMs: options.timeoutMs,
token: env.NOPY_REGISTRY_TOKEN?.trim() || undefined,
fetchImpl: options.fetchImpl,
});
writeUpdateCache(
{ checkedAt: new Date(now).toISOString(), channel, registry, latest },
cachePath
);
return status(currentVersion, latest, channel, registry, false);
} catch {
// Offline, timed out, or the registry returned something unparseable.
return status(
currentVersion,
applicable && cache ? cache.latest : null,
channel,
registry,
true
);
}
}
/** Assembles an {@link UpdateStatus}, deciding whether the remote version wins */
function status(
current: string,
latest: string | null,
channel: Channel,
registry: string,
fromCache: boolean
): UpdateStatus {
const updateAvailable = Boolean(
latest && semver.valid(latest) && semver.valid(current) && semver.gt(latest, current)
);
return { current, latest, channel, registry, updateAvailable, fromCache };
}
/**
* Detects which package manager installed this CLI, so `self-update` re-runs
* the same one rather than leaving two copies on the PATH.
*
* The install path is the evidence: pnpm and bun keep globals under their own
* directory, npm does not.
*/
export function detectPackageManager(
options: { execPath?: string; env?: NodeJS.ProcessEnv } = {}
): PackageManager {
const env = options.env ?? process.env;
const override = env.NOPY_PACKAGE_MANAGER?.trim().toLowerCase();
if (override === 'npm' || override === 'pnpm' || override === 'yarn' || override === 'bun') {
return override;
}
const from = (options.execPath ?? process.argv[1] ?? '').replace(/\\/g, '/').toLowerCase();
if (from.includes('/pnpm/')) return 'pnpm';
if (from.includes('/.bun/')) return 'bun';
if (from.includes('/.yarn/') || from.includes('/yarn/')) return 'yarn';
return 'npm';
}
/**
* Builds the command that installs a given channel globally.
*
* The registry is passed as a **scoped** override rather than `--registry`.
* That is load-bearing for Gitea: its npm registry serves `@bitsquare`
* packages and does not proxy npmjs, so a global `--registry` would send
* `commander`, `execa` and every other dependency to a registry that has never
* heard of them.
*/
export function buildSelfUpdateCommand(options: {
packageManager: PackageManager;
channel: Channel;
registry: string;
packageName?: string;
}): { file: string; args: string[] } {
const packageName = options.packageName ?? PACKAGE_NAME;
const spec = `${packageName}@${options.channel}`;
const registryArgs =
normalizeRegistry(options.registry) === NPMJS_REGISTRY
? []
: [`--${SCOPE}:registry=${normalizeRegistry(options.registry)}`];
switch (options.packageManager) {
case 'pnpm':
return { file: 'pnpm', args: ['add', '--global', spec, ...registryArgs] };
case 'yarn':
return { file: 'yarn', args: ['global', 'add', spec, ...registryArgs] };
case 'bun':
return { file: 'bun', args: ['add', '--global', spec, ...registryArgs] };
default:
return { file: 'npm', args: ['install', '--global', spec, ...registryArgs] };
}
}
/** Renders a command as the shell line a user could paste */
export function formatCommand(command: { file: string; args: string[] }): string {
return [command.file, ...command.args].join(' ');
}
/**
* Renders the one-line hint printed at startup when an update exists.
*
* @returns the notice, or null when there is nothing to say
*/
export function formatUpdateNotice(
status: UpdateStatus,
packageManager?: PackageManager
): string | null {
if (!status.updateAvailable || !status.latest) {
return null;
}
const command = buildSelfUpdateCommand({
packageManager: packageManager ?? detectPackageManager(),
channel: status.channel,
registry: status.registry,
});
const channelNote = status.channel === 'latest' ? '' : ` (${status.channel})`;
return [
`Update available: ${status.current} -> ${status.latest}${channelNote}`,
`Run "nopy self-update" or "${formatCommand(command)}"`,
].join('\n');
}
/**
* The startup path: returns the notice to print, or null.
*
* Never throws and never blocks for longer than the fetch timeout, because it
* sits in front of every command the user actually asked for.
*/
export async function updateNotice(options: {
currentVersion: string;
env?: NodeJS.ProcessEnv;
cachePath?: string;
intervalMs?: number;
timeoutMs?: number;
now?: number;
fetchImpl?: typeof fetch;
run?: CommandRunner;
}): Promise<string | null> {
const env = options.env ?? process.env;
if (isUpdateCheckDisabled(env)) {
return null;
}
try {
const status = await checkForUpdate({ ...options, env });
return formatUpdateNotice(status, detectPackageManager({ env }));
} catch {
return null;
}
}
/** Outcome of a {@link selfUpdate} run */
export interface SelfUpdateResult {
/** The status the decision was based on */
status: UpdateStatus;
/** The command that was run, or would have been run */
command: { file: string; args: string[] };
/** Whether the install actually ran */
ran: boolean;
}
/**
* Installs the newest version on the current channel.
*
* @param options.dryRun - print the command instead of running it
* @param options.force - reinstall even when already up to date
*/
export async function selfUpdate(options: {
currentVersion: string;
channel?: Channel;
registry?: string;
packageManager?: PackageManager;
dryRun?: boolean;
force?: boolean;
env?: NodeJS.ProcessEnv;
cachePath?: string;
fetchImpl?: typeof fetch;
run?: CommandRunner;
spawn?: (file: string, args: string[]) => Promise<unknown>;
}): Promise<SelfUpdateResult> {
const env = options.env ?? process.env;
// Always ignore the cache here: the user asked, so the answer has to be current.
const status = await checkForUpdate({
currentVersion: options.currentVersion,
channel: options.channel,
registry: options.registry,
force: true,
cachePath: options.cachePath,
env,
fetchImpl: options.fetchImpl,
run: options.run,
});
const command = buildSelfUpdateCommand({
packageManager: options.packageManager ?? detectPackageManager({ env }),
channel: status.channel,
registry: status.registry,
});
if (options.dryRun || (!status.updateAvailable && !options.force)) {
return { status, command, ran: false };
}
const spawn =
options.spawn ?? ((file: string, args: string[]) => execa(file, args, { stdio: 'inherit' }));
await spawn(command.file, command.args);
return { status, command, ran: true };
}
+865
View File
@@ -0,0 +1,865 @@
/**
* Tests for nopy.update module
*
* Every network call, clock read and spawn is injected, so nothing here
* reaches a registry or the user's home directory.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildSelfUpdateCommand,
type Channel,
channelForVersion,
checkForUpdate,
detectPackageManager,
fetchChannelVersion,
formatCommand,
formatUpdateNotice,
getUpdateCachePath,
isUpdateCheckDisabled,
NPMJS_REGISTRY,
normalizeRegistry,
readUpdateCache,
resolveRegistry,
selfUpdate,
type UpdateCache,
updateNotice,
writeUpdateCache,
} from '../src/nopy.update.js';
const GITEA = 'https://gitea.bitsquare.dev/api/packages/BitSquare/npm/';
let tmpDir: string;
let cachePath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-update-'));
cachePath = path.join(tmpDir, 'update-check.json');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
/** A fetch stand-in returning the given dist-tags */
function fakeFetch(distTags: Record<string, string>, ok = true): typeof fetch {
return (async () =>
({
ok,
json: async () => ({ 'dist-tags': distTags }),
}) as Response) as unknown as typeof fetch;
}
describe('channelForVersion', () => {
it('maps a clean release to latest', () => {
expect(channelForVersion('0.5.0')).toBe('latest');
expect(channelForVersion('1.2.3')).toBe('latest');
});
it('maps a snapshot to main', () => {
expect(channelForVersion('0.5.0-main.14.g6ecb2c3')).toBe('main');
});
it('maps any other prerelease to next', () => {
expect(channelForVersion('0.6.0-rc.1')).toBe('next');
expect(channelForVersion('1.0.0-alpha5')).toBe('next');
});
it('treats an unparseable version as latest', () => {
expect(channelForVersion('not-a-version')).toBe('latest');
expect(channelForVersion('')).toBe('latest');
});
});
describe('normalizeRegistry', () => {
it('adds a trailing slash', () => {
expect(normalizeRegistry('https://example.com/npm')).toBe('https://example.com/npm/');
});
it('leaves an existing trailing slash alone', () => {
expect(normalizeRegistry(GITEA)).toBe(GITEA);
});
it('trims surrounding whitespace', () => {
expect(normalizeRegistry(' https://example.com/npm ')).toBe('https://example.com/npm/');
});
});
describe('resolveRegistry', () => {
it('prefers the NOPY_REGISTRY override', async () => {
const run = vi.fn();
const registry = await resolveRegistry({
env: { NOPY_REGISTRY: 'https://example.com/npm' },
run,
});
expect(registry).toBe('https://example.com/npm/');
expect(run).not.toHaveBeenCalled();
});
it('falls back to npm config', async () => {
const run = vi.fn(async () => GITEA);
expect(await resolveRegistry({ env: {}, run })).toBe(GITEA);
expect(run).toHaveBeenCalledWith('npm', ['config', 'get', '@bitsquare:registry']);
});
it('treats npm printing "undefined" as unset', async () => {
const run = vi.fn(async () => 'undefined');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('treats npm printing "null" as unset', async () => {
const run = vi.fn(async () => 'null');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('treats empty output as unset', async () => {
const run = vi.fn(async () => ' ');
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('falls back to npmjs when npm is missing', async () => {
const run = vi.fn(async () => {
throw new Error('ENOENT');
});
expect(await resolveRegistry({ env: {}, run })).toBe(NPMJS_REGISTRY);
});
it('ignores a blank override', async () => {
const run = vi.fn(async () => GITEA);
expect(await resolveRegistry({ env: { NOPY_REGISTRY: ' ' }, run })).toBe(GITEA);
});
});
describe('fetchChannelVersion', () => {
it('reads the requested dist-tag', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'main',
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234', latest: '0.5.0' }),
});
expect(version).toBe('0.5.0-main.14.gabc1234');
});
it('returns null when the tag does not exist', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'latest',
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gabc1234' }),
});
expect(version).toBeNull();
});
it('returns null on a non-ok response', async () => {
const version = await fetchChannelVersion({
registry: GITEA,
channel: 'latest',
fetchImpl: fakeFetch({}, false),
});
expect(version).toBeNull();
});
it('returns null when the packument has no dist-tags at all', async () => {
const fetchImpl = (async () =>
({ ok: true, json: async () => ({}) }) as Response) as unknown as typeof fetch;
expect(await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl })).toBeNull();
});
it('url-encodes the scoped package name onto the registry', async () => {
const seen: string[] = [];
const fetchImpl = (async (url: string) => {
seen.push(url);
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
// No trailing slash on purpose: it must be normalised before joining.
await fetchChannelVersion({
registry: 'https://example.com/npm',
channel: 'latest',
fetchImpl,
});
expect(seen[0]).toBe('https://example.com/npm/%40bitsquare%2Fnopy');
});
it('sends a bearer token when one is given', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
await fetchChannelVersion({ registry: GITEA, channel: 'latest', token: 'secret', fetchImpl });
expect(headers.authorization).toBe('Bearer secret');
});
it('omits the authorization header when no token is given', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.5.0' } }) } as Response;
}) as unknown as typeof fetch;
await fetchChannelVersion({ registry: GITEA, channel: 'latest', fetchImpl });
expect(headers.authorization).toBeUndefined();
});
});
describe('the update cache', () => {
it('round-trips', () => {
const cache: UpdateCache = {
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.6.0',
};
writeUpdateCache(cache, cachePath);
expect(readUpdateCache(cachePath)).toEqual(cache);
});
it('creates the containing directory', () => {
const nested = path.join(tmpDir, 'a', 'b', 'update-check.json');
writeUpdateCache(
{
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: null,
},
nested
);
expect(fs.existsSync(nested)).toBe(true);
});
it('reads a missing file as null', () => {
expect(readUpdateCache(path.join(tmpDir, 'absent.json'))).toBeNull();
});
it('reads malformed JSON as null', () => {
fs.writeFileSync(cachePath, '{ not json', 'utf-8');
expect(readUpdateCache(cachePath)).toBeNull();
});
it('rejects a file without a checkedAt stamp', () => {
fs.writeFileSync(cachePath, JSON.stringify({ latest: '9.9.9' }), 'utf-8');
expect(readUpdateCache(cachePath)).toBeNull();
});
it('swallows a write it cannot perform', () => {
// A path whose parent is a file, not a directory.
const blocked = path.join(cachePath, 'nested.json');
fs.writeFileSync(cachePath, '{}', 'utf-8');
expect(() =>
writeUpdateCache(
{
checkedAt: '2026-07-29T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: null,
},
blocked
)
).not.toThrow();
});
it('defaults to a path under the home directory', () => {
expect(getUpdateCachePath('/home/someone')).toBe('/home/someone/.nopy/update-check.json');
});
});
describe('isUpdateCheckDisabled', () => {
it('is off by default', () => {
expect(isUpdateCheckDisabled({})).toBe(false);
});
it('honours NOPY_NO_UPDATE_CHECK', () => {
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: '1' })).toBe(true);
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: 'yes' })).toBe(true);
});
it('treats 0 and false as not disabled', () => {
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: '0' })).toBe(false);
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: 'false' })).toBe(false);
expect(isUpdateCheckDisabled({ NOPY_NO_UPDATE_CHECK: '' })).toBe(false);
});
it('disables itself in CI', () => {
expect(isUpdateCheckDisabled({ CI: 'true' })).toBe(true);
});
});
describe('checkForUpdate', () => {
const base = {
currentVersion: '0.5.0',
registry: NPMJS_REGISTRY,
env: {} as NodeJS.ProcessEnv,
now: Date.parse('2026-07-29T12:00:00.000Z'),
};
it('reports a newer version on the channel', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status).toMatchObject({
current: '0.5.0',
latest: '0.6.0',
channel: 'latest',
updateAvailable: true,
fromCache: false,
});
});
it('reports no update when the channel matches', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
});
expect(status.updateAvailable).toBe(false);
});
it('does not treat an older published version as an update', async () => {
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.4.0' }),
});
expect(status.updateAvailable).toBe(false);
});
it('derives the channel from the running version', async () => {
const status = await checkForUpdate({
...base,
currentVersion: '0.5.0-main.13.gabc1234',
cachePath,
fetchImpl: fakeFetch({ main: '0.5.0-main.14.gdef5678', latest: '0.5.0' }),
});
expect(status.channel).toBe('main');
expect(status.latest).toBe('0.5.0-main.14.gdef5678');
expect(status.updateAvailable).toBe(true);
});
it('writes what it found to the cache', async () => {
await checkForUpdate({ ...base, cachePath, fetchImpl: fakeFetch({ latest: '0.6.0' }) });
expect(readUpdateCache(cachePath)).toEqual({
checkedAt: '2026-07-29T12:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.6.0',
});
});
it('answers from a fresh cache without touching the network', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const fetchImpl = vi.fn();
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(status.latest).toBe('0.7.0');
expect(status.fromCache).toBe(true);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('refetches once the cache goes stale', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-27T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.8.0' }),
});
expect(status.latest).toBe('0.8.0');
expect(status.fromCache).toBe(false);
});
it('ignores a cache written for a different channel', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'next',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache written for a different registry', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: GITEA,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache stamped in the future', async () => {
writeUpdateCache(
{
checkedAt: '2027-01-01T00:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('ignores a cache with an unparseable stamp', async () => {
fs.writeFileSync(
cachePath,
JSON.stringify({
checkedAt: 'whenever',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '9.9.9',
}),
'utf-8'
);
const status = await checkForUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.latest).toBe('0.6.0');
});
it('refetches when forced, even with a fresh cache', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-29T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const status = await checkForUpdate({
...base,
cachePath,
force: true,
fetchImpl: fakeFetch({ latest: '0.9.0' }),
});
expect(status.latest).toBe('0.9.0');
expect(status.fromCache).toBe(false);
});
it('falls back to the cached answer when the network fails', async () => {
writeUpdateCache(
{
checkedAt: '2026-07-20T11:00:00.000Z',
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.7.0',
},
cachePath
);
const fetchImpl = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
expect(status.latest).toBe('0.7.0');
expect(status.updateAvailable).toBe(true);
expect(status.fromCache).toBe(true);
});
it('reports nothing when the network fails and no cache applies', async () => {
const fetchImpl = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const status = await checkForUpdate({ ...base, cachePath, fetchImpl });
expect(status.latest).toBeNull();
expect(status.updateAvailable).toBe(false);
});
it('resolves the registry when none is given', async () => {
const status = await checkForUpdate({
currentVersion: '0.5.0',
cachePath,
env: {},
run: async () => GITEA,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.registry).toBe(GITEA);
});
it('passes a registry token from the environment through', async () => {
let headers: Record<string, string> = {};
const fetchImpl = (async (_url: string, init: RequestInit) => {
headers = init.headers as Record<string, string>;
return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.6.0' } }) } as Response;
}) as unknown as typeof fetch;
await checkForUpdate({
...base,
cachePath,
env: { NOPY_REGISTRY_TOKEN: 'tok' },
fetchImpl,
});
expect(headers.authorization).toBe('Bearer tok');
});
it('does not compare against an unparseable current version', async () => {
const status = await checkForUpdate({
...base,
currentVersion: 'dev',
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(status.updateAvailable).toBe(false);
});
});
describe('detectPackageManager', () => {
it('honours the environment override', () => {
expect(
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'pnpm' }, execPath: '/usr/lib/x' })
).toBe('pnpm');
expect(
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'YARN' }, execPath: '/usr/lib/x' })
).toBe('yarn');
expect(
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'bun' }, execPath: '/usr/lib/x' })
).toBe('bun');
expect(
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'npm' }, execPath: '/x/pnpm/y' })
).toBe('npm');
});
it('ignores an unrecognised override', () => {
expect(
detectPackageManager({ env: { NOPY_PACKAGE_MANAGER: 'cargo' }, execPath: '/usr/lib/x' })
).toBe('npm');
});
it('recognises a pnpm global install', () => {
expect(
detectPackageManager({
env: {},
execPath: '/Users/x/Library/pnpm/global/5/node_modules/.bin/nopy',
})
).toBe('pnpm');
});
it('recognises a bun global install', () => {
expect(
detectPackageManager({ env: {}, execPath: '/Users/x/.bun/install/global/node_modules/nopy' })
).toBe('bun');
});
it('recognises a yarn global install', () => {
expect(detectPackageManager({ env: {}, execPath: '/Users/x/.yarn/bin/nopy' })).toBe('yarn');
});
it('defaults to npm', () => {
expect(
detectPackageManager({
env: {},
execPath: '/usr/local/lib/node_modules/@bitsquare/nopy/dist/nopy.cli.js',
})
).toBe('npm');
});
it('handles a windows-style path and an empty path', () => {
expect(
detectPackageManager({ env: {}, execPath: 'C:\\Users\\x\\AppData\\Local\\pnpm\\nopy.exe' })
).toBe('pnpm');
expect(detectPackageManager({ env: {}, execPath: '' })).toBe('npm');
});
});
describe('buildSelfUpdateCommand', () => {
it('builds an npm global install without a registry flag for npmjs', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/nopy@latest');
});
it('adds a scoped registry override for a non-npmjs registry', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'main',
registry: GITEA,
});
// Scoped, not `--registry`: Gitea does not proxy npmjs, so the transitive
// dependencies have to keep resolving from npmjs.
expect(formatCommand(command)).toBe(
`npm install --global @bitsquare/nopy@main --@bitsquare:registry=${GITEA}`
);
expect(command.args).not.toContain('--registry');
});
it('normalises a registry given without a trailing slash', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: 'https://registry.npmjs.org',
});
expect(command.args).toEqual(['install', '--global', '@bitsquare/nopy@latest']);
});
it('builds for pnpm, yarn and bun', () => {
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'pnpm',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('pnpm add --global @bitsquare/nopy@next');
expect(
formatCommand(
buildSelfUpdateCommand({
packageManager: 'yarn',
channel: 'next',
registry: NPMJS_REGISTRY,
})
)
).toBe('yarn global add @bitsquare/nopy@next');
expect(
formatCommand(
buildSelfUpdateCommand({ packageManager: 'bun', channel: 'next', registry: NPMJS_REGISTRY })
)
).toBe('bun add --global @bitsquare/nopy@next');
});
it('accepts an explicit package name', () => {
const command = buildSelfUpdateCommand({
packageManager: 'npm',
channel: 'latest',
registry: NPMJS_REGISTRY,
packageName: '@bitsquare/keyman',
});
expect(formatCommand(command)).toBe('npm install --global @bitsquare/keyman@latest');
});
});
describe('formatUpdateNotice', () => {
const status = {
current: '0.5.0',
latest: '0.6.0',
channel: 'latest' as Channel,
registry: NPMJS_REGISTRY,
updateAvailable: true,
fromCache: false,
};
it('names both versions and the command', () => {
const notice = formatUpdateNotice(status, 'npm');
expect(notice).toContain('0.5.0 -> 0.6.0');
expect(notice).toContain('nopy self-update');
expect(notice).toContain('npm install --global @bitsquare/nopy@latest');
});
it('names a non-default channel', () => {
expect(formatUpdateNotice({ ...status, channel: 'main' }, 'npm')).toContain('(main)');
});
it('says nothing when there is no update', () => {
expect(formatUpdateNotice({ ...status, updateAvailable: false }, 'npm')).toBeNull();
});
it('says nothing when the latest version is unknown', () => {
expect(formatUpdateNotice({ ...status, latest: null }, 'npm')).toBeNull();
});
it('detects the package manager when none is given', () => {
expect(formatUpdateNotice(status)).toContain('@bitsquare/nopy@latest');
});
});
describe('updateNotice', () => {
it('returns a notice when an update exists', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { NOPY_REGISTRY: NPMJS_REGISTRY },
cachePath,
now: Date.parse('2026-07-29T12:00:00.000Z'),
fetchImpl: fakeFetch({ latest: '0.6.0' }),
});
expect(notice).toContain('0.5.0 -> 0.6.0');
});
it('returns null when the check is disabled', async () => {
const fetchImpl = vi.fn();
const notice = await updateNotice({
currentVersion: '0.5.0',
env: { NOPY_NO_UPDATE_CHECK: '1' },
cachePath,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(notice).toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('returns null rather than throwing when everything fails', async () => {
const notice = await updateNotice({
currentVersion: '0.5.0',
env: {},
cachePath,
run: async () => {
throw new Error('no npm');
},
fetchImpl: (async () => {
throw new Error('offline');
}) as unknown as typeof fetch,
});
expect(notice).toBeNull();
});
});
describe('selfUpdate', () => {
const base = {
currentVersion: '0.5.0',
env: { NOPY_REGISTRY: NPMJS_REGISTRY } as NodeJS.ProcessEnv,
packageManager: 'npm' as const,
};
it('runs the install when a newer version exists', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn,
});
expect(result.ran).toBe(true);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--global', '@bitsquare/nopy@latest']);
});
it('does nothing when already up to date', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
spawn,
});
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
it('reinstalls when forced', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
force: true,
fetchImpl: fakeFetch({ latest: '0.5.0' }),
spawn,
});
expect(result.ran).toBe(true);
});
it('reports the command without running it on a dry run', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
dryRun: true,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn,
});
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
expect(formatCommand(result.command)).toBe('npm install --global @bitsquare/nopy@latest');
});
it('ignores a fresh cache, because the user asked', async () => {
writeUpdateCache(
{
checkedAt: new Date().toISOString(),
channel: 'latest',
registry: NPMJS_REGISTRY,
latest: '0.5.0',
},
cachePath
);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: fakeFetch({ latest: '0.6.0' }),
spawn: async () => undefined,
});
expect(result.status.latest).toBe('0.6.0');
expect(result.ran).toBe(true);
});
it('follows an explicit channel and registry', async () => {
const result = await selfUpdate({
currentVersion: '0.5.0',
env: {},
packageManager: 'pnpm',
channel: 'main',
registry: GITEA,
cachePath,
fetchImpl: fakeFetch({ main: '0.5.0-main.20.gaaaaaaa' }),
spawn: async () => undefined,
});
expect(formatCommand(result.command)).toBe(
`pnpm add --global @bitsquare/nopy@main --@bitsquare:registry=${GITEA}`
);
});
it('does not run when the registry could not be reached', async () => {
const spawn = vi.fn(async () => undefined);
const result = await selfUpdate({
...base,
cachePath,
fetchImpl: (async () => {
throw new Error('offline');
}) as unknown as typeof fetch,
spawn,
});
expect(result.status.latest).toBeNull();
expect(result.ran).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
});