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
278 lines (277 loc) • 14.3 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/.
/**
* Shared ref-resolution helpers for the `env` and `secret` command families
* (WS6). These were duplicated/co-located inline in `src/cli.ts`; hoisting them
* here lets `src/commands/env/env-cli.ts` and `src/commands/env/secret-cli.ts` import a
* single copy of the parse/resolve/make + path-traversal-guard logic (the WS6
* "env traversal-guard copies 5 → 1" KPI). Behaviour is byte-identical to the
* inline forms: env/secret VALUES are never read or surfaced here — these
* helpers only resolve refs to absolute paths and guard against directory
* traversal.
*/
import fs from "node:fs";
import path from "node:path";
import { resolveSourceEntries } from "../indexer/search/search-source.js";
import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "./asset/asset-create.js";
import { assetPathForName, deriveCanonicalAssetName } from "./asset/asset-placement.js";
import { displayRef, isFullRefInput, parseRefInput } from "./asset/resolve-ref.js";
import { isWithin } from "./common.js";
import { loadConfig } from "./config/config.js";
import { NotFoundError, UsageError } from "./errors.js";
import { resolveMutationTarget } from "./mutation-target.js";
import { sensitiveMarkerPath } from "./sensitive-marker-path.js";
import { formatRefForMessage, withWriteTargetMutation } from "./write-source.js";
export { sensitiveMarkerPath } from "./sensitive-marker-path.js";
/**
* Walk each stash's env files and return one entry per `.env` file, using the
* env asset spec's canonical-name logic (e.g. `env/team/prod.env` →
* `env/team/prod`, `env/team/.env` → `env/team/default`). Moved here from
* `commands/env/env-cli.ts` (#950) so `commands/health` can reuse it to name
* which env asset supplies a credential's variable — pure move, `akm env
* list`'s behaviour is unchanged. `listKeysFn` stays injected (rather than a
* static import of `commands/env/env.ts`) so this core module never depends
* upward on the commands layer. `config` defaults to the real `loadConfig()`
* (unchanged default for `akm env list`); `commands/health` passes its own
* injected/resolved config so a test-supplied `loadConfig` seam is honoured
* instead of this always re-reading the real config/sources.
*/
export function listEnvsRecursive(listKeysFn, config = loadConfig()) {
const result = [];
for (const source of resolveSourceEntries(undefined, config)) {
const root = path.join(source.path, "env");
if (!fs.existsSync(root))
continue;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (!entry.isFile())
continue;
if (entry.name !== ".env" && !entry.name.endsWith(".env"))
continue;
const canonical = deriveCanonicalAssetName("env", root, full);
if (!canonical)
continue;
// Skip sensitive envs: a sibling .sensitive marker file suppresses listing.
const markerPath = sensitiveMarkerPath(full, "env");
if (fs.existsSync(markerPath))
continue;
const { keys } = listKeysFn(full);
result.push({ ref: makeEnvRef(canonical, source, config), path: full, keys });
}
};
walk(root);
}
return result;
}
/**
* The `vault` asset type was removed in 0.9.0. The env/secret input path no
* longer routes through the legacy stored-ref parser (which carries the removal
* signpost), so a `vault:`/`vault/` leading token would otherwise be silently
* qualified into an `env/vault:…` not-found. Detect it here and re-emit the
* migration signpost so 0.8→0.9 muscle memory still gets pointed at env/secret.
*/
function assertNotRemovedVaultRef(ref) {
const boundary = ref.indexOf("//");
const bare = boundary >= 0 ? ref.slice(boundary + 2) : ref;
if (/^vault[:/]/.test(bare.trim())) {
throw new UsageError("The `vault` asset type was removed in 0.9.0 — use `env/` (whole .env config) or `secrets/` (a single value).", "INVALID_FLAG_VALUE");
}
}
/**
* Q-08 ruling: the pre-0.9.0 `type:name` ref grammar taught `env:<name>` /
* `secret:<name>` (help text and docs also implied `environment:`/`secrets:`
* variants) as the way to address a single env/secret. That grammar is GONE —
* NO alias, no re-acceptance (same rule as the retired `vault:` prefix above).
* Left unchecked, a colon-prefixed ref does not error here at all: it falls
* through to the "bare name" convenience below and gets silently qualified
* into a literal `env/env:name` (or `secrets/secret:name`) file that can never
* exist — a confusing not-found that hides the real mistake instead of naming
* it. Reject it here, loudly, before that happens.
*/
function assertNotColonRef(ref, aliases, replacement) {
const boundary = ref.indexOf("//");
const bare = (boundary >= 0 ? ref.slice(boundary + 2) : ref).trim();
const colon = bare.indexOf(":");
if (colon <= 0)
return;
const head = bare.slice(0, colon).toLowerCase();
if (!aliases.includes(head))
return;
const name = bare.slice(colon + 1);
throw new UsageError(`The \`${head}:\` ref spelling was removed in 0.9.0 — use the slash form instead: \`${replacement}${name}\`.`, "INVALID_FLAG_VALUE");
}
export function parseEnvRef(ref) {
// Accept a bare env name (`prod`, `sub/prod`) or the new-grammar
// `[bundle//]env/name` conceptId. A bare name's leading segment maps to no
// asset type, so it is qualified with the `env/` conceptId prefix; anything
// already a full new-grammar ref is parsed as-is.
assertNotRemovedVaultRef(ref);
assertNotColonRef(ref, ["env", "environment"], "env/");
return parseRefInput(isFullRefInput(ref) ? ref : `env/${ref}`);
}
export function findEnvSource(origin, type, name) {
const sources = resolveSourceEntries(undefined, loadConfig());
if (sources.length === 0) {
throw new UsageError("No bundles configured. Run `akm bundle create` to create your working bundle.");
}
const candidates = origin ? resolveSourcesForOrigin(origin, sources) : sources;
const typeDir = type === "env" ? "env" : "secrets";
const member = candidates.find((source) => fs.existsSync(assetPathForName(type, path.join(source.path, typeDir), name)));
if (member)
return member;
if (!origin) {
const fallback = candidates[0];
if (fallback)
return fallback;
throw new UsageError("No bundles configured. Run `akm bundle create` to create your working bundle.");
}
const named = candidates[0];
if (!named) {
throw new NotFoundError(`Source not found for origin: ${origin}`);
}
return named;
}
/**
* `config` defaults to the real `loadConfig()`, same as {@link displayDefaultBundle}.
* `listEnvsRecursive` threads its own (possibly injected) config through here so a
* caller-supplied config governs ref display the same way it governs which stashes
* get walked — otherwise this would silently fall back to reading the real config a
* second time even when the walk itself honoured an injected one.
*/
export function makeEnvRef(name, source, config = loadConfig()) {
// F4b output-spelling flip: `env/name` in the primary stash, `bundle//env/name`
// for a slug-clean named source.
return displayRef({ type: "env", name, bundleId: source?.registryId }, displayDefaultBundle(source, config));
}
/**
* Resolve an env ref to an absolute `.env` path. Accepts the `env/<name>`
* conceptId (or a bare name, auto-qualified into it) — the retired
* `env:`/`environment:` colon spelling is rejected loudly (Q-08), never
* silently resolved. The path is returned even when the file does not yet
* exist (so `create` writes under `env/`).
*/
export function resolveEnvPath(ref) {
const parsed = parseEnvRef(ref);
if (parsed.type !== "env") {
throw new UsageError(`Expected an env ref (env/<name>); got "${ref}".`);
}
const source = findEnvSource(parsed.origin, "env", parsed.name);
const envRoot = path.join(source.path, "env");
const envPath = assetPathForName("env", envRoot, parsed.name);
// Defense-in-depth: ensure the resolved path stays inside the env directory.
// validateName already rejects traversal patterns like "../../foo", but an
// absolute-path override or symlink-based attack could still escape without
// this second check.
if (!isWithin(envPath, envRoot)) {
throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
}
return { name: parsed.name, absPath: envPath, source, parsedRef: parsed, dir: "env" };
}
export function parseSecretRef(ref) {
// Same bare-name-vs-full-ref rule as parseEnvRef; a bare name is qualified
// with the `secrets/` conceptId prefix (secret's stash subdir).
assertNotRemovedVaultRef(ref);
assertNotColonRef(ref, ["secret", "secrets"], "secrets/");
return parseRefInput(isFullRefInput(ref) ? ref : `secrets/${ref}`);
}
export function makeSecretRef(name, source) {
// F4b output-spelling flip: `secrets/name` in the primary stash,
// `bundle//secrets/name` for a slug-clean named source.
return displayRef({ type: "secret", name, bundleId: source?.registryId }, displayDefaultBundle(source));
}
function displayDefaultBundle(source, config = loadConfig()) {
if (config.defaultBundle || !source)
return config.defaultBundle;
const primary = resolveSourceEntries(undefined, config)[0];
return primary && path.resolve(primary.path) === path.resolve(source.path) ? source.registryId : undefined;
}
export function resolveSecretPath(ref,
// Create-only (`secret set`): enforce a flat ref name and apply `--path` as
// the subdirectory. Lookup callers omit this so nested refs keep resolving.
create) {
const parsed = parseSecretRef(ref);
if (parsed.type !== "secret") {
throw new UsageError(`Expected a secret ref (secrets/<name>); got "${ref}".`);
}
if (create) {
assertFlatAssetName(parsed.name);
parsed.name = combineCreatePath(normalizeCreateSubPath(create.subPath), parsed.name);
}
// Source resolution is identical for every asset type; reuse the env helper.
const source = findEnvSource(parsed.origin, "secret", parsed.name);
const typeRoot = path.join(source.path, "secrets");
const absPath = assetPathForName("secret", typeRoot, parsed.name);
// Defense-in-depth: ensure the resolved path stays inside the secrets dir.
if (!isWithin(absPath, typeRoot)) {
throw new UsageError(`Secret name "${parsed.name}" escapes the secrets directory.`);
}
return { name: parsed.name, absPath, source };
}
/**
* Resolve the destination for an env mutation. Mirrors {@link resolveEnvPath}
* but selects the source via {@link resolveWriteTarget} (writability-checked)
* instead of the read-side {@link findEnvSource}. `create` enforces a flat ref
* name and applies `--path` as the subdirectory (matching `env create`).
*/
export function resolveEnvWriteTarget(ref, writeTarget, create) {
const parsed = parseEnvRef(ref);
if (parsed.type !== "env") {
throw new UsageError(`Expected an env ref (env/<name>); got "${ref}".`);
}
if (create) {
assertFlatAssetName(parsed.name);
parsed.name = combineCreatePath(normalizeCreateSubPath(create.subPath), parsed.name);
}
const resolved = resolveMutationTarget(loadConfig(), parsed, writeTarget, { allowedAdapters: ["akm", "dotenv"] });
const { target } = resolved;
const envRoot = path.join(target.source.path, "env");
const absPath = assetPathForName("env", envRoot, parsed.name);
if (!isWithin(absPath, envRoot)) {
throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
}
return { name: parsed.name, absPath, target, parsedRef: resolved.ref, ref: resolved.displayRef };
}
/**
* Resolve the destination for a secret mutation. Mirrors
* {@link resolveSecretPath} but selects the source via
* {@link resolveWriteTarget} (writability-checked) instead of the read-side
* {@link findEnvSource}.
*/
export function resolveSecretWriteTarget(ref, writeTarget, create) {
const parsed = parseSecretRef(ref);
if (parsed.type !== "secret") {
throw new UsageError(`Expected a secret ref (secrets/<name>); got "${ref}".`);
}
if (create) {
assertFlatAssetName(parsed.name);
parsed.name = combineCreatePath(normalizeCreateSubPath(create.subPath), parsed.name);
}
const resolved = resolveMutationTarget(loadConfig(), parsed, writeTarget, { allowedAdapters: ["akm", "dotenv"] });
const { target } = resolved;
const typeRoot = path.join(target.source.path, "secrets");
const absPath = assetPathForName("secret", typeRoot, parsed.name);
if (!isWithin(absPath, typeRoot)) {
throw new UsageError(`Secret name "${parsed.name}" escapes the secrets directory.`);
}
return { name: parsed.name, absPath, target, ref: resolved.displayRef };
}
/**
* Land an env/secret mutation on its write target's git boundary. Mirrors the
* tasks/knowledge write path: record each mutated path, then fire the single
* batch-at-boundary commit. Both steps are no-ops for filesystem targets (the
* primary stash included) and for `env/` paths a stash `.gitignore` excludes, so
* callers invoke it unconditionally after every create/ingest/set/remove.
*/
export function withEnvSecretWrite(target, ref, op, paths, mutate) {
return withWriteTargetMutation(target, paths, {
purpose: `${ref.type}-${op.toLowerCase()}`,
message: `${op} ${formatRefForMessage({ type: ref.type, name: ref.name, origin: target.source.name })}`,
}, mutate);
}