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

142 lines (141 loc) • 7.3 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/. /** * akm initialization logic. * * Creates the working stash directory structure, persists the stashDir * in config.json, and ensures ripgrep is available. */ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { stashDirNames } from "../../core/asset/asset-placement.js"; import { mutateConfig } from "../../core/config/config.js"; import { assertSafeStashDir, getConfigPath, getDefaultStashDir } from "../../core/paths.js"; import { warnOnce } from "../../core/warn.js"; import { primaryBundlePath, withPrimaryBundle } from "./bundle-config-ops.js"; import { copyStashSkeleton, ensureStashGitignore, scaffoldStashMeta } from "./stash-skeleton.js"; /** * Refuse to persist a temporary-directory stashDir to the user's config when * running under a test runner AND `--dir <tempdir>` was passed explicitly. * This guard targets the exact agent-overreach pattern documented in * `memories/akm-init-persists-stashdir-warning`: an agent ran * `akm bundle create --dir $(mktemp -d)` for an E2E test and silently rewrote the * developer's real config to point at a now-deleted temp dir. * * Tests that legitimately resolve a tempdir via HOME (default-path init) are * unaffected — those are normal `~/akm` resolutions and not the failure mode. * * Test sentinels (either suffices): * - `BUN_TEST=1` — explicit opt-in * - `NODE_ENV=test` — what `bun test` sets today * * Tests that genuinely need to exercise `akm bundle create --dir /tmp/...` should set * `AKM_FORCE_INIT_TMP_STASH=1`. */ function assertInitSandbox(stashDir, dirExplicitlyProvided) { if (!dirExplicitlyProvided) return; // Only guard explicit --dir, not default HOME resolution. const isUnderTest = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test"; if (!isUnderTest) return; const isTmp = stashDir.startsWith("/tmp/") || stashDir === "/tmp" || stashDir.startsWith("/var/tmp/") || stashDir === "/var/tmp" || stashDir.startsWith("/private/var/folders/") || stashDir.startsWith("/private/tmp/"); if (!isTmp) return; warnOnce(`init-tmp-stash:${stashDir}`, `Persisting --dir stashDir to a temporary path (${stashDir}) while a test-runner env var (BUN_TEST/NODE_ENV=test) is set; ` + "the OS may reap this path, leaving the next run pointing at a deleted bundle."); } // ── Test seam ──────────────────────────────────────────────────────────────── // Swap-and-restore override. Inert in production; only tests call the setter. let akmInitOverride; /** TEST-ONLY. Swap the implementation of `akmInit`; pass undefined to restore. */ export function _setAkmInitForTests(fake) { akmInitOverride = fake; } export async function akmInit(options) { if (akmInitOverride) return akmInitOverride(options); return akmInitReal(options); } async function akmInitReal(options) { const dirExplicitlyProvided = options?.dir != null; const setDefault = options?.setDefault === true; const stashDir = options?.dir ? path.resolve(options.dir) : getDefaultStashDir(); // Safety check (#473): refuse stashDir at /, $HOME, /etc, ~/.config, etc. // Runs BEFORE any disk write — a fat-fingered `akm bundle create --dir /` or // `akm bundle create --dir ~` would otherwise mkdir + git-init the user's system // root or home directory. Catastrophic-on-misuse vs. trivial-to-recover-from. assertSafeStashDir(stashDir); // Defense-in-depth: refuse to persist an explicit --dir /tmp/... stashDir // to config under a test runner. Default HOME-resolved paths are exempt. assertInitSandbox(stashDir, dirExplicitlyProvided); let created = false; if (!fs.existsSync(stashDir)) { fs.mkdirSync(stashDir, { recursive: true }); created = true; } for (const sub of stashDirNames()) { const subDir = path.join(stashDir, sub); if (!fs.existsSync(subDir)) { fs.mkdirSync(subDir, { recursive: true, mode: sub === "env" || sub === "secrets" ? 0o700 : undefined }); } } // Ensure the default stash is a local git repo (no remote required) ensureGitRepo(stashDir); // 08-F1: scaffold a default `.gitignore` that keeps env/ + secrets/ out of git // so a `git push` can never leak them. Idempotent + non-clobbering; the user // opts into versioning by un-ignoring a path. ensureStashGitignore(stashDir); // Run seeding UNCONDITIONALLY (not just when the stash was newly created) so // re-running `akm bundle create` on an existing stash backfills any missing skeleton // files — the README, the per-type SOFT convention templates under // facts/conventions/assets/, and the `.meta/index.md` orientation doc. Both // helpers are absent-only: they never overwrite a file a user has edited. copyStashSkeleton(stashDir); scaffoldStashMeta(stashDir); // Persist stashDir in config.json — but ONLY when the user is actually // setting up / opting into a default. A bare `akm bundle create --dir <secondary>` // must NOT silently repoint the user's real default stash (the footgun // documented in memories/akm-init-persists-stashdir-warning). // // Decision matrix — persist when ANY of: // (a) no --dir provided → default HOME-resolved setup flow // (b) --dir AND no existing stashDir in config → first-time bootstrap // (c) --dir AND --set-default → explicit opt-in // Otherwise (--dir + existing default + no --set-default) leave the default // pointer alone; the target dir is still scaffolded above. const configPath = getConfigPath(); let defaultBundleUpdated = false; let previousStashDir; if (options?.persistConfig !== false) { const result = mutateConfig((latest) => { // 0.9.0 (spec §10.1): the primary stash is the `defaultBundle`'s filesystem // `path`, not the retired top-level `stashDir`. const currentPrimary = primaryBundlePath(latest); const shouldPersist = !dirExplicitlyProvided || !currentPrimary || setDefault; if (!shouldPersist) { previousStashDir = currentPrimary; return latest; } if (currentPrimary === stashDir) return latest; return withPrimaryBundle(latest, stashDir); }); defaultBundleUpdated = result.written; } return { bundleDir: stashDir, created, configPath, defaultBundleUpdated, previousBundleDir: previousStashDir }; } /** Initialise `dir` as a git repository if it is not already one. */ function ensureGitRepo(dir) { const gitDir = path.join(dir, ".git"); if (fs.existsSync(gitDir)) return; // Non-fatal: git may not be available in all environments spawnSync("git", ["init", dir], { encoding: "utf8", timeout: 15_000 }); }