npx / bunx storebound always run the newest version: a copy started from the npx or bunx cache re-runs the command as storebound@<newest> when npm has a newer one (registry asked at most every 10 minutes, 1.5 s timeout; off with STOREBOUND_NO_UPDATE=1, in CI and offline); a project install gets a one-line note instead

This commit is contained in:
CRTkafa 2026-10-18 13:07:45 +07:00
parent 3381673ae6
commit 14ffe3545a
3 changed files with 86 additions and 0 deletions

View file

@ -61,6 +61,8 @@ async function main(): Promise<void> {
argv.splice(i, argv[i]!.includes('=') ? 1 : 2);
}
}
// npx / bunx run a cached copy: start the newest storebound instead (cli/lib/self-update.ts)
{ const { runNewest } = await import('./lib/self-update'); await runNewest((await import('../package.json')).version, process.argv.slice(2)); }
const [a, b] = argv;
if (!a || a === 'help' || a === '--help' || a === '-h') return help();
if (a === 'version' || a === '--version') return console.log((await import('../package.json')).version);

69
cli/lib/self-update.ts Normal file
View file

@ -0,0 +1,69 @@
// `npx storebound` / `bunx storebound` always run the newest version: npx and bunx reuse a cached copy without
// asking the registry, so an old storebound kept running after a release. When this copy runs from such a cache and
// npm has a newer version, the same command is started again as `npx -y storebound@<newest>` (or bunx) and this one
// exits with its code. A project's own install (node_modules) is never swapped: it gets a one-line note instead.
// Off with STOREBOUND_NO_UPDATE=1, in CI, offline and in tests; the registry is asked at most every 10 minutes.
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const CACHE = join(homedir(), '.storebound', 'update-check.json');
const TTL_MS = 10 * 60_000;
/** a > b for x.y.z versions (pre-releases are never newer than their release). Pure. */
export function newer(a: string, b: string): boolean {
const p = (v: string) => v.split('-')[0]!.split('.').map(n => Number(n) || 0);
const [x, y] = [p(a), p(b)];
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
return !a.includes('-') && b.includes('-');
}
/** How this copy was started: an npx / bunx cache (swappable), a project install, or anything else. Pure. */
export function runner(scriptPath: string): 'npx' | 'bunx' | 'project' | 'other' {
const s = scriptPath.replace(/\\/g, '/');
if (s.includes('/_npx/')) return 'npx';
if (s.includes('/.bun/install/cache/') || /\/bunx-[^/]*\//.test(s)) return 'bunx';
if (s.includes('/node_modules/storebound/')) return 'project';
return 'other';
}
async function latest(): Promise<string | null> {
try {
const c = JSON.parse(readFileSync(CACHE, 'utf8')) as { at: number; version: string };
if (Date.now() - c.at < TTL_MS && c.version) return c.version;
} catch { /* no cache */ }
try {
const r = await fetch('https://registry.npmjs.org/storebound/latest', { signal: AbortSignal.timeout(1500), headers: { accept: 'application/json' } });
if (!r.ok) return null;
const v = ((await r.json()) as { version?: string }).version ?? null;
if (v) { try { mkdirSync(join(homedir(), '.storebound'), { recursive: true }); writeFileSync(CACHE, JSON.stringify({ at: Date.now(), version: v })); } catch { /* read-only home */ } }
return v;
} catch { return null; }
}
/** Re-runs the command with the newest storebound when this copy is an outdated npx / bunx cache; returns false to
* go on with this copy. Never throws. */
export async function runNewest(current: string, argv: string[], scriptPath = process.argv[1] ?? ''): Promise<boolean> {
const env = process.env;
if (env.STOREBOUND_NO_UPDATE === '1' || env.CI || env.STOREBOUND_NO_NETWORK === '1' || env.STOREBOUND_STORE_MOCK) return false;
const how = runner(scriptPath);
if (how === 'other') return false;
const v = await latest();
if (!v || !newer(v, current)) return false;
if (how === 'project') {
process.stderr.write(`storebound ${v} is out (this project has ${current}): bun add -d storebound@latest, or npx storebound@latest\n`);
return false;
}
process.stderr.write(`storebound ${current} -> ${v} (newest)\n`);
const cmd = how === 'npx' ? ['npx', '-y', `storebound@${v}`] : ['bunx', `storebound@${v}`];
const code = await new Promise<number>(res => {
const p = spawn(cmd[0]!, [...cmd.slice(1), ...argv], { stdio: 'inherit', env: { ...env, STOREBOUND_NO_UPDATE: '1' }, shell: process.platform === 'win32' });
p.on('exit', c => res(c ?? 1));
p.on('error', () => res(-1));
});
if (code === -1) return false; // npx / bunx missing: go on with this copy
process.exit(code);
}
export const cacheFile = (): string | null => (existsSync(CACHE) ? CACHE : null);

15
tests/self-update.test.ts Normal file
View file

@ -0,0 +1,15 @@
import { expect, test } from 'bun:test';
import { newer, runner } from '../cli/lib/self-update';
test('npx / bunx caches are swapped for the newest version, a project install only gets a note', () => {
expect(runner('/Users/you/.npm/_npx/1a2b/node_modules/storebound/dist/storebound.mjs')).toBe('npx');
expect(runner('C:\\Users\\x\\AppData\\Local\\npm-cache\\_npx\\1a2b\\node_modules\\storebound\\dist\\storebound.mjs')).toBe('npx');
expect(runner('/Users/you/.bun/install/cache/storebound@0.8.12@@@1/dist/storebound.mjs')).toBe('bunx');
expect(runner('/Users/you/app/node_modules/storebound/dist/storebound.mjs')).toBe('project');
expect(runner('/Users/you/storebound/cli/index.ts')).toBe('other');
expect(newer('0.8.13', '0.8.12')).toBe(true);
expect(newer('0.8.12', '0.8.12')).toBe(false);
expect(newer('0.9.0', '0.8.99')).toBe(true);
expect(newer('0.8.12', '0.8.13')).toBe(false);
expect(newer('0.8.12', '0.8.12-rc.1')).toBe(true);
});