From 14ffe3545ac23f9616f815fd906a96ea49b222be Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 18 Oct 2026 13:07:45 +0700 Subject: [PATCH] npx / bunx storebound always run the newest version: a copy started from the npx or bunx cache re-runs the command as storebound@ 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 --- cli/index.ts | 2 ++ cli/lib/self-update.ts | 69 +++++++++++++++++++++++++++++++++++++++ tests/self-update.test.ts | 15 +++++++++ 3 files changed, 86 insertions(+) create mode 100644 cli/lib/self-update.ts create mode 100644 tests/self-update.test.ts diff --git a/cli/index.ts b/cli/index.ts index e5bd789..b6346f4 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -61,6 +61,8 @@ async function main(): Promise { 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); diff --git a/cli/lib/self-update.ts b/cli/lib/self-update.ts new file mode 100644 index 0000000..c3e91f9 --- /dev/null +++ b/cli/lib/self-update.ts @@ -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@` (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 { + 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 { + 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(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); diff --git a/tests/self-update.test.ts b/tests/self-update.test.ts new file mode 100644 index 0000000..845d40c --- /dev/null +++ b/tests/self-update.test.ts @@ -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); +});