storebound/cli/lib/rt.ts

496 lines
23 KiB
TypeScript

// The runtime layer: everything the CLI needs from its JavaScript runtime, written against Node's own modules so the
// same code runs on Node 20+ (`npx storebound`, the dist/storebound.mjs bundle) and on Bun (`bunx storebound`, the
// sources, the compiled desktop sidecar). The CLI never touches the `Bun` global directly; a test
// (tests/node-compat.test.ts) keeps it that way. The behaviour follows Bun's where callers rely on it: spawn's
// defaults (stdin ignored, stdout piped, stderr inherited), `exited` resolving to the exit code or 128 + the signal
// number, a missing executable throwing "Executable not found in $PATH" right away, spawnSync never limiting output.
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type StdioOptions } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { createInterface } from 'node:readline';
import * as nodeModule from 'node:module';
import { constants } from 'node:os';
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
import type { Readable } from 'node:stream';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { gunzipSync as zGunzipSync } from 'node:zlib';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const BUN: any = (globalThis as any).Bun;
/** True under Bun (sources, bunx, the compiled sidecar); false under Node (the npm bundle). */
export const isBun = BUN !== undefined;
/** Runtime name and version, for `doctor` and `system.version`. */
export const runtime = { name: isBun ? 'bun' : 'node', version: isBun ? String(BUN.version) : process.versions.node } as const;
/** Bun's version, or null under Node. */
export const bunVersion: string | null = isBun ? String(BUN.version) : null;
/** The entry script the runtime started (Bun.main; under Node the script in argv[1]). */
export const mainPath = (): string => (isBun ? String(BUN.main) : process.argv[1] ?? '');
// ---- time ----
export const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, Math.max(0, ms)));
/** Blocks the thread (only for short retries in cleanup code). */
export function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
}
// ---- files and paths ----
/** Folder of a module: `here(import.meta.url)` (Bun's import.meta.dir, which Node does not have). */
export const here = (url: string): string => dirname(fileURLToPath(url));
let rootCache: string | null = null;
/**
* The storebound package folder (package.json, templates/, skills/, cli/): found upwards from this module, which is
* cli/lib/rt.ts in the sources and dist/storebound.mjs in the npm bundle. In the compiled sidecar (no package on
* disk) it is the folder two levels up, as before.
*/
export function packageRoot(): string {
if (rootCache) return rootCache;
const start = here(import.meta.url);
for (let d = start; ; d = dirname(d)) {
try {
const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string };
if (pkg.name === 'storebound') return (rootCache = d);
} catch { /* keep looking */ }
if (dirname(d) === d) break;
}
return (rootCache = resolve(start, '..', '..'));
}
/** `require.resolve(spec)` from the folder `dir` (Bun.resolveSync(spec, dir)); throws when it is not found. */
export function resolveFrom(spec: string, dir: string): string {
return nodeModule.createRequire(join(resolve(dir), '__storebound_resolve__.js')).resolve(spec);
}
/** Imports a module by file path (absolute paths become file: URLs, which Node needs on Windows). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const importPath = (path: string): Promise<any> => import(pathToFileURL(path).href);
/**
* Imports a config module (storebound.config.ts / .js). Bun runs TypeScript itself; so does Node 22.18+ / 23.6+.
* Older Node (20, 22.x) refuses `.ts` (ERR_UNKNOWN_FILE_EXTENSION): the types are stripped (node:module's
* stripTypeScriptTypes when it exists, else the small stripper below, enough for a config file) and the result is
* imported from a data: URL.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function importConfigModule(path: string): Promise<any> {
if (isBun || !/\.[cm]?ts$/.test(path)) return importPath(path);
try {
return await withoutTypeStripWarning(() => importPath(path));
} catch (e) {
const code = (e as { code?: string }).code;
if (code !== 'ERR_UNKNOWN_FILE_EXTENSION' && code !== 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING' && !/Unknown file extension/.test(String(e))) throw e;
}
const src = readFileSync(path, 'utf8');
let js: string;
const strip = (nodeModule as unknown as { stripTypeScriptTypes?: (s: string) => string }).stripTypeScriptTypes;
try { js = typeof strip === 'function' ? withoutTypeStripWarningSync(() => strip(src)) : stripTypes(src); } catch { js = stripTypes(src); }
if (/^\s*import\s[^'"]*['"]\.{1,2}\//m.test(js)) throw new Error(`${path}: this Node (${process.versions.node}) cannot load a TypeScript config that imports other files; use Node 22.18+ or Bun, or a storebound.config.js`);
return import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`);
}
/** Node 22.18+ prints an ExperimentalWarning the first time it strips types; storebound's output stays clean. */
async function withoutTypeStripWarning<T>(f: () => Promise<T>): Promise<T> {
const emit = process.emitWarning;
process.emitWarning = ((w: string | Error, ...rest: unknown[]) => {
if (/Type Stripping|type stripping/i.test(String(typeof w === 'string' ? w : w.message))) return;
return (emit as (...a: unknown[]) => void).call(process, w, ...rest);
}) as typeof process.emitWarning;
try { return await f(); } finally { process.emitWarning = emit; }
}
function withoutTypeStripWarningSync<T>(f: () => T): T {
const emit = process.emitWarning;
process.emitWarning = (() => undefined) as typeof process.emitWarning;
try { return f(); } finally { process.emitWarning = emit; }
}
/**
* Removes the TypeScript a storebound config uses (type-only imports and exports, `: Type` on a declaration,
* `satisfies T`, `as T`) outside strings and comments. Not a TypeScript compiler: a fallback for Node versions
* without type stripping.
*/
export function stripTypes(src: string): string {
const parts: { code: boolean; text: string }[] = [];
let i = 0, start = 0;
const push = (code: boolean, end: number) => { if (end > start) parts.push({ code, text: src.slice(start, end) }); start = end; };
while (i < src.length) {
const c = src[i]!, n = src[i + 1];
if (c === '/' && n === '/') { push(true, i); const e = src.indexOf('\n', i); i = e < 0 ? src.length : e; push(false, i); continue; }
if (c === '/' && n === '*') { push(true, i); const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; push(false, i); continue; }
if (c === '"' || c === "'" || c === '`') {
push(true, i);
for (i++; i < src.length && src[i] !== c; i++) if (src[i] === '\\') i++;
i++; push(false, i); continue;
}
i++;
}
push(true, src.length);
const TYPE = String.raw`[A-Za-z_$][\w$.]*(?:<[^;=(){}]*?>)?(?:\[\])*(?:\s*\|\s*[A-Za-z_$][\w$.]*(?:<[^;=(){}]*?>)?(?:\[\])*)*`;
const out = parts.map(p => !p.code ? p.text : p.text
.replace(new RegExp(String.raw`\b(const|let|var)(\s+[A-Za-z_$][\w$]*)\s*:\s*${TYPE}\s*=`, 'g'), '$1$2 =')
.replace(new RegExp(String.raw`\s+satisfies\s+${TYPE}`, 'g'), '')
.replace(new RegExp(String.raw`\s+as\s+(?:const\b|${TYPE})`, 'g'), '')
.replace(/\{\s*type\s+/g, '{ ')
.replace(/,\s*type\s+(?=[A-Za-z_$])/g, ', ')).join('');
// type-only imports and exports, whose specifier string sits in its own (non-code) part
return out
.replace(/^\s*import\s+type\s[^;]*?from\s*(['"])[^'"]*\1\s*;?[ \t]*$/gm, '')
.replace(/^\s*export\s+type\s+\{[^}]*\}(\s*from\s*(['"])[^'"]*\2)?\s*;?[ \t]*$/gm, '')
.replace(/^\s*(export\s+)?(type\s+[A-Za-z_$][\w$]*(<[^>]*>)?\s*=[^;]*;|interface\s+[A-Za-z_$][\w$]*[^{]*\{[^}]*\})/gm, '');
}
// ---- hashing, compression ----
/** Incremental hash (Bun.CryptoHasher): `hasher('md5').update(buf).digest('hex')`. */
export const hasher = (algorithm: 'md5' | 'sha1' | 'sha256' | 'sha512') => createHash(algorithm);
export const gunzipSync = (data: Uint8Array): Uint8Array => new Uint8Array(zGunzipSync(data));
// ---- PATH lookup ----
/**
* Bun.which: the full path of `cmd` on PATH (`opts.PATH` instead of process.env.PATH), or null. A name with a path
* separator is checked as is. On Windows every PATHEXT extension is tried.
*/
export function which(cmd: string, opts: { PATH?: string; cwd?: string } = {}): string | null {
if (!cmd) return null;
const win = process.platform === 'win32';
const exts = win ? ['', ...(process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map(e => e.toLowerCase())] : [''];
const ok = (p: string): boolean => {
try {
const s = statSync(p);
if (!s.isFile()) return false;
return win || (s.mode & 0o111) !== 0;
} catch { return false; }
};
const tryExts = (base: string): string | null => {
for (const e of exts) {
if (win && e === '' && !/\.[^\\/.]+$/.test(base)) continue;
const p = base + e;
if (ok(p)) return p;
if (win && e && ok(base + e.toUpperCase())) return base + e.toUpperCase();
}
return null;
};
if (/[\\/]/.test(cmd)) return tryExts(isAbsolute(cmd) ? cmd : resolve(opts.cwd ?? process.cwd(), cmd));
const PATH = opts.PATH ?? process.env.PATH ?? (win ? process.env.Path : undefined) ?? '';
for (const dir of PATH.split(delimiter)) {
if (!dir) continue;
const hit = tryExts(join(dir.replace(/^"(.*)"$/, '$1'), cmd));
if (hit) return hit;
}
return null;
}
// ---- processes ----
type Io = 'pipe' | 'inherit' | 'ignore';
export interface SpawnOptions {
cwd?: string;
env?: Record<string, string | undefined>;
stdin?: Io | null;
stdout?: Io | null;
stderr?: Io | null;
/** [stdin, stdout, stderr]; wins over the single options. */
stdio?: [Io | null, Io | null, Io | null];
/** Its own process group (session): a Ctrl+C sent to the terminal's group does not reach it. */
detached?: boolean;
}
/** A running child, shaped like Bun's Subprocess (the parts storebound uses). */
export interface Subprocess {
readonly pid: number | undefined;
/** The piped output (an empty, closed stream when not piped). */
readonly stdout: ReadableStream<Uint8Array>;
readonly stderr: ReadableStream<Uint8Array>;
/** The exit code; 128 + the signal number when a signal ended it. Never rejects. */
readonly exited: Promise<number>;
readonly exitCode: number | null;
readonly signalCode: string | null;
readonly killed: boolean;
kill(signal?: NodeJS.Signals | number): void;
/** Lets this process exit while the child runs on. */
unref(): void;
}
const notFound = (cmd: string): Error => Object.assign(new Error(`Executable not found in $PATH: "${cmd}"`), { code: 'ENOENT', path: cmd });
/** Throws like Bun when the executable does not exist (Node would only emit an asynchronous 'error'). */
function checkExecutable(cmd: string | undefined, o: { cwd?: string; env?: Record<string, string | undefined> }): string {
if (!cmd) throw new Error('spawn: empty command');
const PATH = o.env ? (o.env.PATH ?? o.env.Path ?? '') : undefined;
if (!which(cmd, { ...(PATH !== undefined ? { PATH } : {}), ...(o.cwd ? { cwd: o.cwd } : {}) })) throw notFound(cmd);
return cmd;
}
const io = (v: Io | null | undefined, dflt: Io): Io => (v === null ? 'ignore' : v ?? dflt);
const envOf = (env: Record<string, string | undefined> | undefined): NodeJS.ProcessEnv | undefined =>
env ? Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)) : undefined;
/** A node Readable as a web ReadableStream of bytes (or an empty, closed stream for null). */
function webStream(r: Readable | null | undefined): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(c) {
if (!r) { c.close(); return; }
let ended = false;
const end = () => { if (!ended) { ended = true; try { c.close(); } catch { /* closed */ } } };
r.on('data', (d: Buffer | string) => { if (!ended) c.enqueue(typeof d === 'string' ? new TextEncoder().encode(d) : new Uint8Array(d)); });
r.on('end', end);
r.on('close', end);
r.on('error', () => end());
},
});
}
const signalNumber = (s: NodeJS.Signals | null): number => (s ? (constants.signals as Record<string, number>)[s] ?? 1 : 0);
/** Bun.spawn: argv[0] on PATH (opts.env.PATH when given), Bun's stdio defaults (stdin ignore, stdout pipe, stderr inherit). */
export function spawn(argv: string[], o: SpawnOptions = {}): Subprocess {
const cmd = checkExecutable(argv[0], o);
const [i0, o0, e0] = o.stdio ?? [o.stdin, o.stdout, o.stderr];
const stdio: StdioOptions = [io(i0, 'ignore'), io(o0, 'pipe'), io(e0, 'inherit')];
const child = nodeSpawn(cmd, argv.slice(1), { cwd: o.cwd, env: envOf(o.env) ?? process.env, stdio, windowsHide: true, ...(o.detached ? { detached: true } : {}) });
let exitCode: number | null = null, signalCode: string | null = null;
const exited = new Promise<number>(res => {
let done = false;
const finish = (code: number | null, sig: NodeJS.Signals | null) => {
if (done) return;
done = true;
exitCode = code; signalCode = sig;
res(code ?? (sig ? 128 + signalNumber(sig) : 1));
};
child.on('exit', finish);
child.on('error', () => { if (child.exitCode === null && child.signalCode === null && child.pid === undefined) finish(1, null); });
});
const stdout = webStream(child.stdout), stderr = webStream(child.stderr);
return {
get pid() { return child.pid; },
stdout, stderr, exited,
get exitCode() { return exitCode; },
get signalCode() { return signalCode; },
get killed() { return child.killed; },
kill(signal?: NodeJS.Signals | number) { try { child.kill(signal ?? 'SIGTERM'); } catch { /* gone */ } },
unref() {
child.unref();
for (const s of [child.stdin, child.stdout, child.stderr]) (s as unknown as { unref?: () => void } | null)?.unref?.();
},
};
}
export interface SpawnSyncOptions {
cwd?: string;
env?: Record<string, string | undefined>;
/** Bytes or text written to the child's stdin (then closed), or an stdio mode. */
stdin?: Uint8Array | string | Io | null;
stdout?: Io | null;
stderr?: Io | null;
/** Milliseconds; the child gets SIGTERM after it (exitCode null, signalCode 'SIGTERM'). */
timeout?: number;
}
export interface SyncResult { exitCode: number | null; signalCode: string | null; success: boolean; stdout: Buffer; stderr: Buffer }
/** Bun.spawnSync: never throws for a nonzero exit or a timeout, throws when the executable does not exist. */
export function spawnSync(argv: string[], o: SpawnSyncOptions = {}): SyncResult {
const cmd = checkExecutable(argv[0], o);
const input = typeof o.stdin === 'string' && !['pipe', 'inherit', 'ignore'].includes(o.stdin) ? o.stdin : o.stdin instanceof Uint8Array ? o.stdin : undefined;
const stdinMode: Io = input !== undefined ? 'pipe' : io(o.stdin as Io | null | undefined, 'ignore');
const r = nodeSpawnSync(cmd, argv.slice(1), {
cwd: o.cwd, env: envOf(o.env) ?? process.env, input, windowsHide: true,
stdio: [stdinMode, io(o.stdout, 'pipe'), io(o.stderr, 'pipe')], maxBuffer: 1 << 30,
...(o.timeout ? { timeout: o.timeout, killSignal: 'SIGTERM' as const } : {}),
});
const err = r.error as (Error & { code?: string }) | undefined;
if (err && err.code === 'ENOENT') throw notFound(argv[0]!);
if (err && err.code !== 'ETIMEDOUT' && r.status === null && r.signal === null) throw err;
const buf = (b: Buffer | string | null | undefined): Buffer => (b == null ? Buffer.alloc(0) : typeof b === 'string' ? Buffer.from(b) : b);
return { exitCode: r.status, signalCode: r.signal, success: r.status === 0, stdout: buf(r.stdout), stderr: buf(r.stderr) };
}
// ---- stdin ----
/** This process's stdin as a web stream (Bun.stdin.stream()). */
export const stdinStream = (): ReadableStream<Uint8Array> => webStream(process.stdin);
/** All of stdin as text (Bun.stdin.text()). */
export const stdinText = (): Promise<string> => new Response(stdinStream()).text();
/** Streams a readLine saw end: a new interface on them would never emit close. */
const endedInputs = new WeakSet<object>();
/**
* One line from stdin, without the line break; null at EOF (so a [Y/n] prompt never reads a closed stdin as Enter). For prompts on Node and Bun (`for await (const line of
* console)` is Bun-only: on Node it throws "console is not async iterable"). The interface is closed after the line
* and stdin is paused and unref'd, so no handle stays open (an open stdin handle tripped a libuv assertion on Windows
* at exit).
*/
export function readLine(input: NodeJS.ReadableStream & { ref?: () => void; unref?: () => void; pause?: () => void } = process.stdin): Promise<string | null> {
if ((input as { readableEnded?: boolean }).readableEnded || endedInputs.has(input)) return Promise.resolve(null);
return new Promise(res => {
input.ref?.(); // an earlier readLine unref'd it
const rl = createInterface({ input, terminal: false, crlfDelay: Infinity });
let done = false;
const finish = (line: string | null) => {
if (done) return;
done = true;
rl.close();
input.pause?.();
input.unref?.();
res(line === null ? null : line.replace(/\r$/, ''));
};
rl.once('line', finish);
rl.once('close', () => { if (!done) endedInputs.add(input); finish(null); });
});
}
// ---- a small HTTP server (Bun.serve) ----
export interface Server { readonly port: number; readonly hostname: string; stop(closeActive?: boolean): void }
/**
* Bun.serve on node:http: `fetch(Request) => Response` for local pages (GET and HEAD; the body is read whole).
* Resolves once listening; rejects when the port is taken.
*/
export function serve(o: { port?: number; hostname?: string; fetch(req: Request): Response | Promise<Response> }): Promise<Server> {
const hostname = o.hostname ?? '127.0.0.1';
const server = createServer((req, res) => {
void (async () => {
try {
const headers = new Headers();
for (const [k, v] of Object.entries(req.headers)) if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : v);
const r = await o.fetch(new Request(`http://${req.headers.host ?? `${hostname}:${port}`}${req.url ?? '/'}`, { method: req.method ?? 'GET', headers }));
const body = req.method === 'HEAD' ? null : Buffer.from(await r.arrayBuffer());
const h: Record<string, string> = {};
r.headers.forEach((v, k) => { h[k] = v; });
if (body) h['content-length'] = String(body.length);
res.writeHead(r.status, h);
res.end(body ?? undefined);
} catch (e) {
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
res.end(String((e as Error).message ?? e));
}
})();
});
let port = o.port ?? 0;
return new Promise((res, rej) => {
server.once('error', rej);
server.listen(port, hostname, () => {
server.off('error', rej);
const a = server.address();
if (a && typeof a === 'object') port = a.port;
res({
port, hostname,
stop(closeActive = false) { if (closeActive) server.closeAllConnections?.(); server.close(); },
});
});
});
}
/** A file as a Response body, or null when it does not exist (Bun.file + exists()). */
export function fileResponse(path: string, headers: Record<string, string> = {}): Response | null {
try {
if (!statSync(path).isFile()) return null;
return new Response(readFileSync(path), { headers });
} catch { return null; }
}
// ---- globs (Bun.Glob) ----
/** A glob pattern: `*` and `?` within one path segment, `**` across segments, `[abc]`, `{a,b}`. */
export class Glob {
readonly re: RegExp;
constructor(readonly pattern: string) { this.re = globRegExp(pattern); }
match(path: string): boolean { return this.re.test(path.replace(/\\/g, '/')); }
/** The matching paths under `cwd`, relative with forward slashes; dot entries only with `dot`; symlinked folders are not entered. */
*scanSync(o: { cwd?: string; onlyFiles?: boolean; dot?: boolean } = {}): Generator<string> {
const cwd = o.cwd ?? process.cwd();
const onlyFiles = o.onlyFiles ?? true;
const segs = this.pattern.replace(/\\/g, '/').split('/');
const deep = segs.includes('**') || segs.some(s => s.includes('**'));
// the literal leading segments: no need to walk anything above them
let fixed = 0;
while (fixed < segs.length - 1 && !/[*?[{]/.test(segs[fixed]!)) fixed++;
const prefix = segs.slice(0, fixed).join('/');
const maxDepth = deep ? Infinity : segs.length - fixed;
const self = this;
function* walk(rel: string, depth: number): Generator<string> {
let entries;
try { entries = readdirSync(join(cwd, rel), { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (!o.dot && e.name.startsWith('.')) continue;
const p = rel ? `${rel}/${e.name}` : e.name;
const dir = e.isDirectory();
if ((!onlyFiles || !dir) && self.re.test(p)) yield p;
if (dir && depth + 1 < maxDepth) yield* walk(p, depth + 1);
}
}
if (prefix && !existsSync(join(cwd, prefix))) return;
yield* walk(prefix, 0);
}
}
export function globRegExp(pattern: string): RegExp {
const p = pattern.replace(/\\/g, '/');
let re = '';
let braces = 0;
for (let i = 0; i < p.length; i++) {
const c = p[i]!;
if (c === '*') {
if (p[i + 1] === '*') {
const atStart = i === 0 || p[i - 1] === '/';
i++;
if (atStart && p[i + 1] === '/') { re += '(?:[^/]*/)*'; i++; }
else re += '.*';
} else re += '[^/]*';
} else if (c === '?') re += '[^/]';
else if (c === '[') {
const end = p.indexOf(']', i + 1);
if (end < 0) { re += '\\['; continue; }
let body = p.slice(i + 1, end);
if (body.startsWith('!')) body = '^' + body.slice(1);
re += `[${body.replace(/\\/g, '\\\\')}]`;
i = end;
} else if (c === '{') { braces++; re += '(?:'; }
else if (c === '}' && braces) { braces--; re += ')'; }
else if (c === ',' && braces) re += '|';
else re += c.replace(/[.+^$()|\\]/g, '\\$&');
}
return new RegExp(`^${re}$`);
}
// ---- SQLite (bun:sqlite under Bun, node:sqlite on Node 22.5+) ----
export interface SqliteDb {
all<T>(sql: string, ...params: unknown[]): T[];
get<T>(sql: string, ...params: unknown[]): T | undefined;
run(sql: string, ...params: unknown[]): void;
exec(sql: string): void;
close(): void;
}
/** Opens a SQLite file; throws when this runtime has no SQLite (Node before 22.5). */
export async function openSqlite(path: string): Promise<SqliteDb> {
const spec = isBun ? 'bun:sqlite' : 'node:sqlite';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(spec);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db: any = isBun ? new mod.Database(path) : new mod.DatabaseSync(path);
return {
all: (sql, ...p) => db.prepare(sql).all(...p),
get: (sql, ...p) => db.prepare(sql).get(...p) ?? undefined,
run: (sql, ...p) => { db.prepare(sql).run(...p); },
exec: sql => { db.exec(sql); },
close: () => db.close(),
};
}