UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

339 lines (338 loc) • 14.4 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Runtime boundary for non-SQLite host APIs. * * Single source of truth for every `Bun.*` API (and `import.meta.dir`) used * outside the SQLite layer. The rest of the codebase imports these named * helpers and NEVER touches `Bun.*` directly, so the application code is * runtime-agnostic and the Node path is purely additive. * * Runtime selection: a single `isBun` flag (computed once at module load) * branches each helper. On Bun (the primary/test runtime) the helpers delegate * to the native `Bun.*` API so behaviour stays byte-identical; on Node they use * the `node:*` equivalents. The Node-only deps (`semver`) are loaded lazily so * Bun never resolves them. * * This is intentionally NOT an adapter/DI/ports-and-adapters layer — just a * plain module of named function exports plus a couple of structural types. * * @module runtime */ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { createWriteStream, statfsSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; import { ResponseTooLargeError, readChunkWithDeadline } from "./core/common.js"; /** True when running under the Bun runtime. Computed once at module load. */ const isBun = !!process.versions?.bun; /** A CommonJS-style require usable from this ESM module on both runtimes. */ const nodeRequire = createRequire(import.meta.url); // ── Subprocess ────────────────────────────────────────────────────────────── /** * Spawn a child process asynchronously. On Bun delegates to `Bun.spawn` (the * returned native `Subprocess` is structurally identical to {@link Subprocess}); * on Node wraps `child_process.spawn` to expose the same Web-stream surface. */ export function spawn(cmd, options = {}) { if (isBun) { return bunGlobal().spawn(cmd, options); } return nodeSpawnAdapter(cmd, options); } /** * Spawn a child process synchronously, returning a Bun-shaped result (note * `success` and the Buffer `stdout`/`stderr`). On Node `child_process.spawnSync` * is normalised to that shape. */ export function spawnSync(cmd, options = {}) { if (isBun) { const r = bunGlobal().spawnSync(cmd, options); return { success: r.success, exitCode: r.exitCode ?? null, stdout: toBuffer(r.stdout), stderr: toBuffer(r.stderr), }; } const [bin, ...args] = cmd; const r = nodeSpawnSync(bin, args, { cwd: options.cwd, env: options.env ?? process.env }); const exitCode = r.status; return { success: r.error === undefined && exitCode === 0, exitCode: exitCode ?? null, stdout: toBuffer(r.stdout), stderr: toBuffer(r.stderr), }; } function nodeSpawnAdapter(cmd, options) { const [bin, ...args] = cmd; const child = nodeSpawn(bin, args, { cwd: options.cwd, env: options.env ?? process.env, detached: options.detached, stdio: [stdioFor(options.stdin), stdioFor(options.stdout), stdioFor(options.stderr)], windowsVerbatimArguments: options.windowsVerbatimArguments, }); // Node's 'exit' fires (null, signal) when the child dies from a signal. // Resolving `code ?? 0` reported that as SUCCESS, so an OOM-killed or // segfaulted child came back exit 0 with partial stdout. Bun resolves // 128 + signum for the same case; match it so both runtimes agree and // callers that only check `exitCode !== 0` classify signal deaths correctly. let signalCode = null; const exited = new Promise((resolve, reject) => { child.once("exit", (code, signal) => { signalCode = signal; resolve(code ?? (signal ? 128 + signalNumber(signal) : 0)); }); child.once("error", reject); }); return { get stdout() { return child.stdout ? Readable.toWeb(child.stdout) : null; }, get stderr() { return child.stderr ? Readable.toWeb(child.stderr) : null; }, get stdin() { return child.stdin ? Writable_toWeb(child.stdin) : null; }, exited, get exitCode() { return child.exitCode; }, get signalCode() { return signalCode ?? child.signalCode; }, pid: child.pid, kill(signal) { child.kill(signal); }, }; } /** * Map a signal name to its number so a signal death can be reported as the * conventional 128 + signum exit status. Falls back to SIGKILL's 9 for a name * this platform does not define, which still yields a non-zero status — the * property that matters for classifying the run as failed. */ function signalNumber(signal) { const { constants } = nodeRequire("node:os"); return constants.signals[signal] ?? 9; } // `node:stream`'s Writable.toWeb is available on Node >=17; referenced via the // class to avoid a static import that Bun's typings may not expose. function Writable_toWeb(w) { const { Writable } = nodeRequire("node:stream"); return Writable.toWeb(w); } function stdioFor(mode) { return mode ?? "pipe"; } // ── Stdin ─────────────────────────────────────────────────────────────────── /** Read all of stdin as a single `Buffer`. */ export async function readStdin() { const chunks = []; for await (const chunk of stdinIterator()) { chunks.push(chunk); } return Buffer.concat(chunks); } function stdinIterator() { if (isBun) { return bunGlobal().stdin.stream(); } // process.stdin is an async iterable of Buffer on Node. return process.stdin; } // ── File write ────────────────────────────────────────────────────────────── /** * Defensive ceiling on a streamed-to-disk download (1 GiB). */ export const MAX_STREAMED_DOWNLOAD_BYTES = 1024 * 1024 * 1024; /** Default overall wall-clock budget for streaming a download body (5 min). */ export const STREAMED_DOWNLOAD_BODY_TIMEOUT_MS = 5 * 60_000; function writeStreamChunk(out, chunk) { return new Promise((resolve, reject) => { out.write(chunk, (err) => (err ? reject(err) : resolve())); }); } function endWriteStream(out) { return new Promise((resolve, reject) => { out.once("error", reject); out.end(() => resolve()); }); } /** * Stream a `Response` body to `filePath` enforcing an explicit max-bytes cap * and an overall body-read deadline. * * Refuses before reading when `Content-Length` exceeds the cap; otherwise * streams chunk-by-chunk, aborting with {@link ResponseTooLargeError} the * instant BYTES WRITTEN exceed the cap, or {@link BodyReadTimeoutError} when the * body-read deadline passes. Uses the same runtime-agnostic reader loop on Bun * and Node (correctness over the native `Bun.write` fast path, which offers no * per-byte hook). On any failure the partial file is discarded and the stream * cancelled. */ export async function writeResponseToFileCapped(filePath, res, options) { const maxBytes = options?.maxBytes ?? MAX_STREAMED_DOWNLOAD_BYTES; const bodyTimeoutMs = options?.bodyTimeoutMs ?? STREAMED_DOWNLOAD_BODY_TIMEOUT_MS; const url = res.url || filePath; const declared = Number(res.headers.get("content-length")); if (Number.isFinite(declared) && declared > maxBytes) { await res.body?.cancel?.().catch(() => undefined); throw new ResponseTooLargeError(url, maxBytes, declared); } const body = res.body; if (!body) { const buf = Buffer.from(await res.arrayBuffer()); if (buf.byteLength > maxBytes) throw new ResponseTooLargeError(url, maxBytes, buf.byteLength); await pipeline(Readable.from(buf), createWriteStream(filePath)); return; } const reader = body.getReader(); const out = createWriteStream(filePath); const deadlineAt = Date.now() + bodyTimeoutMs; let total = 0; try { while (true) { const { done, value } = await readChunkWithDeadline(reader, deadlineAt, options?.signal, url, bodyTimeoutMs); if (done) break; if (!value) continue; total += value.byteLength; if (total > maxBytes) throw new ResponseTooLargeError(url, maxBytes, total); await writeStreamChunk(out, value); } await endWriteStream(out); } catch (err) { out.destroy(); await reader.cancel().catch(() => undefined); throw err; } finally { reader.releaseLock?.(); } } // ── Hashing ───────────────────────────────────────────────────────────────── /** * Hex SHA-256 of `data`. Uses `node:crypto` on both runtimes (available in Bun * and Node), which is simpler than `Bun.CryptoHasher` and produces identical * digests. */ export function sha256Hex(data) { return createHash("sha256").update(data).digest("hex"); } /** Hex MD5 of `data`. Uses `node:crypto` on both runtimes. */ export function md5Hex(data) { return createHash("md5").update(data).digest("hex"); } // ── Semver ────────────────────────────────────────────────────────────────── /** * Compare two semver strings, returning -1, 0, or 1 (the ordering contract of * `Bun.semver.order`). On Bun uses the built-in; on Node uses the `semver` * package's `compare`, loaded lazily. */ export function semverOrder(a, b) { if (isBun) { return bunGlobal().semver.order(a, b); } const semver = nodeRequire("semver"); return semver.compare(a, b); } // ── Filesystem paths ──────────────────────────────────────────────────────── /** * The directory of the module identified by `importMetaUrl`. Replaces * `import.meta.dir` (Bun-only). Uses `fileURLToPath` + `dirname` on both * runtimes — on Bun this is byte-identical to `import.meta.dir`. */ export function getDirname(importMetaUrl) { return path.dirname(fileURLToPath(importMetaUrl)); } // ── Filesystem type probe ───────────────────────────────────────────────────── /** * Best-effort filesystem-type probe: return the numeric `f_type` magic of the * filesystem backing `path`, or `undefined` on any error (ENOENT/EPERM/ * unsupported). Used (via injection) by the SQLite journal-mode network-FS * fallback (#628). * * This is a runtime primitive (a `statfs` filesystem-type probe) and therefore * lives here, per the runtime boundary, even though it uses only `node:fs`. The * "magic-number → is-network" classification stays a pure helper elsewhere. * * `fs.statfsSync` is a stable `node:fs` API (Node ≥ 18.15) and is implemented * under Bun as well. It THROWS ENOENT on a non-existent path, so callers should * probe an already-created directory — the swallowed error simply yields * `undefined` (treated as "not network"). */ export function statfsType(path) { try { return statfsSync(path).type; } catch { return undefined; } } // ── Sleep ─────────────────────────────────────────────────────────────────── /** * Block the current thread for `ms` without busy-spinning. On Bun uses the real * blocking `Bun.sleepSync`; on Node uses `Atomics.wait` on a throwaway buffer, * which yields the thread to the OS scheduler the same way. */ export function sleepSync(ms) { if (isBun) { bunGlobal().sleepSync(ms); return; } Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } /** * Request a synchronous garbage collection. On Bun runs `Bun.gc(true)`; on * Node a no-op (no portable forced-GC without --expose-gc). Used by the * migrate-apply WAL conversion to finalize any zombie-closed bun:sqlite * connection (statements not finalized before close keep the connection and * its WAL shared memory alive until GC — issue #720) before concluding that * ANOTHER process holds the database. */ export function requestGc() { if (isBun) { bunGlobal().gc(true); } } /** Async sleep for `ms`. On Bun uses `Bun.sleep`; on Node a `setTimeout` promise. */ export function sleep(ms) { if (isBun) { return bunGlobal().sleep(ms); } return new Promise((resolve) => setTimeout(resolve, ms)); } // ── Main entrypoint ───────────────────────────────────────────────────────── /** * Path of the script that started the process. On Bun this is `Bun.main` * (which, for compiled binaries, points at a virtual `/$bunfs/` path rather * than `process.execPath`); on Node it is `process.argv[1]`. */ export const mainPath = isBun ? bunGlobal().main : process.argv[1]; /** Access the `Bun` global. Only ever called when `isBun` is true. */ function bunGlobal() { return globalThis.Bun; } function toBuffer(data) { if (data == null) return Buffer.alloc(0); if (Buffer.isBuffer(data)) return data; if (typeof data === "string") return Buffer.from(data, "utf8"); return Buffer.from(data); }