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

237 lines (236 loc) • 10.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 { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { akmAdapter } from "../../core/adapter/adapters/akm-adapter.js"; import { stashDirNames } from "../../core/asset/asset-placement.js"; import { resolveSecret } from "../../core/config/config.js"; import { ConfigError, UsageError } from "../../core/errors.js"; import { getRegistryIndexCacheDir } from "../../core/paths.js"; import { validateGitUrl } from "../../registry/resolve.js"; import { withFreshnessCache } from "../freshness.js"; import { assertNoIgnoredPathOverwrite, cloneRepo, inspectGitUpstream, runGit } from "./git-install.js"; import { sanitizeString } from "./provider-utils.js"; /** Cache TTL before refreshing the mirrored repo (12 hours). */ const CACHE_TTL_MS = 12 * 60 * 60 * 1000; /** Maximum stale age allowed when refresh fails (7 days). */ const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000; /** * Git source provider — clones (and re-pulls) a remote repo into a local * cache directory. Implements the {@link SourceProvider} interface. * * Reading is the indexer's job — this class doesn't implement `search` or * `show`. Install refs are materialized by `syncFromRef`; this provider only * refreshes configured Git sources. */ export class GitSourceProvider { kind = "git"; name; #config; #path = null; constructor(config) { this.#config = config; this.name = config.name ?? "git"; } path() { if (this.#path == null) { this.#path = resolveGitContentDir(this.#config); } return this.#path; } async sync(options) { await syncMirroredRepo(this.#config, { force: options?.force, ...(this.#config.credential ? { credential: resolveSecret(this.#config.credential, options?.secrets?.resolveSecret) } : {}), }); } } /** Resolve the on-disk content directory for a configured git source. */ function resolveGitContentDir(config) { if (config.path) return config.path; if (config.url) { const repo = parseGitRepoUrl(config.url); return getCachePaths(repo.canonicalUrl).repoDir; } throw new ConfigError("git source entry must have either `path` or `url`"); } // ── Cache management ──────────────────────────────────────────────────────── export function getCachePaths(repoUrl, cacheRootOverride) { const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16); const cacheRoot = cacheRootOverride ?? getRegistryIndexCacheDir(); const rootDir = path.join(cacheRoot, `git-${key}`); return { rootDir, repoDir: path.join(rootDir, "repo"), indexPath: path.join(rootDir, "index.json"), }; } export async function ensureGitMirror(repo, cachePaths, options) { const requireRepoDir = options?.requireRepoDir === true; const writable = options?.writable === true; await withFreshnessCache({ markerPath: cachePaths.indexPath, ttlMs: CACHE_TTL_MS, staleMs: CACHE_STALE_MS, force: options?.force === true, allowStaleOnRefreshFailure: !(options?.force === true && writable), isUsable: () => !requireRepoDir || hasExtractedRepo(cachePaths.repoDir), refresh: async () => { fs.mkdirSync(cachePaths.rootDir, { recursive: true }); if (writable && fs.existsSync(path.join(cachePaths.repoDir, ".git"))) { // Writable repo already cloned — pull instead of re-clone to preserve local changes pullRepo(cachePaths.repoDir, options?.credential); } else { cloneRepo(repo.cloneUrl, repo.ref, cachePaths.repoDir, writable, options?.credential); } // Touch index file to track freshness fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 }); }, }); } /** * Sync mode for a long-lived configured git stash. Mirrors the repo into the * shared registry-index cache (12h TTL) and exposes the working tree as the * stash content directory. */ export async function syncMirroredRepo(config, options) { if (!config.url) { throw new ConfigError("git stash entry requires a URL when no install ref is supplied"); } const repo = parseGitRepoUrl(config.url); const cachePaths = getCachePaths(repo.canonicalUrl, options?.cacheRootDir); await ensureGitMirror(repo, cachePaths, { requireRepoDir: true, writable: options?.writable ?? config.writable === true, force: options?.force, credential: options?.credential, }); const syncedAt = (options?.now ?? new Date()).toISOString(); const contentDir = cachePaths.repoDir; return { id: repo.canonicalUrl, source: "git", ref: repo.canonicalUrl, artifactUrl: repo.canonicalUrl, contentDir, cacheDir: cachePaths.rootDir, extractedDir: contentDir, writable: options?.writable ?? config.writable === true, syncedAt, }; } function pullRepo(repoDir, credential) { const status = runGit(["-C", repoDir, "status", "--porcelain"]); if (status.status !== 0 || status.stdout.trim()) { throw new UsageError(`Writable Git source at ${repoDir} has uncommitted changes; refusing to update it.`); } const relation = inspectGitUpstream(repoDir, credential); if (relation.behind > 0 && relation.upstream) { if (relation.ahead > 0) { throw new UsageError(`Writable Git source at ${repoDir} has unpushed commits; refusing to update it.`); } const statusBeforeMerge = runGit(["-C", repoDir, "status", "--porcelain"]); if (statusBeforeMerge.status !== 0 || statusBeforeMerge.stdout.trim()) { throw new UsageError(`Writable Git source at ${repoDir} changed while its update was prepared; refusing to merge.`); } assertNoIgnoredPathOverwrite(repoDir, relation.upstream); const merge = runGit(["-C", repoDir, "merge", "--ff-only", "--no-overwrite-ignore", relation.upstream], { timeout: 120_000, }); if (merge.status !== 0) { throw new Error(`Failed to fast-forward ${repoDir}: ${merge.stderr?.trim() || "unknown error"}`); } } } function hasExtractedRepo(repoDir) { try { if (!fs.statSync(repoDir).isDirectory()) return false; if (fs.statSync(path.join(repoDir, "content")).isDirectory()) return true; } catch { /* fall through to root-layout detection */ } try { if (!fs.statSync(repoDir).isDirectory()) return false; // WI-3.1: the "any type dir present" test is now sourced from the `akm` // adapter's directoryList() — behavior-identical to the placement // stash-subdir names, with `stashDirNames()` kept live as the fallback. The // content/ subdir check above is preserved verbatim. const ownedDirs = akmAdapter.directoryList?.({ id: "akm", adapter: "akm", root: repoDir, writable: false }) ?? stashDirNames(); return ownedDirs.some((dirName) => fs.existsSync(path.join(repoDir, dirName))); } catch { return false; } } export function parseGitRepoUrl(rawUrl) { if (!rawUrl) { throw new ConfigError("Git provider requires a repository URL"); } // SSH shorthand: git@host:path — valid as-is, delegated to system git credentials if (/^git@[^:]+:.+$/.test(rawUrl)) { return { cloneUrl: rawUrl, ref: null, canonicalUrl: rawUrl }; } // Validate URL scheme is safe before parsing try { validateGitUrl(rawUrl); } catch (err) { if (err instanceof UsageError) throw new ConfigError(err.message); throw err; } let parsed; try { parsed = new URL(rawUrl); } catch { throw new ConfigError(`Git provider URL is not valid: "${rawUrl}"`); } // GitHub web URLs: extract a clean clone URL and optional branch from /tree/<ref> if (parsed.hostname === "github.com" && parsed.protocol === "https:") { const segments = parsed.pathname.split("/").filter(Boolean); if (segments.length < 2) { throw new ConfigError(`Git provider URL must point to a repository, got "${rawUrl}"`); } const owner = sanitizeString(segments[0]); const repo = sanitizeString(segments[1].replace(/\.git$/i, "")); if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) { throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`); } let ref = null; if (segments[2] === "tree" && segments.length >= 4) { const rawRef = sanitizeString(segments.slice(3).join("/"), 255); if (rawRef && !rawRef.includes("..") && /^[A-Za-z0-9._/-]+$/.test(rawRef)) { ref = rawRef; } } const cloneUrl = `https://github.com/${owner}/${repo}`; const canonicalUrl = ref ? `${cloneUrl}/tree/${ref}` : cloneUrl; return { cloneUrl, ref, canonicalUrl }; } // Any other valid git URL: use as-is for cloning, but strip embedded credentials // from canonicalUrl so secrets don't leak into cache keys or warning messages. let canonicalUrl = rawUrl; try { const u = new URL(rawUrl); u.username = ""; u.password = ""; u.search = ""; u.hash = ""; canonicalUrl = u.toString(); } catch { // URL failed to parse — fall back to raw (validateGitUrl already accepted it) } return { cloneUrl: rawUrl, ref: null, canonicalUrl }; }