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
JavaScript
// 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);
}