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
959 lines (958 loc) • 44.2 kB
JavaScript
// 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/.
/**
* Source operations: list, remove, update.
*
* Provides unified operations across all configured bundle source providers.
* The CLI's `akm bundle list`, `akm bundle remove`, and `akm bundle update` commands are wired here.
*
* 0.9.0 (spec §10.1/§10.2): the retired `installed[]` array is gone — a
* registry-managed source is now a `bundles.<slug>` entry (the desired locator)
* paired with a lock entry (the resolved `localRoot`/version). A bundle that has
* a lock entry is "managed" (installed from a registry and overwritten on
* `akm bundle update`); a bundle with no lock is a plain filesystem/git/website source.
*/
import fs from "node:fs";
import path from "node:path";
import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
import { isWithin, resolveStashDir } from "../../core/common.js";
import { bundleComponentConfig, getSources, loadConfig, resolveSecret } from "../../core/config/config.js";
import { AkmError, ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
import { isPathAbsent } from "../../core/path-access.js";
import { getDbPath, getRegistryCacheDir } from "../../core/paths.js";
import { warn } from "../../core/warn.js";
import { resolveGitContentRoot } from "../../core/write-source.js";
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
import { akmIndex } from "../../indexer/indexer.js";
import { readLockfile, upsertLockEntry } from "../../integrations/lockfile.js";
import { parseRegistryRef } from "../../registry/resolve.js";
import { getCachePaths, parseGitRepoUrl, runGit, syncMirroredRepo } from "../../sources/providers/git.js";
import { replaceDirectory } from "../../sources/providers/git-install.js";
import { syncFromRef } from "../../sources/providers/sync-from-ref.js";
import { createWebsiteProvider } from "../../sources/providers/website.js";
import { storeSecretResolver } from "../../sources/snapshot-fetchers/secret-seam.js";
import { ensureWebsiteMirror } from "../../sources/snapshot-fetchers/website-ingest.js";
import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js";
import { getAllEntries } from "../../storage/repositories/index-entries-repository.js";
import { auditStashForDangerousKeys } from "./dangerous-env-audit.js";
import { removeInstalledRegistryEntry } from "./source-add.js";
import { removeStash } from "./source-manage.js";
/** Enumerate the registry-managed installs (lock-backed bundles) in a config. */
function listManagedInstalls(config) {
const bundles = config.bundles ?? {};
const locks = new Map(readLockfile().map((entry) => [entry.id, entry]));
const out = [];
for (const [key, bundle] of Object.entries(bundles)) {
const lock = locks.get(key);
if (!lock)
continue; // only lock-backed bundles are registry-managed
const componentWritable = Object.values(bundle.components ?? {})[0]?.writable;
const componentRoot = bundleComponentConfig(bundle)?.root ?? ".";
out.push({
bundleKey: key,
installId: bundle.registryId ?? key,
source: lock.source,
ref: lock.ref,
localRoot: lock.localRoot ?? "",
resolvedVersion: lock.resolvedVersion,
resolvedRevision: lock.resolvedRevision,
writable: componentWritable ?? bundle.writable === true,
componentRoot,
requiredRoots: lock.localRoot ? [path.resolve(lock.localRoot, componentRoot)] : [],
...(bundle.credential !== undefined ? { credential: bundle.credential } : {}),
});
}
return out;
}
/** Resolve an `akm bundle remove`/`akm bundle update` target to a managed install, if any. */
function resolveManagedTarget(config, target) {
const installs = listManagedInstalls(config);
const byId = installs.find((m) => m.installId === target || m.bundleKey === target);
if (byId)
return byId;
const byRef = installs.find((m) => m.ref === target);
if (byRef)
return byRef;
const isUrl = target.startsWith("http://") || target.startsWith("https://");
if (!isUrl) {
const resolved = path.resolve(target);
const byPath = installs.find((m) => m.localRoot && path.resolve(m.localRoot) === resolved);
if (byPath)
return byPath;
}
let parsedId;
try {
parsedId = parseRegistryRef(target).id;
}
catch {
parsedId = undefined;
}
if (parsedId)
return installs.find((m) => m.installId === parsedId);
return undefined;
}
function describeBundleSource(entry) {
if (entry.path)
return { kind: "path", locator: entry.path };
if (entry.git)
return { kind: "git", locator: entry.git };
if (entry.website) {
return {
kind: "website",
locator: entry.website.url,
...(entry.website.maxPages !== undefined ? { maxPages: entry.website.maxPages } : {}),
};
}
return { kind: "npm", locator: entry.npm ?? "" };
}
/**
* Per-component `{ adapter, detected }` disclosure (#908/#909): `adapter` is
* the EFFECTIVE adapter (explicit config, or auto-detected via the same
* ordered probe `akm index` uses) and `detected` is `true` exactly when no
* explicit `adapter` was configured. Before this, an auto-detected adapter
* was invisible on `akm bundle list` — a mixed-layout bundle silently
* shadowed by a narrower adapter (#908) gave no sign a choice had even been
* made. `bundleRoot` is the bundle's resolved content root (lock `localRoot`
* or its plain `path`), or `undefined` when neither is known yet (an
* unresolved registry/website source) — detection is skipped in that case
* rather than probing a path that may not exist.
*/
function describeComponents(entry, bundleRoot) {
const configuredComponents = entry.components ?? {};
const names = Object.keys(configuredComponents);
if (names.length === 0) {
// No explicit component at all — the implicit single component every
// bundle gets (spec §1.2 rule 5; `deriveInstallations` applies the same
// "main"-shaped default at index time).
const adapter = bundleRoot !== undefined ? detectAdapterId(bundleRoot) : "akm";
return [{ name: "main", adapter, detected: true }];
}
return names.map((name) => {
const component = configuredComponents[name];
const componentRoot = bundleRoot !== undefined ? path.resolve(bundleRoot, component.root ?? ".") : undefined;
const adapter = component.adapter ?? (componentRoot !== undefined ? detectAdapterId(componentRoot) : "akm");
return {
name,
...(component.root !== undefined ? { root: component.root } : {}),
adapter,
detected: component.adapter === undefined,
...(component.writable !== undefined ? { writable: component.writable } : {}),
};
});
}
function describeLock(entry) {
if (!entry)
return null;
return {
source: entry.source,
ref: entry.ref,
...(entry.resolvedVersion !== undefined ? { resolvedVersion: entry.resolvedVersion } : {}),
...(entry.resolvedRevision !== undefined ? { resolvedRevision: entry.resolvedRevision } : {}),
...(entry.integrity !== undefined ? { integrity: entry.integrity } : {}),
...(entry.localRoot !== undefined ? { localRoot: entry.localRoot } : {}),
...(entry.manifestDigest !== undefined ? { manifestDigest: entry.manifestDigest } : {}),
...(entry.adapterIds !== undefined ? { adapterIds: entry.adapterIds } : {}),
...(entry.installedAt !== undefined ? { installedAt: entry.installedAt } : {}),
};
}
function readBundleCounts() {
const counts = new Map();
const dbPath = getDbPath();
// An empty map renders as `itemCount: 0` for every bundle — indistinguishable
// from "these bundles really are empty". Only a never-built index gets to say
// that silently; an unreadable one falls through to the opener and is
// reported by the `warn` in the catch below (#791).
if (isPathAbsent(dbPath))
return counts;
let db;
try {
db = openReadonlyExistingDatabase(dbPath);
if (!db)
return counts;
for (const row of getAllEntries(db)) {
if (!row.bundleId)
continue;
const count = counts.get(row.bundleId) ?? { itemCount: 0, byType: {} };
count.itemCount += 1;
count.byType[row.entry.type] = (count.byType[row.entry.type] ?? 0) + 1;
counts.set(row.bundleId, count);
}
}
catch (error) {
warn(`[akm bundle list] failed to read bundle counts from ${dbPath}: ${String(error)}`);
}
finally {
if (db) {
try {
closeDatabase(db);
}
catch {
// The inventory read is already complete; a close failure is non-fatal.
}
}
}
return counts;
}
export async function akmListSources(input) {
const stashDir = input?.stashDir ?? resolveStashDir();
const config = loadConfig();
const kindFilter = input?.kind;
const locks = new Map(readLockfile().map((entry) => [entry.id, entry]));
const counts = readBundleCounts();
const sources = [];
for (const bundle of getSources(config)) {
const key = bundle.name ?? bundle.path ?? bundle.url ?? "unknown";
const configured = config.bundles?.[key];
if (!configured)
continue;
const lock = locks.get(key);
const kind = bundle.type;
if (kindFilter && !kindFilter.includes(kind))
continue;
const root = lock?.localRoot ?? bundle.path ?? "";
const componentWritable = Object.values(configured.components ?? {})[0]?.writable;
const bundleCounts = counts.get(key) ?? { itemCount: 0, byType: {} };
sources.push({
name: key,
kind,
default: key === config.defaultBundle,
source: describeBundleSource(configured),
...(root ? { path: root } : {}),
...(lock ? { ref: lock.ref } : {}),
provider: bundle.url != null ? bundle.type : undefined,
...(lock?.resolvedVersion !== undefined ? { version: lock.resolvedVersion } : {}),
writable: componentWritable ?? bundle.writable ?? kind === "filesystem",
...(configured.registryId !== undefined ? { registryId: configured.registryId } : {}),
components: describeComponents(configured, root || undefined),
lock: describeLock(lock),
itemCount: bundleCounts.itemCount,
byType: bundleCounts.byType,
status: { exists: root ? directoryExists(root) : true },
});
}
return {
schemaVersion: 1,
bundleDir: stashDir,
defaultBundle: config.defaultBundle ?? null,
sources,
totalSources: sources.length,
};
}
export async function akmRemove(input) {
const target = input.target.trim();
if (!target)
throw new UsageError("Target is required. Provide the source id, ref, path, URL, or name (e.g. `akm bundle remove npm:@scope/stash` or `akm bundle remove ~/my-stash`).");
const stashDir = input.stashDir ?? resolveStashDir();
const config = loadConfig();
// Registry-managed installs (lock-backed bundles) first.
const managed = resolveManagedTarget(config, target);
if (managed) {
const updatedConfig = await removeInstalledRegistryEntry(managed.installId);
if (managed.source !== "local" && managed.localRoot) {
cleanupDirectoryBestEffort(managed.localRoot, "remove");
}
const index = await akmIndex({ stashDir });
return {
schemaVersion: 1,
bundleDir: stashDir,
target,
removed: {
id: managed.installId,
source: managed.source,
ref: managed.ref,
cacheDir: managed.localRoot,
stashRoot: managed.localRoot,
},
config: {
sourceCount: getSources(updatedConfig).length,
},
index: {
mode: index.mode,
totalEntries: index.totalEntries,
directoriesScanned: index.directoriesScanned,
directoriesSkipped: index.directoriesSkipped,
},
};
}
// Plain sources (filesystem/git/website bundles) via the bundle-map remover.
const stashResult = removeStash(target);
if (!stashResult.removed || !stashResult.entry) {
throw new NotFoundError(`No matching source for target: ${target}`, "SOURCE_NOT_FOUND");
}
const removedEntry = stashResult.entry;
const index = await akmIndex({ stashDir });
const updatedConfig = loadConfig();
return {
schemaVersion: 1,
bundleDir: stashDir,
target,
removed: {
id: removedEntry.name ?? removedEntry.path ?? removedEntry.url ?? target,
source: removedEntry.type,
ref: removedEntry.path ?? removedEntry.url ?? target,
cacheDir: "",
stashRoot: removedEntry.path ?? "",
},
config: {
sourceCount: getSources(updatedConfig).length,
},
index: {
mode: index.mode,
totalEntries: index.totalEntries,
directoriesScanned: index.directoriesScanned,
directoriesSkipped: index.directoriesSkipped,
},
};
}
/**
* Read the current index generation without creating or hydrating anything.
* This path never ran an embedding pass (#954, field-report follow-up), so it has no
* `verification` to report — {@link buildUpdateResponse} falls back to the
* two facts it can actually know (whether semantic search is configured on
* at all) rather than fabricating verification numbers (`entryCount: 0`,
* `ok: true`) for a run that never verified anything.
*/
function readCurrentIndexSummary() {
const db = openReadonlyExistingDatabase(getDbPath());
if (!db) {
return { mode: "incremental", totalEntries: 0, directoriesScanned: 0, directoriesSkipped: 0 };
}
try {
return {
mode: "incremental",
totalEntries: getAllEntries(db).length,
directoriesScanned: 0,
directoriesSkipped: 0,
};
}
finally {
closeDatabase(db);
}
}
/** Build a standard UpdateResponse summary block from a committed or unchanged index generation. */
function buildUpdateResponse(stashDir, target, all, processed, opts) {
const index = opts.index;
const finalConfig = loadConfig();
return {
schemaVersion: 1,
bundleDir: stashDir,
target,
all,
processed,
...(opts?.plainSynced?.length ? { plainSynced: opts.plainSynced } : {}),
...(opts?.skipped?.length ? { skipped: opts.skipped } : {}),
config: {
sourceCount: getSources(finalConfig).length,
},
index: {
mode: index.mode,
totalEntries: index.totalEntries,
directoriesScanned: index.directoriesScanned,
directoriesSkipped: index.directoriesSkipped,
...(index.scanComplete !== undefined ? { scanComplete: index.scanComplete } : {}),
// A real embedding pass (`akmIndex`/`runEmbeddingPass`) reports its own
// verified `semanticStatus`. When no pass ran this update (the
// no-op/nothing-configured fallback) the only two facts known without
// fabricating a verification are whether semantic search is off at all
// or, if not, that its state is simply unverified this run.
semanticStatus: index.verification?.semanticStatus ?? (finalConfig.semanticSearchMode === "off" ? "disabled" : "pending"),
},
};
}
function incompleteFilesystemOutcome(id) {
return {
id,
kind: "filesystem",
status: "skipped",
code: "SOURCE_SCAN_INCOMPLETE",
reason: `Filesystem source "${id}" was not scanned completely; preserving its last-known-good index rows.`,
};
}
function createStagingParent(parent) {
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
return fs.mkdtempSync(path.join(parent, ".akm-update-stage-"));
}
/** A bundle's single component root and writability. */
function bundleComponent(config, id) {
const bundle = config.bundles?.[id];
const component = bundleComponentConfig(bundle);
return { root: component?.root ?? ".", writable: component?.writable ?? bundle?.writable === true };
}
async function auditStagedUpdate(auditRoot, ref, allowDangerousEnvKeys) {
const decision = await auditStashForDangerousKeys({
stashRoot: auditRoot,
ref,
allowDangerousKeys: allowDangerousEnvKeys,
isTTY: process.stdin.isTTY === true,
operation: "update",
renderBlockedError: false,
});
if (decision.blocked)
throw new NotFoundError(decision.error, decision.code);
}
function gitOrThrow(repoDir, args) {
const result = runGit(["-C", repoDir, ...args], { timeout: 120_000 });
if (result.status !== 0) {
throw new UsageError(`git ${args[0]} failed in ${repoDir}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`);
}
return result.stdout.trim();
}
/**
* Publish an audited writable checkout: fast-forward the live checkout to
* exactly the staged commit. Git refuses a non-fast-forward or a merge that
* would overwrite local work, and changes nothing in that case.
*/
function fastForwardCheckout(liveRepo, stagedRepo) {
const head = gitOrThrow(stagedRepo, ["rev-parse", "HEAD"]);
gitOrThrow(liveRepo, ["fetch", "--no-tags", stagedRepo, head]);
gitOrThrow(liveRepo, ["merge", "--ff-only", "--no-overwrite-ignore", head]);
}
/**
* Reindex after an update. Sources are not re-fetched (the update just did
* that) and detected adapters are not written to config.json — an update
* leaves config alone; the next `akm index` records them.
*/
function reindexAfterUpdate(stashDir) {
return akmIndex({ stashDir, full: true, hydrateSources: false, persistDetectedAdapters: false });
}
/** Publish, then reindex, holding the asset-mutation lease so no other writer interleaves. */
function publishAndIndex(stashDir, publish) {
return withAssetMutationLease("source-update", async () => {
await publish();
return reindexAfterUpdate(stashDir);
});
}
/** Fetch a registry-managed install into staging without touching its live content. */
async function stageManagedUpdate(managed, force) {
const credential = managed.credential
? resolveSecret(managed.credential, storeSecretResolver.resolveSecret)
: undefined;
if (managed.writable && managed.localRoot) {
// A writable install is a checkout that may carry local commits: the
// provider fast-forwards a copy, the copy is audited, and the live
// checkout is then fast-forwarded to exactly the audited commit.
const topLevel = runGit(["-C", managed.localRoot, "rev-parse", "--show-toplevel"]);
if (topLevel.status !== 0 || !topLevel.stdout.trim()) {
throw new UsageError(`Writable Git install at ${managed.localRoot} is not a checkout; refusing to update it.`);
}
const liveRepo = topLevel.stdout.trim();
const stagingParent = createStagingParent(path.dirname(liveRepo));
try {
const stagedRepo = path.join(stagingParent, path.basename(liveRepo));
fs.cpSync(liveRepo, stagedRepo, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
const stagedRoot = path.join(stagedRepo, path.relative(liveRepo, fs.realpathSync(managed.localRoot)));
const fetched = await syncFromRef(managed.ref, {
force,
writable: true,
writableRoot: stagedRoot,
writableRequiredRoots: managed.requiredRoots.map((root) => path.join(stagedRoot, path.relative(managed.localRoot, root))),
...(credential ? { credential } : {}),
});
return {
synced: { ...fetched, contentDir: managed.localRoot, cacheDir: liveRepo, extractedDir: liveRepo },
staged: {
auditRoot: path.resolve(fetched.contentDir, managed.componentRoot),
publish: () => fastForwardCheckout(liveRepo, stagedRepo),
cleanup: () => cleanupDirectoryBestEffort(stagingParent, "bundle update"),
},
};
}
catch (error) {
cleanupDirectoryBestEffort(stagingParent, "bundle update");
throw error;
}
}
if (managed.source === "local") {
// The user's own directory: nothing to fetch or publish.
const synced = await syncFromRef(managed.ref, { force, writable: managed.writable });
return {
synced,
staged: { auditRoot: path.resolve(synced.contentDir, managed.componentRoot), publish() { }, cleanup() { } },
};
}
const cacheRoot = getRegistryCacheDir();
const stagingParent = createStagingParent(cacheRoot);
try {
const fetched = await syncFromRef(managed.ref, {
force,
writable: false,
cacheRootDir: stagingParent,
...(credential ? { credential } : {}),
});
const liveCacheDir = path.join(cacheRoot, path.relative(stagingParent, fetched.cacheDir));
const toLive = (candidate) => path.join(liveCacheDir, path.relative(fetched.cacheDir, candidate));
return {
synced: {
...fetched,
cacheDir: liveCacheDir,
contentDir: toLive(fetched.contentDir),
extractedDir: toLive(fetched.extractedDir),
},
staged: {
auditRoot: path.resolve(fetched.contentDir, managed.componentRoot),
publish: () => replaceDirectory(fetched.cacheDir, liveCacheDir),
cleanup: () => cleanupDirectoryBestEffort(stagingParent, "bundle update"),
},
};
}
catch (error) {
cleanupDirectoryBestEffort(stagingParent, "bundle update");
throw error;
}
}
/** Fetch a plain git source into staging, audit it, publish it, and reindex. */
async function syncGitPlainSource(config, gitSource, stashDir, allowDangerousEnvKeys) {
const id = gitSource.name ?? gitSource.url ?? "";
const ref = gitSource.url ?? "";
if (!gitSource.url)
throw new ConfigError(`Git source "${id}" has no URL.`);
const component = bundleComponent(config, id);
const canonicalUrl = parseGitRepoUrl(gitSource.url).canonicalUrl;
const livePaths = getCachePaths(canonicalUrl);
const stagingParent = createStagingParent(path.dirname(livePaths.rootDir));
try {
const stagedPaths = getCachePaths(canonicalUrl, stagingParent);
// A writable mirror may carry local commits: pull into a copy, then
// fast-forward the live checkout once the copy passes the audit.
const liveCheckout = component.writable && fs.existsSync(livePaths.repoDir);
if (liveCheckout)
fs.cpSync(livePaths.rootDir, stagedPaths.rootDir, { recursive: true, preserveTimestamps: true });
await syncMirroredRepo(gitSource, {
force: true,
writable: component.writable,
cacheRootDir: stagingParent,
...(gitSource.credential
? { credential: resolveSecret(gitSource.credential, storeSecretResolver.resolveSecret) }
: {}),
});
await auditStagedUpdate(path.resolve(resolveGitContentRoot(stagedPaths.repoDir), component.root), ref, allowDangerousEnvKeys);
const index = await publishAndIndex(stashDir, () => {
if (liveCheckout)
fastForwardCheckout(livePaths.repoDir, stagedPaths.repoDir);
else
replaceDirectory(stagedPaths.rootDir, livePaths.rootDir);
});
return { item: { id, kind: "git", ref }, index };
}
finally {
cleanupDirectoryBestEffort(stagingParent, "bundle update");
}
}
/**
* Re-crawl a website source and reindex. The website provider already builds
* its snapshot in a staging directory and swaps it in, so a killed refresh
* keeps the previous mirror; a snapshot holds only knowledge pages, so there
* is no env file to audit.
*/
async function syncWebsitePlainSource(websiteSource, stashDir) {
const id = websiteSource.name ?? websiteSource.url ?? "";
await createWebsiteProvider(websiteSource).sync({
force: true,
secrets: storeSecretResolver,
ensureWebsiteMirror,
});
const index = await reindexAfterUpdate(stashDir);
return { item: { id, kind: "website", ref: websiteSource.url ?? "" }, index };
}
/** Sync a git-mirrored (plain) source and return an UpdateResponse (single-target path). */
async function updateGitSource(config, stashDir, target, all, gitSource, allowDangerousEnvKeys) {
const synced = await syncGitPlainSource(config, gitSource, stashDir, allowDangerousEnvKeys);
return buildUpdateResponse(stashDir, target, all, [], { plainSynced: [synced.item], index: synced.index });
}
/** Re-crawl a website (plain) source and return an UpdateResponse (single-target path). */
async function updateWebsiteSource(stashDir, target, all, websiteSource) {
const synced = await syncWebsitePlainSource(websiteSource, stashDir);
return buildUpdateResponse(stashDir, target, all, [], { plainSynced: [synced.item], index: synced.index });
}
/** Reconcile a filesystem source's current bytes without provider hydration. */
async function updateFilesystemSource(stashDir, target, all, filesystemSource) {
const id = filesystemSource.name ?? filesystemSource.path ?? target;
const ref = filesystemSource.path ?? target;
const index = await akmIndex({ stashDir, hydrateSources: false });
if (!index.scanComplete) {
return buildUpdateResponse(stashDir, target, all, [], {
skipped: [incompleteFilesystemOutcome(id)],
index,
});
}
return buildUpdateResponse(stashDir, target, all, [], {
plainSynced: [{ id, kind: "filesystem", ref }],
index,
});
}
/**
* A plain (lockless) npm bundle has no deterministic content path — unlike
* git/website, resolving an npm package requires a registry round-trip to
* pick a concrete version/tarball, which is exactly what the lock records.
* So a plain npm source is synced via the same registry-install pipeline as
* `akm bundle add <package>` and PROMOTED to a registry-managed (lock-backed)
* install as a side effect of its first successful sync; it is reported via
* `processed` like any other managed install from then on. Building a
* {@link ManagedInstall} view onto the plain entry lets this reuse
* {@link updateManagedInstall} verbatim rather than duplicating its lock/config
* bookkeeping.
*/
function managedInstallViewOfPlainNpm(config, npmSource) {
const id = npmSource.name ?? npmSource.path ?? "";
const spec = npmSource.path ?? "";
return {
bundleKey: id,
installId: id,
source: "npm",
ref: spec.startsWith("npm:") ? spec : `npm:${spec}`,
localRoot: "",
writable: false,
componentRoot: bundleComponent(config, id).root,
requiredRoots: [],
};
}
/**
* Sync a single registry-managed install and return the processed record:
* fetch into staging, audit, publish, write its lock entry, reindex.
*
* `yes` gates ONLY the destructive branch below (deleting a previous
* `localRoot` whose content moved) — it has no effect on the sync itself. A
* normal refresh that resolves the SAME content directory (the overwhelming
* majority of updates) never reaches that branch, so it never prompts and
* never requires `--yes` (F1/R-058: `update` must stay usable bare in
* scripts; only the branch that can `rm -rf` needs a gate, not the whole
* command).
*/
async function updateManagedInstall(managed, force, yes, stashDir, allowDangerousEnvKeys) {
const { synced, staged } = await stageManagedUpdate(managed, force);
try {
await auditStagedUpdate(staged.auditRoot, managed.ref, allowDangerousEnvKeys);
const installedEntry = {
id: managed.installId,
// Preserve the original source classification. syncFromRef() re-derives the
// source type from the ref scheme (e.g. "github:" → source: "github"), but
// an update should not reclassify an existing entry.
source: managed.source,
ref: synced.ref,
artifactUrl: synced.artifactUrl,
resolvedVersion: synced.resolvedVersion,
resolvedRevision: synced.resolvedRevision,
stashRoot: synced.contentDir,
cacheDir: synced.cacheDir,
installedAt: synced.syncedAt,
writable: synced.writable ?? managed.writable,
};
const movedRoot = managed.localRoot !== "" &&
path.resolve(managed.localRoot) !== path.resolve(synced.contentDir) &&
managed.source !== "local" &&
!managed.writable;
if (movedRoot) {
const { confirmDestructive } = await import("../../cli/confirm.js");
const confirmed = await confirmDestructive(`Update resolved a new content directory for "${managed.installId}" (${synced.contentDir}) and would delete the previous install directory at ${managed.localRoot}. This cannot be undone.`, { yes });
if (!confirmed)
throw new UsageError(`Update cancelled for "${managed.installId}"; no configuration was changed.`);
}
const index = await publishAndIndex(stashDir, async () => {
staged.publish();
await upsertLockEntry({
id: managed.bundleKey,
// Preserve the STORED install kind: a `github:`-ref entry recorded as
// source "git" must not be reclassified by the sync flow's re-derivation.
source: managed.source,
ref: synced.ref,
resolvedVersion: synced.resolvedVersion,
resolvedRevision: synced.resolvedRevision,
integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
localRoot: synced.contentDir,
installedAt: synced.syncedAt,
});
});
if (movedRoot) {
if (referencesRoot(managed.localRoot, loadConfig(), readLockfile())) {
warn(`[akm bundle update] kept previous install directory at ${managed.localRoot} because another configured or locked bundle still references it.`);
}
else {
cleanupDirectoryBestEffort(managed.localRoot, "update");
}
}
const versionChanged = (managed.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
const revisionChanged = (managed.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
return {
index,
item: {
id: managed.installId,
source: managed.source,
ref: managed.ref,
previous: {
resolvedVersion: managed.resolvedVersion,
resolvedRevision: managed.resolvedRevision,
cacheDir: managed.localRoot,
},
installed: { ...installedEntry, extractedDir: synced.extractedDir },
changed: {
version: versionChanged,
revision: revisionChanged,
any: versionChanged || revisionChanged,
},
},
};
}
finally {
staged.cleanup();
}
}
function referencesRoot(target, config, locks) {
const resolvedTarget = path.resolve(target);
const locksById = new Map(locks.map((entry) => [entry.id, entry]));
for (const [bundleId, bundle] of Object.entries(config.bundles ?? {})) {
const root = bundle.path ?? locksById.get(bundleId)?.localRoot;
if (!root)
continue;
if (isRootAtOrBelow(root, resolvedTarget))
return true;
for (const component of Object.values(bundle.components ?? {})) {
if (isRootAtOrBelow(path.resolve(root, component.root ?? "."), resolvedTarget))
return true;
}
}
return locks.some((entry) => entry.localRoot && isRootAtOrBelow(entry.localRoot, resolvedTarget));
}
function isRootAtOrBelow(candidate, root) {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
const lexicallyWithin = relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
return lexicallyWithin || isWithin(candidate, root);
}
// ── akmUpdate dispatcher ─────────────────────────────────────────────────────
export async function akmUpdate(input) {
const stashDir = input?.stashDir ?? resolveStashDir();
const target = input?.target?.trim();
const all = input?.all === true;
const force = input?.force === true;
const yes = input?.yes === true;
const allowDangerousEnvKeys = input?.allowDangerousEnvKeys === true;
const config = loadConfig();
const managedInstalls = listManagedInstalls(config);
if (target && !all) {
// Registry-managed install (lock-backed) — re-download from its locator.
const managed = resolveManagedTarget(config, target);
if (managed) {
const updated = await updateManagedInstall(managed, force, yes, stashDir, allowDangerousEnvKeys);
return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
}
// Plain git / website source (bundles without a lock) — provider re-sync.
const stashes = getSources(config);
const isUrl = target.startsWith("http://") || target.startsWith("https://");
const resolvedPath = !isUrl ? path.resolve(target) : undefined;
const gitMatch = stashes.find((s) => {
if (s.type !== "git")
return false;
if (isUrl && s.url === target)
return true;
if (resolvedPath && s.path && path.resolve(s.path) === resolvedPath)
return true;
if (s.name === target)
return true;
if (s.url) {
try {
const repo = parseGitRepoUrl(s.url);
if (repo.canonicalUrl === target)
return true;
}
catch {
// Ignore malformed config here; later provider sync will surface it.
}
}
return false;
});
if (gitMatch)
return updateGitSource(config, stashDir, target, all, gitMatch, allowDangerousEnvKeys);
const websiteMatch = stashes.find((s) => {
if (s.type !== "website")
return false;
if (isUrl && s.url === target)
return true;
if (s.name === target)
return true;
if (resolvedPath && s.path && path.resolve(s.path) === resolvedPath)
return true;
return false;
});
if (websiteMatch)
return updateWebsiteSource(stashDir, target, all, websiteMatch);
// Plain npm source (bundle without a lock) — sync via the registry
// pipeline and promote to a managed install (see
// managedInstallViewOfPlainNpm's doc comment for why npm can't stay
// plain the way git/website do).
const npmMatch = stashes.find((s) => {
if (s.type !== "npm")
return false;
if (s.name === target)
return true;
if (s.path === target)
return true;
return false;
});
if (npmMatch) {
const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, npmMatch), force, yes, stashDir, allowDangerousEnvKeys);
return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
}
const filesystemMatch = stashes.find((source) => {
if (source.type !== "filesystem")
return false;
if (source.name === target)
return true;
return resolvedPath !== undefined && source.path !== undefined && path.resolve(source.path) === resolvedPath;
});
if (filesystemMatch)
return updateFilesystemSource(stashDir, target, all, filesystemMatch);
}
const enabledManagedInstalls = all
? managedInstalls.filter((managed) => config.bundles?.[managed.bundleKey]?.enabled !== false)
: managedInstalls;
const selected = selectManagedTargets(config, enabledManagedInstalls, target, all);
const processed = [];
const plainSynced = [];
const skipped = [];
let latestIndex;
for (const managed of selected) {
try {
const updated = await updateManagedInstall(managed, force, yes, stashDir, allowDangerousEnvKeys);
processed.push(updated.item);
latestIndex = updated.index;
}
catch (error) {
if (!all)
throw error;
skipped.push(updateFailureOutcome(managed.installId, sourceKindForInstall(managed.source), error));
}
}
// `--all` must account for EVERY configured source, not only the
// registry-managed (lock-backed) ones (R-015) — `selectManagedTargets`
// above returns only `installs` for `all`, so plain sources were
// previously never even looked at. Git/website/npm plain sources are synced
// here too (npm is promoted to managed, same as the single-target path
// above). Filesystem sources reflect their files in place and are reported
// as intentionally skipped.
if (all) {
const managedKeys = new Set(managedInstalls.map((m) => m.bundleKey));
const plainSources = getSources(config).filter((source) => source.enabled !== false && !managedKeys.has(source.name ?? ""));
const filesystemSources = [];
for (const plain of plainSources) {
const id = plain.name ?? plain.path ?? plain.url ?? "";
try {
if (plain.type === "git") {
const updated = await syncGitPlainSource(config, plain, stashDir, allowDangerousEnvKeys);
plainSynced.push(updated.item);
latestIndex = updated.index;
}
else if (plain.type === "npm") {
const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, plain), force, yes, stashDir, allowDangerousEnvKeys);
processed.push(updated.item);
latestIndex = updated.index;
}
else if (plain.type === "website") {
const updated = await syncWebsitePlainSource(plain, stashDir);
plainSynced.push(updated.item);
latestIndex = updated.index;
}
else {
filesystemSources.push(plain);
}
}
catch (error) {
skipped.push(updateFailureOutcome(id, plain.type, error));
}
}
if (filesystemSources.length > 0) {
try {
// A remote source may have reconciled before a later source finished
// hydrating, so an incomplete intermediate result is not authoritative
// for the final `--all` outcome. Retry once after every source update;
// a genuinely missing filesystem root remains incomplete and is
// reported below without claiming reconciliation.
if (!latestIndex?.scanComplete)
latestIndex = await akmIndex({ stashDir, hydrateSources: false });
if (latestIndex.scanComplete) {
for (const source of filesystemSources) {
plainSynced.push({
id: source.name ?? source.path ?? "",
kind: "filesystem",
ref: source.path ?? source.name ?? "",
});
}
}
else {
for (const source of filesystemSources) {
skipped.push(incompleteFilesystemOutcome(source.name ?? source.path ?? ""));
}
}
}
catch (error) {
for (const source of filesystemSources) {
skipped.push(updateFailureOutcome(source.name ?? source.path ?? "", "filesystem", error));
}
}
}
}
return buildUpdateResponse(stashDir, target, all, processed, {
plainSynced,
skipped,
index: latestIndex ?? readCurrentIndexSummary(),
});
}
function sourceKindForInstall(source) {
if (source === "local")
return "filesystem";
if (source === "github")
return "git";
return source;
}
function updateFailureOutcome(id, kind, error) {
const code = error instanceof AkmError ? error.code : undefined;
const status = code === "DANGEROUS_ENV_KEY" || code === "DANGEROUS_ENV_AUDIT_FAILED" ? "blocked" : "failed";
const message = error instanceof Error ? error.message : String(error);
return {
id,
kind,
status,
...(code ? { code } : {}),
reason: `${status}: ${message}`,
};
}
function selectManagedTargets(config, installs, target, all) {
if (all && target) {
throw new UsageError("Specify either <target> or --all, not both.", "MISSING_OR_AMBIGUOUS_TARGET");
}
if (all)
return installs;
if (!target) {
throw new UsageError("Either <target> or --all is required.", "MISSING_OR_AMBIGUOUS_TARGET");
}
const found = resolveManagedTarget(config, target);
if (found)
return [found];
throw new NotFoundError(`No matching source for target: ${target}`, "SOURCE_NOT_FOUND");
}
/**
* Best-effort removal of a directory that is no longer referenced by config
* or the lockfile. `context` labels the call site (e.g. "remove", "update")
* in the warning so an operator can tell which command left the directory
* behind. Failure does not throw — callers already committed the config/lock
* change that made `target` orphaned — but it must not be silent either
* (F1/R-058): a swallowed `rmSync` error used to leave no trace anywhere the
* caller could inspect, so a confirmed deletion that then failed (permission
* error, file in use, …) looked identical to a successful one.
*/
function cleanupDirectoryBestEffort(target, context) {
try {
fs.rmSync(target, { recursive: true, force: true });
}
catch (err) {
warn(`[akm ${context}] failed to remove directory ${target}: ${err instanceof Error ? err.message : String(err)}. ` +
"Remove it manually if it is no longer needed.");
}
}
function directoryExists(target) {
try {
return fs.statSync(target).isDirectory();
}
catch {
return false;
}
}