// 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 => 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 => 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 { 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(f: () => Promise): Promise { 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(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; stdin?: Io | null; stdout?: Io | null; stderr?: Io | null; /** [stdin, stdout, stderr]; wins over the single options. */ stdio?: [Io | null, Io | null, Io | null]; } /** 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; readonly stderr: ReadableStream; /** The exit code; 128 + the signal number when a signal ended it. Never rejects. */ readonly exited: Promise; 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 { 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 | 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 { return new ReadableStream({ 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)[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 }); let exitCode: number | null = null, signalCode: string | null = null; const exited = new Promise(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; /** 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 => webStream(process.stdin); /** All of stdin as text (Bun.stdin.text()). */ export const stdinText = (): Promise => new Response(stdinStream()).text(); /** Streams a readLine saw end: a new interface on them would never emit close. */ const endedInputs = new WeakSet(); /** * 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 { 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 }): Promise { 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 = {}; 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 = {}): 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 { 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 { 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(sql: string, ...params: unknown[]): T[]; get(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 { 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(), }; }