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

248 lines (247 loc) • 12.1 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 fs from "node:fs"; import path from "node:path"; import { writeFileAtomic } from "../core/common.js"; import { ConfigError, rethrowIfTestIsolationError, TransientError } from "../core/errors.js"; import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../core/file-lock.js"; import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js"; import { getDataDir, getLockfileLockPath, getLockfilePath } from "../core/paths.js"; import { warn } from "../core/warn.js"; // ── Lock sentinel ──────────────────────────────────────────────────────────── const LOCK_ACQUIRE_TIMEOUT_MS = 30_000; const LOCK_RETRY_INITIAL_DELAY_MS = 50; const LOCK_RETRY_MAX_DELAY_MS = 1_000; let lockAcquireTimeoutMsForTests; export function _setLockAcquireTimeoutMsForTests(ms) { lockAcquireTimeoutMsForTests = ms; } async function acquireLockSentinel() { const sentinelPath = getLockfileLockPath(); // Ensure the directory exists before attempting to create the sentinel. fs.mkdirSync(path.dirname(sentinelPath), { recursive: true }); const timeoutMs = lockAcquireTimeoutMsForTests ?? LOCK_ACQUIRE_TIMEOUT_MS; const deadline = Date.now() + timeoutMs; let delayMs = LOCK_RETRY_INITIAL_DELAY_MS; let announced = false; for (;;) { const ownership = tryAcquireLockSync(sentinelPath, createLockPayload()); if (ownership) { return () => releaseLock(ownership); } const probe = probeLock(sentinelPath); if (probe.state === "stale" && reclaimStaleLock(sentinelPath, probe)) { continue; // Reclaimed — retry immediately. } // Another process holds the lock: ordinary contention, exit 75, not a // config error a supervisor should stop retrying on. if (Date.now() >= deadline) { throw new TransientError(`Could not acquire lockfile sentinel at ${sentinelPath} after ${(timeoutMs / 1000).toFixed(1)}s; refusing to write without exclusive ownership.`, "LOCKFILE_CONTENDED"); } if (!announced) { announced = true; warn("[akm] Waiting for another akm process to release the lockfile..."); } await new Promise((resolve) => setTimeout(resolve, delayMs)); delayMs = Math.min(delayMs * 2, LOCK_RETRY_MAX_DELAY_MS); } } // ── Read / Write ──────────────────────────────────────────────────────────── export function readLockfile() { const lockfilePath = getLockfilePath(); try { const raw = JSON.parse(fs.readFileSync(lockfilePath, "utf8")); if (!Array.isArray(raw)) return []; return raw.filter(isValidLockfileEntry); } catch (err) { // Defense-in-depth: getLockfilePath() is outside this try block, but a // future refactor that pushes a getDataDir() call inside must not mask // the bun-test isolation guard as "empty lockfile". rethrowIfTestIsolationError(err); return []; } } /** * Refuse to treat an UNREADABLE lockfile (or data dir) as an absent one (#791). * * Every write path here is read-modify-WRITE: it loads the current entries and * writes the whole array back. An unreadable `akm.lock` that reads as `[]` * therefore does not merely lose information — the very next * `writeFileAtomic` replaces the operator's entire lock record with the single * entry this call happened to be adding. That is the same catastrophe R-012 * guards against for a *corrupt* file, reached instead through a permission * fault, and `fs.existsSync`/a swallowed `readFileSync` could not tell the two * apart from "the file was never created". * * No-op when the path is genuinely absent — that case really does have nothing * to preserve. */ function assertLockfilePathReadable(target) { const { access, code } = classifyPathAccess(target); if (access !== "inaccessible") return; throw new ConfigError(`Refusing to modify the lockfile: ${describeInaccessiblePath(target, code)}. akm cannot read the existing lock ` + "records, and writing over them would destroy every bundle they track. Fix the ownership or mode of that " + "path (or point AKM_DATA_DIR / XDG_DATA_HOME somewhere this user owns) and retry.", "DATA_DIR_UNREADABLE"); } /** * Like {@link readLockfile}, but THROWS instead of silently degrading to `[]` * when the on-disk lockfile exists yet cannot be read or parsed (R-012, #791). * * `readLockfile`'s fail-open contract is intentional for READ paths — a * corrupt lock degrades a managed bundle to "unmanaged" rather than erroring * every read-only command. But every writer below reads the current entries * and writes the whole array back; if that read silently returned `[]` for a * corrupt or unreadable file, the write would replace it with only the one * entry being changed — permanently destroying every other lock record (a * corrupt lockfile once wiped every entry this way). A missing file is NOT * corruption: there is nothing to preserve, so that case returns `[]`. */ function readLockfileOrThrow() { const lockfilePath = getLockfilePath(); let raw; try { raw = fs.readFileSync(lockfilePath, "utf8"); } catch (err) { rethrowIfTestIsolationError(err); if (err.code === "ENOENT") return []; // An UNREADABLE lockfile has everything to preserve and we cannot see it. // Classify AFTER the failed read so the happy path costs no extra syscall. assertLockfilePathReadable(lockfilePath); return []; } let parsed; try { parsed = JSON.parse(raw); } catch (error) { throw new ConfigError(`Refusing to modify lockfile ${lockfilePath}: existing content is not valid JSON (${error instanceof Error ? error.message : String(error)}). Fix or remove the file by hand before retrying — every existing lock entry would otherwise be lost.`, "INVALID_CONFIG_FILE"); } if (!Array.isArray(parsed)) { throw new ConfigError(`Refusing to modify lockfile ${lockfilePath}: existing content is not a JSON array. Fix or remove the file by hand before retrying — every existing lock entry would otherwise be lost.`, "INVALID_CONFIG_FILE"); } // Refuse rather than filter. This is the WRITE path's read: everything it // returns is what gets written back, so silently dropping entries that fail // per-entry validation destroyed them on the next write — the same // data-losing overwrite the two refusals above exist to prevent, just at // entry granularity instead of file granularity. const invalid = parsed.filter((entry) => !isValidLockfileEntry(entry)); if (invalid.length > 0) { throw new ConfigError(`Refusing to modify lockfile ${lockfilePath}: ${invalid.length} existing entr${invalid.length === 1 ? "y is" : "ies are"} malformed. ` + "Fix or remove the file by hand before retrying — those entries would otherwise be lost.", "INVALID_CONFIG_FILE"); } return parsed; } /** * The materialized content root recorded in the lock for a managed (git/npm) * bundle — spec §10.2 desired/resolved split, where the desired config carries * only the source LOCATOR and the resolved `localRoot` lives here. * * This is the SINGLE lock-first resolution point shared by the indexer READ * path (`resolveEntryContentDir` in indexer/search) and the command-layer WRITE * path (`adaptConfiguredSource` in core/write-source): consulting it first makes * a write land in exactly the directory a read walks. Returns `undefined` for a * bundle with no lock `localRoot` or a non-managed type, so both callers fall * back to the identical provider-path derivation. */ export function lockContentRootFor(bundleId, type) { if (!bundleId || (type !== "git" && type !== "npm")) return undefined; for (const lock of readLockfile()) { if (lock.id === bundleId && typeof lock.localRoot === "string" && lock.localRoot.length > 0) { return lock.localRoot; } } return undefined; } function writeLockfileUnlocked(entries) { // Always write to $DATA — never to the legacy $CONFIG location. const lockfilePath = getLockfilePath(); const dir = path.dirname(lockfilePath); fs.mkdirSync(dir, { recursive: true }); writeFileAtomic(lockfilePath, `${JSON.stringify(entries, null, 2)}\n`); } export async function writeLockfile(entries) { const release = await acquireLockSentinel(); try { writeLockfileUnlocked(entries); } finally { release(); } } export async function upsertLockEntry(entry) { const release = await acquireLockSentinel(); try { // R-012: readLockfileOrThrow (not readLockfile) — a corrupt/malformed // lockfile must abort the upsert loudly rather than read as `[]` and get // silently overwritten with just this one entry. const entries = readLockfileOrThrow(); const withoutExisting = entries.filter((e) => e.id !== entry.id); writeLockfileUnlocked([...withoutExisting, entry]); } finally { release(); } } /** * Rename a lock entry's id in place (D6 — `akm bundle rename`), keeping every * other resolved field (`localRoot`, `resolvedVersion`, …) unchanged. Returns * `true` when an entry for `oldId` existed and was renamed, `false` when * there was nothing to rename (e.g. a filesystem bundle, which has no lock * entry). */ export async function renameLockEntry(oldId, newId) { const release = await acquireLockSentinel(); try { // R-012: see upsertLockEntry — a corrupt lockfile must abort loudly // rather than read as `[]` and get silently overwritten. const entries = readLockfileOrThrow(); const existing = entries.find((e) => e.id === oldId); if (!existing) return false; const renamed = entries.map((e) => (e.id === oldId ? { ...e, id: newId } : e)); writeLockfileUnlocked(renamed); return true; } finally { release(); } } export async function removeLockEntry(id) { // Returning early says "there is no lock record to remove", and the uninstall // that called us reports success on that basis. Only an absent data dir earns // it — one we cannot read may hold the very entry we were asked to drop, and // silently leaving it behind is how a bundle stays "installed" forever (#791). const dataDir = getDataDir(); assertLockfilePathReadable(dataDir); if (!fs.existsSync(dataDir)) return; const release = await acquireLockSentinel(); try { // R-012: see upsertLockEntry — same destructive-overwrite risk on a // corrupt lockfile, so the same strict reader is used here. const entries = readLockfileOrThrow(); writeLockfileUnlocked(entries.filter((e) => e.id !== id)); } finally { release(); } } // ── Helpers ───────────────────────────────────────────────────────────────── export function isValidLockfileEntry(value) { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const obj = value; return (typeof obj.id === "string" && obj.id !== "" && typeof obj.source === "string" && ["npm", "github", "git", "local"].includes(obj.source) && typeof obj.ref === "string" && obj.ref !== ""); }