// `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);