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

253 lines (252 loc) • 10.6 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/. /** * Pure I/O helpers for AKM config files. * * No knowledge of the AkmConfig shape — these functions just read JSON(C) text * from disk and write JSON text back atomically. Current-schema validation lives * in `./config.ts`; older config shapes are rejected. * * Split out so the load path is testable without touching the filesystem * (`parseConfigText` is pure), and so a single atomic write path serves * `saveConfig` and the setup wizard (#464.c). */ import fs from "node:fs"; import path from "node:path"; import { sleepSync } from "../../runtime.js"; import { readTextFile, stripJsonComments, writeFileAtomic } from "../common.js"; import { ConfigError } from "../errors.js"; import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js"; import { getCacheDir, getConfigDir } from "../paths.js"; /** * Read the raw text of a config file. Returns `undefined` when the file does * not exist (legitimate cold-start). Other I/O errors propagate. */ export function readConfigText(configPath) { try { return readTextFile(configPath, "Config file"); } catch (err) { if (err.code === "ENOENT") return undefined; throw err; } } /** * Parse JSON(C) config text into a plain object. Strips `//` and `/* *​/` * comments before parsing. * * Throws {@link ConfigError} when the text is unparseable or when the root is * not a JSON object. Per #458, malformed config text is NOT silently rescued — * the caller must surface the parse error. */ export function parseConfigText(text, sourcePath) { const stripped = stripJsonComments(text); const where = sourcePath ? ` at ${sourcePath}` : ""; let parsed; try { parsed = JSON.parse(stripped); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new ConfigError(`Failed to parse config JSON${where}: ${detail}`, "INVALID_CONFIG_FILE", "Edit the file to fix the JSON syntax error. Comments (// and /* */) are allowed; trailing commas are not."); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new ConfigError(`Config file${where} must contain a JSON object at the root, got ${describeJsonRoot(parsed)}.`, "INVALID_CONFIG_FILE"); } return parsed; } function describeJsonRoot(value) { if (value === null) return "null"; if (Array.isArray(value)) return "an array"; if (typeof value === "string") return "a string"; if (typeof value === "number") return "a number"; if (typeof value === "boolean") return "a boolean"; return typeof value; } /** * Atomically write a config object to disk as pretty-printed JSON. Routes * through {@link writeFileAtomic} so partial writes can never corrupt the * config file (#464.c). */ export function writeConfigAtomic(configPath, config) { writeFileAtomic(configPath, `${JSON.stringify(config, null, 2)}\n`); } /** Maximum number of timestamped config backups to retain (#459). */ const MAX_CONFIG_BACKUPS = 5; export function backupExistingConfig(configPath, now = new Date()) { if (!fs.existsSync(configPath)) return undefined; const backupDir = path.join(getCacheDir(), "config-backups"); // 08-F4: lock the backup dir owner-only up front (0700) — matching the // env.ts/secret.ts convention — so no other local user can traverse in during // the copy→chmod window. chmod again to tighten a dir from an older version. fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); fs.chmodSync(backupDir, 0o700); const timestamp = now.toISOString().replace(/[.:]/g, "-"); let sequence = 0; let timestamped; while (true) { timestamped = path.join(backupDir, `config-${timestamp}${sequence === 0 ? "" : `-${sequence}`}.json`); try { fs.copyFileSync(configPath, timestamped, fs.constants.COPYFILE_EXCL); break; } catch (error) { if (error.code !== "EEXIST") throw error; sequence++; } } const latest = path.join(backupDir, "config.latest.json"); fs.copyFileSync(configPath, latest); // 08-F4: a config backup carries the same sensitive fields as the live config // (endpoints, tokens). `copyFileSync` inherits the source's (often 0644) mode, // so tighten the backups to owner-only — mirrors the env-cli 0600 write floor. fs.chmodSync(timestamped, 0o600); fs.chmodSync(latest, 0o600); pruneOldBackups(backupDir); return { timestamped, latest }; } function pruneOldBackups(backupDir) { pruneToNewest(backupDir, MAX_CONFIG_BACKUPS, (entry) => entry.isFile() && isTimestampedConfigBackup(entry.name)); } function isTimestampedConfigBackup(name) { return name.startsWith("config-") && name.endsWith(".json") && name !== "config.latest.json"; } /** * Keep the `keep` most-recently-modified entries of `dir` that `select` * admits and remove the rest. Best-effort: an unreadable dir is a no-op, an * unreadable entry sorts oldest (pruned first), and a failed removal is * retried by the next call. */ export function pruneToNewest(dir, keep, select) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } const candidates = entries .filter(select) .map((entry) => { const full = path.join(dir, entry.name); let mtime = 0; try { mtime = fs.statSync(full).mtimeMs; } catch { // Unreadable — sorts to the end via mtime 0. } return { path: full, mtime }; }) .sort((a, b) => b.mtime - a.mtime); for (const stale of candidates.slice(keep)) { try { fs.rmSync(stale.path, { recursive: true, force: true }); } catch { // Best-effort prune; the next call will retry. } } } // ── Config write lock ──────────────────────────────────────────────────────── /** * Path to the config write sentinel (`config.json.lck` in $CONFIG). * * Placed next to config.json so the lock scope is obvious and the path is * predictable for debugging. Uses $CONFIG (not $DATA) because config.json * itself lives in $CONFIG — they should fail together if the dir is read-only. */ export function getConfigLockPath() { return path.join(getConfigDir(), "config.json.lck"); } // Total wait budget for a CONTENDED lock: retries × delay. An abandoned lock // is not covered by this budget — it is reclaimed by the stale-lock probe // below — so this only bounds how long a caller queues behind live writers. // // 10 × 50ms (500ms) was too thin: N concurrent `akm config set` processes // serialize on this lock, so the last one in line waits for all N-1 holders. // On a loaded machine (CI runs four test shards in parallel) eight contenders // exhausted the budget and one exited with "Timed out waiting for config // lock", which is a spurious failure — the lock was healthy and simply busy. // 2s gives real concurrent use headroom while still failing promptly against a // genuinely wedged (but non-stale) holder. const CONFIG_LOCK_MAX_RETRIES = 40; const CONFIG_LOCK_RETRY_DELAY_MS = 50; /** * Block the current thread for `ms` without busy-spinning (H8). Delegates to * the runtime boundary's `sleepSync`, a real blocking sleep that yields the * thread to the OS scheduler. */ function sleepSyncMs(ms) { sleepSync(ms); } /** * Acquire an exclusive sentinel around config writes. * * Returns a release function. Acquisition is fail-closed: config mutation may * never continue without owning the lock that protects its read/merge/write. */ export function acquireConfigLock() { const lockPath = getConfigLockPath(); try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { // Directory already exists or unwritable — let the write fail naturally. } for (let attempt = 0; attempt < CONFIG_LOCK_MAX_RETRIES; attempt++) { try { const ownership = tryAcquireLockSync(lockPath, createLockPayload()); if (ownership) { return () => releaseLock(ownership); } } catch (error) { throw new ConfigError(`Unable to acquire config lock at ${lockPath}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE"); } const probe = probeLock(lockPath); if (probe.state === "stale" && reclaimStaleLock(lockPath, probe)) { continue; // Reclaimed — retry immediately. } if (attempt < CONFIG_LOCK_MAX_RETRIES - 1) { // H8: yield the thread between retries instead of busy-spinning. // The previous `while (Date.now() < deadline)` loop burned CPU for up to // 50ms per retry (≈500ms total), freezing the single JS thread and // starving co-scheduled work under parallel load. `sleepSync` is a // real blocking sleep that releases the thread to the OS scheduler. // Kept synchronous (rather than an async sleep) to preserve the sync // `withConfigLock` signature and avoid an async ripple through every // `saveConfig`/`loadConfig` caller. Lock semantics are unchanged: same // retry count, same delay budget, same best-effort fall-through. sleepSyncMs(CONFIG_LOCK_RETRY_DELAY_MS); } } throw new ConfigError(`Timed out waiting for config lock at ${lockPath}. Another AKM process may be updating config.`, "INVALID_CONFIG_FILE"); } /** * Run `fn` inside the config write lock. Always releases the lock. */ export function withConfigLock(fn) { const release = acquireConfigLock(); try { return fn(); } finally { release(); } } /** * Re-exported from core/common.ts, where it now lives so `resolveStashDir` * can strip comments too without importing this module (common cannot depend * on config-io — config-io already depends on common). */ export { stripJsonComments } from "../common.js";