69 lines
3.8 KiB
TypeScript
69 lines
3.8 KiB
TypeScript
// `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);
|