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

995 lines (994 loc) • 40.8 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/. import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { migrateLegacySourceShape } from "./config/legacy-source-shape-shim.js"; import { ConfigError } from "./errors.js"; import { getConfigPath, getDefaultStashDir, getRegistryCacheDir, getRegistryIndexCacheDir } from "./paths.js"; // ── Constants ─────────────────────────────────────────────────────────────── // Moved to the platform leaf so paths.ts can use it without a common↔paths // cycle (chunk-8 WI-8.6, DoD 11); re-exported here for the existing surface. export { IS_WINDOWS } from "./platform.js"; export const MAX_LOCK_METADATA_BYTES = 64 * 1024; export function isHttpUrl(value) { return !!value && /^https?:\/\//.test(value); } /** * Returns `true` when `value` looks like a remote URL that a VCS or HTTP * fetch can access. Covers http/https, git@, ssh://, and git:// schemes. * Consolidates the repeated inline URL-detection pattern in source-manage.ts. */ export function isRemoteUrl(value) { if (!value) return false; return (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("git@") || value.startsWith("ssh://") || value.startsWith("git://")); } // ── Utilities ─────────────────────────────────────────────────────────────── export function readTextFileDescriptorWithLimit(fd, maxBytes, label = "File", displayPath = "(open file)") { const stat = fs.fstatSync(fd); if (!stat.isFile()) throw new ConfigError(`${label} is not a regular file: ${displayPath}.`, "INVALID_CONFIG_FILE"); if (stat.size > maxBytes) { throw new ConfigError(`${label} exceeds the ${maxBytes}-byte limit: ${displayPath}.`, "INVALID_CONFIG_FILE"); } const buffer = Buffer.allocUnsafe(maxBytes + 1); let total = 0; while (total <= maxBytes) { const bytesRead = fs.readSync(fd, buffer, total, maxBytes + 1 - total, null); if (bytesRead === 0) break; total += bytesRead; } if (total > maxBytes) { throw new ConfigError(`${label} exceeds the ${maxBytes}-byte limit: ${displayPath}.`, "INVALID_CONFIG_FILE"); } return buffer.subarray(0, total).toString("utf8"); } export function readTextFile(filePath, label = "File") { const fd = fs.openSync(filePath, "r"); try { const stat = fs.fstatSync(fd); if (!stat.isFile()) throw new ConfigError(`${label} is not a regular file: ${filePath}.`, "INVALID_CONFIG_FILE"); return fs.readFileSync(fd, "utf8"); } finally { fs.closeSync(fd); } } /** * Write content to a file atomically via a temp file + rename. * Prevents partial-write corruption on crash. * The temp file is opened with the target `mode` (default 0o600) from the * start, so it is never world-readable even briefly. * * `content` may be a string or a `Buffer`. Buffer callers (e.g. secrets, where * binary certs and CRLF/LF endings must round-trip byte-exact) get the same * fsync'd temp-file-plus-rename guarantees as string callers — there is a * single atomic-write implementation. * * Durability: fsync'd against the May 2026 config-clobber incident (#472). * On ext4 (data=ordered) and NVMe-with-TRIM, a power-loss inside the kernel * writeback window could leave the renamed file truncated to zero — defeating * the purpose of the atomic rename. We: * 1. fdatasync the temp fd before close, so the data is on disk before the * rename observes it. * 2. fsync the parent directory after rename, so the directory entry change * is durable too. Some filesystems (FAT, certain FUSE mounts) don't * support directory fsync; we ignore EINVAL/ENOTSUP so atomic writes * don't fail on those mounts. Windows does not support opening a * directory for fsync, so the directory-sync step is skipped there. */ /** * Strip JavaScript-style comments from a JSON string (JSONC support). * Handles `//` line comments and `/* *​/` block comments while preserving * comment-like sequences inside quoted strings. */ export function stripJsonComments(text) { let result = ""; let i = 0; let inString = false; while (i < text.length) { if (inString) { if (text[i] === "\\") { result += text[i] + (text[i + 1] ?? ""); i += 2; continue; } if (text[i] === '"') { inString = false; } result += text[i]; i++; continue; } if (text[i] === '"') { inString = true; result += text[i]; i++; continue; } if (text[i] === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; continue; } if (text[i] === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i += 2; continue; } result += text[i]; i++; } return result; } /** * The mode to rewrite an existing USER-owned file with. * * {@link writeFileAtomic} creates its temp file with an explicit mode and * chmods it, so it needs one — and its 0600 default is right for akm's own * state but wrong for a user's asset, where rewriting must never change * permissions. Returns the file's current mode, or the umask-derived default * `fs.writeFileSync` would have produced for a new file. */ export function existingFileMode(filePath) { try { return fs.statSync(filePath).mode & 0o777; } catch { // Absent (a new asset) or unreadable: fall back to the default create mode. try { return 0o666 & ~process.umask(); } catch { return 0o644; } } } export function writeFileAtomic(target, content, mode) { const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`; const data = typeof content === "string" ? Buffer.from(content) : content; const fileMode = mode ?? 0o600; let fd; let tempOwned = false; let renamed = false; let failed = false; let failure; try { fd = fs.openSync(tmp, "wx", fileMode); tempOwned = true; if (process.platform !== "win32") fs.fchmodSync(fd, fileMode); let offset = 0; while (offset < data.byteLength) { const written = fs.writeSync(fd, data, offset, data.byteLength - offset); if (written <= 0) throw new Error(`Could not make progress writing atomic temp file ${tmp}.`); offset += written; } fs.fdatasyncSync(fd); fs.closeSync(fd); fd = undefined; fs.renameSync(tmp, target); renamed = true; } catch (error) { failed = true; failure = error; } finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch (error) { if (!failed) { failed = true; failure = error; } } } if (tempOwned && !renamed) { try { fs.unlinkSync(tmp); } catch (error) { if (!failed && !hasErrnoCode(error, "ENOENT")) { failed = true; failure = error; } } } } if (failed) throw failure; if (process.platform !== "win32") { let dirFd; try { dirFd = fs.openSync(path.dirname(target), "r"); } catch (error) { if (hasErrnoCode(error, "EINVAL") || hasErrnoCode(error, "ENOTSUP")) return; throw error; } try { try { fs.fsyncSync(dirFd); } catch (error) { if (!hasErrnoCode(error, "EINVAL") && !hasErrnoCode(error, "ENOTSUP")) throw error; } } finally { fs.closeSync(dirFd); } } } /** * Resolve the stash directory using a three-level fallback chain: * 1. AKM_BUNDLE_DIR environment variable (override for CI/scripts) * 2. The configured default bundle path * 3. Platform default (~/akm or ~/Documents/akm on Windows) * * Throws if no valid stash directory is found. */ export function resolveStashDir(env = process.env) { // 1. Env var override (for CI, scripts, testing) const envDir = env.AKM_BUNDLE_DIR?.trim(); if (envDir) { return validateStashDir(envDir); } // 2. Configured default bundle path const configStashDir = readStashDirFromConfig(); if (configStashDir) return validateStashDir(configStashDir); // 3. Platform default — use it if it exists const defaultDir = getDefaultStashDir(env); if (isValidDirectory(defaultDir)) { return defaultDir; } throw new ConfigError(`No bundle directory found. Run "akm bundle create" to create one at ${defaultDir}.`, "STASH_DIR_NOT_FOUND"); } function validateStashDir(raw) { const stashDir = path.resolve(raw); let stat; try { stat = fs.statSync(stashDir); } catch { throw new ConfigError(`Unable to read bundle directory at "${stashDir}".`, "STASH_DIR_UNREADABLE"); } if (!stat.isDirectory()) { throw new ConfigError(`Bundle path must point to a directory: "${stashDir}".`, "STASH_DIR_NOT_A_DIRECTORY"); } return stashDir; } function isValidDirectory(dir) { try { return fs.statSync(dir).isDirectory(); } catch (error) { // Genuinely absent — the caller's "not found" fallback applies. Any other // stat failure (e.g. EACCES) is not "doesn't exist"; treating it as such // produced the wrong message ("Run akm bundle create") for a directory // that exists but cannot be read. if (hasErrnoCode(error, "ENOENT")) return false; throw new ConfigError(`Unable to read bundle directory at "${dir}".`, "STASH_DIR_UNREADABLE"); } } /** * Read the primary stash path directly from config.json without pulling in the * full config module, to avoid circular dependencies. * * Reads only the current `bundles`/`defaultBundle` shape. Unsupported older * keys are never interpreted as a second config architecture. */ function readStashDirFromConfig() { try { const configPath = getConfigPath(); const text = readTextFile(configPath, "Config file"); // The config loader accepts JSONC, so a commented config.json is valid and // in use. Parsing it raw here threw, the catch swallowed it, and every // caller silently fell back — operating on the wrong bundle or failing with // STASH_DIR_NOT_FOUND despite a perfectly good config. const parsed = JSON.parse(stripJsonComments(text)); if (typeof parsed !== "object" || parsed === null) return undefined; const raw = migrateLegacySourceShape(parsed, configPath); // 0.9.0 config-shape cutover (spec §10.1): the primary stash is the // `defaultBundle`'s filesystem `path`. Read it directly (no config module // import) so the primary-stash location survives the stashDir → bundles // migration without a runtime rewire. const bundles = raw.bundles; const defaultBundle = raw.defaultBundle; const selectedBundle = typeof defaultBundle === "string" ? bundles?.[defaultBundle] : undefined; const selectedBundlePath = typeof selectedBundle?.path === "string" ? selectedBundle.path : undefined; if (bundles && typeof bundles === "object" && typeof defaultBundle === "string" && selectedBundlePath?.trim()) { const bundle = selectedBundle; const bundlePath = bundle.path.trim(); if (bundle.components !== undefined) { if (typeof bundle.components !== "object" || bundle.components === null) { throw new ConfigError("A bundle components map must contain exactly one component.", "INVALID_CONFIG_FILE"); } const components = Object.values(bundle.components); if (components.length !== 1) { throw new ConfigError("A bundle components map must contain exactly one component.", "INVALID_CONFIG_FILE"); } const component = components[0]; if (typeof component === "object" && component !== null) { const componentConfig = component; if (typeof componentConfig.root !== "string") return bundlePath; const bundleRoot = path.resolve(bundlePath); const componentRoot = path.resolve(bundleRoot, componentConfig.root); if (!isWithin(componentRoot, bundleRoot)) { throw new ConfigError(`Component root "${componentConfig.root}" escapes bundle "${defaultBundle}".`, "INVALID_CONFIG_FILE"); } return componentRoot; } } return bundlePath; } } catch (err) { // An unsupported-shape refusal must reach the caller; genuine missing/invalid // config (read or JSON-parse failure) falls through to the platform default. if (err instanceof ConfigError) throw err; } return undefined; } export function toPosix(input) { return input.replace(/\\/g, "/"); } /** Locale-independent code-point ordering — a stable `Array.prototype.sort` comparator for strings (paths, names, ids). */ export function compareCodePoints(left, right) { return left < right ? -1 : left > right ? 1 : 0; } export function hasErrnoCode(error, code) { if (typeof error !== "object" || error === null || !("code" in error)) return false; return error.code === code; } /** * True when `value` is a RELATIVE path that cannot leave its base directory: * no absolute form (POSIX `/`, Windows `\` or a `C:` drive prefix), no `~` * home expansion, and no `..` segment under either separator. * * This is the SYNTACTIC half of containment — cheap, string-only, usable at * authoring time before any directory exists. It is deliberately paired with * (never a substitute for) {@link isWithin}, which resolves symlinks against a * real base at use time. Workflow `exec` units run both: the parser and the * frozen-plan decoder reject uncontained spellings, and the executor re-checks * the resolved path before spawning. */ export function isContainedRelativePath(value) { if (value === "" || value.startsWith("/") || value.startsWith("\\") || value.startsWith("~")) return false; if (/^[A-Za-z]:/.test(value)) return false; return !value.split(/[/\\]+/).includes(".."); } export function isWithin(candidate, root) { return isContainedResolvedPath(safeRealpath(candidate), safeRealpath(root)); } /** * True when `filePath` sits inside akm's OWN resolved registry-cache * directories (`<cache>/registry`, `<cache>/registry-index` — * {@link getRegistryCacheDir}/{@link getRegistryIndexCacheDir}), the * read-only installed-source/registry-index copies that lint (and `--fix`) * must never touch. * * This is the single source of truth for that exclusion — it replaces what * used to be three independent unanchored substring checks * (`posixPath.includes("/.cache/") || posixPath.includes("/registry/")`). * That check matched ANY path merely containing the literal text `.cache` or * `registry` anywhere in it — a normal XDG `~/.cache/...` user bundle, or a * CI workspace checked out under a `.cache`-named directory, tripped it and * silently got zero lint findings. Using {@link isWithin} (realpath + * containment, not a string search) fixes that while still excluding the * real cache content the check was meant to skip. */ export function isAkmRegistryCachePath(filePath) { return isWithin(filePath, getRegistryCacheDir()) || isWithin(filePath, getRegistryIndexCacheDir()); } /** * {@link isWithin} for callers that must not block the event loop (e.g. the * workflow exec dispatch path, which runs once per fan-out unit). Same * comparison, same normalization, same nearest-existing-ancestor fallback — * only the realpath syscalls are awaited. */ export async function isWithinAsync(candidate, root) { return isContainedResolvedPath(await safeRealpathAsync(candidate), await safeRealpathAsync(root)); } /** The containment comparison shared by {@link isWithin} and {@link isWithinAsync}. */ function isContainedResolvedPath(resolvedCandidate, resolvedRoot) { const rel = path.relative(normalizeFsPathForComparison(resolvedRoot), normalizeFsPathForComparison(resolvedCandidate)); if (rel === "") return true; if (path.isAbsolute(rel)) return false; // Compare the first SEGMENT, not a string prefix: `..data` and `...v2` are // legal directory names, and only a leading `..` segment means the candidate // climbed out of the root. Both separators, because `path.relative` answers // in the host's spelling while callers may hold either. return rel.split(/[/\\]+/)[0] !== ".."; } /** * Resolve symlinks on `p`, walking up to the closest existing ancestor when * `p` itself does not exist. This ensures that comparisons between an * existing directory and a not-yet-created child path inside it are * consistent even when the directory hierarchy contains symlinks (e.g. * macOS /tmp → /private/tmp, or a HOME that is itself a symlink). */ export function safeRealpath(p) { const resolved = path.resolve(p); try { return fs.realpathSync(resolved); } catch { // Path doesn't exist — resolve symlinks on the nearest existing ancestor // and reconstruct the full path from there. const suffix = []; let current = resolved; for (;;) { const parent = path.dirname(current); if (parent === current) { // Reached filesystem root without finding an existing entry. return resolved; } suffix.unshift(path.basename(current)); current = parent; try { const realParent = fs.realpathSync(current); return path.join(realParent, ...suffix); } catch { // parent also doesn't exist; keep walking up } } } } /** {@link safeRealpath}'s async twin — awaited syscalls, identical walk-up. */ export async function safeRealpathAsync(p) { const resolved = path.resolve(p); try { return await fs.promises.realpath(resolved); } catch { const suffix = []; let current = resolved; for (;;) { const parent = path.dirname(current); if (parent === current) return resolved; suffix.unshift(path.basename(current)); current = parent; try { return path.join(await fs.promises.realpath(current), ...suffix); } catch { // parent also doesn't exist; keep walking up } } } } function normalizeFsPathForComparison(value) { return process.platform === "win32" ? value.toLowerCase() : value; } /** * Fetch with an AbortController timeout. * Defaults to 30 seconds if no timeout is specified. * * SCOPE — connection + response-header phase only. `timeoutMs` bounds the time * until `fetch` RESOLVES (i.e. until the status line + headers arrive); the * timer is cleared once the `Response` is returned. It does NOT bound the time * spent streaming the response BODY: a server can dribble body bytes forever * under any per-byte limit. Callers that read the body MUST bound it * themselves — pass `{ bodyTimeoutMs, signal }` to {@link readBodyWithByteCap} * (in-memory reads) or use a capped streaming writer for downloads. That is the * sanctioned "body-deadline mechanism"; a bounded header timeout here plus a * bounded body read there gives a bounded TOTAL window. * * External `signal`: a caller-supplied `AbortSignal` aborts the in-flight * request with the caller's own `reason`. The bridged listener is removed in * `finally`, so once this function returns the caller's signal no longer * governs the returned `Response`'s body stream — pass the SAME `signal` to the * body-read helper so cancellation continues to apply to the body phase with * the caller's reason. (A timeout and an external abort can never overwrite * each other: whichever fires first aborts the controller, and a second * `controller.abort()` is a no-op that preserves the first reason.) */ export async function fetchWithTimeout(url, opts, timeoutMs = 30_000, signal) { const controller = new AbortController(); const timer = timeoutMs === null ? undefined : setTimeout(() => controller.abort(), timeoutMs); const abortExternal = () => controller.abort(signal?.reason); if (signal) { if (signal.aborted) { if (timer) clearTimeout(timer); controller.abort(signal.reason); } else { signal.addEventListener("abort", abortExternal, { once: true }); } } try { if (controller.signal.aborted) throw controller.signal.reason ?? new Error(`Request aborted: ${url}`); return await fetch(url, { ...opts, signal: controller.signal }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { if (signal?.aborted) { throw new Error(`Request aborted: ${url}`); } throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`); } throw err; } finally { if (signal) signal.removeEventListener("abort", abortExternal); if (timer) clearTimeout(timer); } } /** * Cap on how long a retry loop will wait between attempts, even when a * server-supplied `Retry-After` claims a longer delay. Prevents an * attacker-controlled or misconfigured server from parking a caller * indefinitely. */ export const DEFAULT_RETRY_MAX_DELAY_MS = 30_000; export function shouldRetry(status) { return status === 429 || status >= 500; } /** Jittered exponential backoff, capped at `maxDelayMs`. */ export function backoffDelay(attempt, baseDelay = 500, maxDelayMs = DEFAULT_RETRY_MAX_DELAY_MS) { return Math.min(maxDelayMs, baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5)); } /** * Determine the delay before the next retry attempt. * * Honors a server-supplied `Retry-After` header in both its numeric-seconds * and HTTP-date forms, but always clamps the result to `maxDelayMs` — an * unclamped `Retry-After` lets an attacker/misconfigured server park a * caller for an arbitrarily long time. Falls back to jittered exponential * backoff when the header is absent or unparseable. */ export function computeRetryDelay(response, attempt, options) { const maxDelayMs = options?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS; const baseDelay = options?.baseDelay ?? 500; const retryAfter = response.headers.get("retry-after"); if (retryAfter) { const seconds = Number(retryAfter); if (Number.isFinite(seconds)) { return seconds >= 0 ? Math.min(maxDelayMs, seconds * 1_000) : backoffDelay(attempt, baseDelay, maxDelayMs); } const date = Date.parse(retryAfter); if (Number.isFinite(date)) return Math.min(maxDelayMs, Math.max(0, date - Date.now())); } return backoffDelay(attempt, baseDelay, maxDelayMs); } /** * Fetch with retry and exponential backoff. * Retries on network errors, 429, and 5xx responses. * Honors Retry-After header, capped at `maxDelayMs`. */ export async function fetchWithRetry(url, init, options) { const maxRetries = options?.retries ?? 3; const baseDelay = options?.baseDelay ?? 500; const maxDelayMs = options?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS; const timeout = options?.timeout ?? 30_000; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const response = await fetchWithTimeout(url, init, timeout, init?.signal ?? undefined); if (attempt < maxRetries && shouldRetry(response.status)) { const delay = computeRetryDelay(response, attempt, { baseDelay, maxDelayMs }); await response.body?.cancel().catch(() => undefined); await abortableDelay(delay, init?.signal); continue; } return response; } catch (err) { if (attempt >= maxRetries) throw err; // A caller-supplied abort is terminal: never keep retrying past it. if (init?.signal?.aborted) throw err; await abortableDelay(backoffDelay(attempt, baseDelay, maxDelayMs), init?.signal); } } throw new Error("fetchWithRetry: unreachable"); } /** * Sleep, but wake immediately if `signal` aborts. * * A server-supplied `Retry-After` is honored verbatim and can be arbitrarily * large. Sleeping it out with a bare `setTimeout` ignored the caller's abort * signal entirely, so a single `429` could park an operation far past any * deadline its caller believed it had imposed — the request timeout bounds * only the request, never the wait between attempts. Callers using this for * a retry delay should pass a `maxDelayMs`-capped `ms` (see * {@link computeRetryDelay}) to bound the wait itself. */ export function abortableDelay(ms, signal, abortMessage = "Aborted") { if (!signal) return new Promise((resolve) => setTimeout(resolve, ms)); if (signal.aborted) return Promise.reject(signal.reason ?? new Error(abortMessage)); return new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timer); reject(signal.reason ?? new Error(abortMessage)); }; const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); signal.addEventListener("abort", onAbort, { once: true }); }); } /** * Read stdin as UTF-8 text if something is piped in. Returns `undefined` * when stdin is a TTY (no pipe) or when the piped content is empty. */ export function tryReadStdinText() { if (process.stdin.isTTY) return undefined; const input = fs.readFileSync(0, "utf8"); return input.length > 0 ? input : undefined; } /** * Default byte cap for untrusted network responses (10 MB). * * Applies to website scraping, registry index fetches, and any other * response that is read into memory from a source the CLI does not fully * control. A compromised or malicious endpoint that streams an unbounded * response would otherwise exhaust RAM — this cap ensures the process * aborts with a clean error instead of crashing. */ export const DEFAULT_RESPONSE_BYTE_CAP = 10 * 1024 * 1024; /** * Thrown by {@link readBodyWithByteCap} and its helpers when a response * body exceeds the caller's byte cap. Callers can catch this specifically * to surface a targeted error to the user. */ export class ResponseTooLargeError extends Error { url; maxBytes; observedBytes; constructor(url, maxBytes, observedBytes) { const observed = observedBytes === null ? "unknown" : `${observedBytes} bytes`; super(`Response body exceeded ${maxBytes} bytes (observed: ${observed}): ${url}`); this.name = "ResponseTooLargeError"; this.url = url; this.maxBytes = maxBytes; this.observedBytes = observedBytes; } } /** * Thrown by {@link readBodyWithByteCap} / {@link readChunkWithDeadline} (and the * capped disk writer) when streaming a response body exceeds the caller's * overall body-read deadline. Distinct from a connection/header timeout * (`fetchWithTimeout`) — this is the body-phase bound. */ export class BodyReadTimeoutError extends Error { url; timeoutMs; constructor(url, timeoutMs) { super(`Response body read exceeded ${timeoutMs}ms: ${url}`); this.name = "BodyReadTimeoutError"; this.url = url; this.timeoutMs = timeoutMs; } } function bodyAbortError(signal, url) { const reason = signal?.reason; return reason instanceof Error ? reason : new Error(`Response body read aborted: ${url}`); } /** * Read one chunk from `reader`, rejecting if the overall body deadline passes * or `signal` aborts first. `deadlineAt` is an absolute epoch-ms instant (null * = no deadline). Only races the pending `read()` so a stalled body cannot * block forever; the CALLER cancels the reader on rejection. Throws * {@link BodyReadTimeoutError} on deadline, or the signal's reason (or an * AbortError) on external abort. */ export async function readChunkWithDeadline(reader, deadlineAt, signal, url, timeoutMs) { if (signal?.aborted) throw bodyAbortError(signal, url); const remaining = deadlineAt === null ? null : deadlineAt - Date.now(); if (remaining !== null && remaining <= 0) throw new BodyReadTimeoutError(url, timeoutMs); if (remaining === null && !signal) return reader.read(); return new Promise((resolve, reject) => { let timer; const cleanup = () => { if (timer) clearTimeout(timer); signal?.removeEventListener("abort", onAbort); }; const onAbort = () => { cleanup(); reject(bodyAbortError(signal, url)); }; if (remaining !== null) { timer = setTimeout(() => { cleanup(); reject(new BodyReadTimeoutError(url, timeoutMs)); }, remaining); } signal?.addEventListener("abort", onAbort, { once: true }); reader.read().then((result) => { cleanup(); resolve(result); }, (err) => { cleanup(); reject(err); }); }); } /** * Read a Response body as a UTF-8 string with a byte-count cap. * * Streams the body so we abort as soon as the cap is exceeded, without * buffering the full response first. If the server sent a * `Content-Length` larger than the cap, we refuse before reading any * bytes. `response.body` is consumed and cancelled on cap breach. * * `maxBytes` defaults to {@link DEFAULT_RESPONSE_BYTE_CAP} (10 MB). `limits` * bounds the body PHASE (duration + caller abort) that `fetchWithTimeout` does * not cover; pass the same `signal` you gave `fetchWithTimeout` so caller * cancellation keeps applying while the body streams. */ export async function readBodyWithByteCap(response, maxBytes = DEFAULT_RESPONSE_BYTE_CAP, limits) { const url = response.url || "(unknown URL)"; const contentLengthHeader = response.headers.get("content-length"); if (contentLengthHeader) { const declared = Number(contentLengthHeader); if (Number.isFinite(declared) && declared > maxBytes) { // Don't even start reading. await response.body?.cancel?.().catch(() => undefined); throw new ResponseTooLargeError(url, maxBytes, declared); } } const body = response.body; if (!body) { // No streaming body available (e.g., some mock environments). Fall // back to text() but still enforce the cap post-hoc. const text = await response.text(); const byteLength = Buffer.byteLength(text, "utf8"); if (byteLength > maxBytes) throw new ResponseTooLargeError(url, maxBytes, byteLength); return text; } const reader = body.getReader(); const chunks = []; let total = 0; const bodyTimeoutMs = limits?.bodyTimeoutMs; const deadlineAt = bodyTimeoutMs != null ? Date.now() + bodyTimeoutMs : null; try { while (true) { const { done, value } = await readChunkWithDeadline(reader, deadlineAt, limits?.signal, url, bodyTimeoutMs ?? 0); if (done) break; if (!value) continue; total += value.byteLength; if (total > maxBytes) throw new ResponseTooLargeError(url, maxBytes, total); chunks.push(value); } } catch (err) { // Cancel the underlying stream on ANY failure (cap breach, body-read // timeout, or caller abort) so the socket is released, not just on cap. await reader.cancel().catch(() => undefined); throw err; } finally { reader.releaseLock?.(); } if (chunks.length === 0) return ""; if (chunks.length === 1) return new TextDecoder().decode(chunks[0]); const combined = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } return new TextDecoder().decode(combined); } /** * Parse a Response body as JSON with a byte-count cap. A cheap wrapper * around {@link readBodyWithByteCap}; prefer this for registry index * fetches, GitHub API responses, and any other untrusted JSON source. */ export async function jsonWithByteCap(response, maxBytes = DEFAULT_RESPONSE_BYTE_CAP, limits) { const text = await readBodyWithByteCap(response, maxBytes, limits); return JSON.parse(text); } export function toErrorMessage(error) { return error instanceof Error ? error.message : String(error); } // ── Date / timestamp utilities ─────────────────────────────────────────────── /** * Return today's date in ISO-8601 format (`YYYY-MM-DD`). * Consolidates the `new Date().toISOString().slice(0, 10)` pattern that * appears at multiple call sites. */ export function todayIso() { return new Date().toISOString().slice(0, 10); } /** * `YYYY-MM-DD` in LOCAL time — deliberately not {@link todayIso}, which is * UTC and can differ near midnight. This is the spelling the `updated:` * frontmatter stampers share (`core/asset/akm-markdown.ts` on write, * `commands/lint/base-linter.ts` on `--fix`), so the field's format has one * definition even though the two stampers pick different instants (now vs * file mtime). */ export function localDateStamp(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } /** * Return a filesystem-safe timestamp string derived from the current instant. * Colons and dots are replaced with hyphens so the result is safe as a * filename component on all platforms (e.g. `2024-01-15T10-30-00-000Z`). */ export function timestampForFilename() { return new Date().toISOString().replace(/[:.]/g, "-"); } // ── String coercion ────────────────────────────────────────────────────────── /** * Return the trimmed string value if non-empty, otherwise `undefined`. * Consolidates `toStringOrUndefined` (frontmatter.ts), `asNonEmptyString` * (config.ts), and `firstString` (memory-improve.ts) — all had the same * "return a string or undefined" contract with minor semantic differences. */ export function asNonEmptyString(value) { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } // ── env-file assignment scanning ───────────────────────────────────────────── /** Matches a `KEY=value` assignment line, capturing only the key. */ export const ENV_ASSIGN_LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/; /** Scan lines and return KEY names in file order, without duplicates. */ export function scanEnvKeyNames(text) { const keys = []; const seen = new Set(); for (const line of text.split(/\r?\n/)) { const m = line.match(ENV_ASSIGN_LINE_RE); if (!m) continue; const key = m[1]; if (!key) continue; if (seen.has(key)) continue; seen.add(key); keys.push(key); } return keys; } /** True when `value` contains no lone/unpaired UTF-16 surrogate (every surrogate is part of a valid pair). */ export function wellFormedUnicode(value) { for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); if (code >= 0xd800 && code <= 0xdbff) { const next = value.charCodeAt(index + 1); if (!(next >= 0xdc00 && next <= 0xdfff)) return false; index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) return false; } return true; } // ── Generic data utilities ─────────────────────────────────────────────────── /** * Narrow an unknown value to a plain-ish record: an `object` that is neither * `null` nor an `Array`. Does not distinguish a literal `{}` from a `Date`, * `Map`, or class instance — callers that need that distinction use a * stricter predicate instead. */ export function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** `JSON.parse` that returns `undefined` instead of throwing. */ export function tryParseJson(raw) { try { return JSON.parse(raw); } catch { return undefined; } } /** * Coerce an unknown value to a filtered, trimmed string array. * Non-strings and empty/whitespace-only entries are dropped. */ export function stringArray(value) { if (!Array.isArray(value)) return []; const out = []; for (const item of value) { if (typeof item === "string" && item.trim().length > 0) out.push(item.trim()); } return out; } /** * Group an array of values by a string key derived from each element. * Returns a `Map` so insertion order within each group is preserved. */ /** * Return true if a process with the given PID is currently alive. * Uses `process.kill(pid, 0)` which does not deliver a signal but * throws ESRCH when the process does not exist. * * EPERM means the process EXISTS but belongs to another uid, so it must be * reported alive. Treating it as dead let a lock held by a live process in a * shared data dir (agent sandboxes, containers, service accounts — a * configuration managed-db.ts explicitly supports) be reclaimed as stale. * * `pid` is `unknown` because callers reading it out of untrusted on-disk * JSON (e.g. a lease file) cannot guarantee it parsed as a valid PID; a * non-positive-integer value is reported dead without ever reaching * `process.kill`. */ export function isProcessAlive(pid) { if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (err) { return err?.code === "EPERM"; } } /** * Convert a number of days to milliseconds. Consolidates the * `N * 24 * 60 * 60 * 1000` pattern used throughout the cooldown logic. */ export function daysToMs(days) { return days * 86_400_000; } export function groupBy(values, keyFn) { const groups = new Map(); for (const value of values) { const key = keyFn(value); const existing = groups.get(key); if (existing) existing.push(value); else groups.set(key, [value]); } return groups; }