[feat] nopy update command and auto-update pipeline
This commit is contained in:
@@ -1,3 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
export * from './keyman.main.js';
|
||||
export type {
|
||||
Channel,
|
||||
CommandRunner,
|
||||
PackageManager,
|
||||
SelfUpdateResult,
|
||||
UpdateCache,
|
||||
UpdateStatus,
|
||||
} from './keyman.update.js';
|
||||
export {
|
||||
buildSelfUpdateCommand,
|
||||
channelForVersion,
|
||||
checkForUpdate,
|
||||
DEFAULT_CHECK_INTERVAL_MS,
|
||||
detectPackageManager,
|
||||
fetchChannelVersion,
|
||||
formatCommand,
|
||||
formatUpdateNotice,
|
||||
getUpdateCachePath,
|
||||
isUpdateCheckDisabled,
|
||||
NPMJS_REGISTRY,
|
||||
normalizeRegistry,
|
||||
PACKAGE_NAME,
|
||||
readUpdateCache,
|
||||
resolveRegistry,
|
||||
selfUpdate,
|
||||
updateNotice,
|
||||
writeUpdateCache,
|
||||
} from './keyman.update.js';
|
||||
|
||||
@@ -1,10 +1,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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user