496 lines
23 KiB
TypeScript
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(),
|
|
};
|
|
}
|