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
158 lines (157 loc) • 6.69 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/.
import fs from "node:fs";
import path from "node:path";
import embeddedChangelog from "../../../CHANGELOG.md" with { type: "text" };
import { getDirname } from "../../runtime.js";
const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
/**
* Directory containing per-version release notes. Resolved relative to
* `import.meta.dir` so the lookup works whether this module is running
* from source (`<repo>/src`) or from the published build (`<pkg>/dist`).
* The `docs/migration/release-notes/` directory is shipped via the
* `files[]` array in `package.json`.
*/
function releaseNotesDir() {
return path.resolve(getDirname(import.meta.url), "../../../docs/migration/release-notes");
}
function loadChangelog() {
try {
const changelogPath = path.resolve(getDirname(import.meta.url), "../../../CHANGELOG.md");
if (fs.existsSync(changelogPath)) {
return fs.readFileSync(changelogPath, "utf8");
}
}
catch {
// fall through to the embedded copy
}
// In the `bun build --compile` standalone binary, import.meta.url points into
// the virtual /$bunfs tree and every existsSync above misses, so `akm help
// migrate <version>` degraded to the generic "no dedicated note" message for
// EVERY version. Only assets imported `with { type: "text" }` are embedded.
return embeddedChangelog.length > 0 ? embeddedChangelog : undefined;
}
/**
* Load the bundled migration note for a specific version, if one exists.
* Returns the file body verbatim (no transformations). Missing files,
* permission errors, and non-file entries all return `undefined` —
* callers are responsible for the fallback message.
*/
function loadReleaseNote(version) {
if (!isSafeVersionComponent(version))
return undefined;
const notePath = path.join(releaseNotesDir(), `${version}.md`);
try {
if (!fs.existsSync(notePath))
return undefined;
const stat = fs.statSync(notePath);
if (!stat.isFile())
return undefined;
return fs.readFileSync(notePath, "utf8");
}
catch {
return undefined;
}
}
/**
* Restrict version lookups to strings that are safe as a single path
* segment. Accepts typical semver forms (`0.6.0`, `0.6.0-rc1`,
* `0.6.0+build.5`) and rejects anything with slashes, `..`, or control
* characters that would let a crafted input escape the release-notes
* directory.
*/
function isSafeVersionComponent(version) {
if (!version || version.length > 64)
return false;
return /^[A-Za-z0-9._+-]+$/.test(version) && !version.includes("..");
}
function normalizeRequestedVersion(input) {
const value = input.trim();
if (!value)
return value;
if (value.toLowerCase() === "latest")
return "latest";
const withoutV = value.replace(/^v/i, "");
return withoutV;
}
function versionCandidates(requested) {
if (requested === "latest")
return ["latest"];
const exact = requested;
const stable = requested.replace(/[-+].*$/, "");
return stable === exact ? [exact] : [exact, stable];
}
function resolveLatestVersion(changelog) {
for (const match of changelog.matchAll(/^## \[([^\]]+)\]/gm)) {
const version = match[1];
if (version !== "Unreleased")
return version;
}
return undefined;
}
function extractChangelogSection(changelog, version) {
// `\Z` is not a JavaScript anchor — it matches a literal "Z", which truncated
// the section at the first capital Z in the body (and failed outright for the
// last entry). `$` with the `m` flag would stop at the first line end, so the
// end-of-input alternative has to be an explicit lookahead for the input end.
const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|$(?![\\s\\S]))`, "m");
const match = changelog.match(pattern);
if (!match)
return undefined;
return `## [${version}]\n${match[1].trim()}\n`;
}
function escapeRegexString(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function fallbackGuide(version) {
const bundled = loadReleaseNote(version);
if (bundled)
return `${bundled.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
const available = listBundledReleaseVersions();
const availableLine = available.length ? `\nAvailable bundled notes: ${available.join(", ")}\n` : "";
return (`No dedicated migration note is bundled for akm v${version}.\n${availableLine}\n` +
`See the full changelog: ${CHANGELOG_URL}\n` +
`Longform migration guide: ${MIGRATION_DOC_URL}\n`);
}
export function renderMigrationHelp(versionInput, changelogText = loadChangelog()) {
const requested = normalizeRequestedVersion(versionInput);
if (!requested) {
return `Version is required.\n\nUsage: akm help migrate <version>\n`;
}
const resolvedLatest = changelogText ? resolveLatestVersion(changelogText) : undefined;
const candidates = requested === "latest" && resolvedLatest ? [resolvedLatest] : versionCandidates(requested);
if (changelogText) {
for (const candidate of candidates) {
const section = extractChangelogSection(changelogText, candidate);
const bundled = loadReleaseNote(candidate);
if (bundled) {
if (!section)
return `${bundled.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
return `${bundled.trim()}\n\nRelease notes\n-------------\n${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
}
if (section) {
return `${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
}
}
}
const fallbackVersion = candidates.find((candidate) => candidate !== "latest") ?? requested;
return fallbackGuide(fallbackVersion);
}
/** Test-only helper — list every version with a bundled release note. */
export function listBundledReleaseVersions() {
try {
const dir = releaseNotesDir();
if (!fs.existsSync(dir))
return [];
return fs
.readdirSync(dir)
.filter((name) => name.endsWith(".md") && name !== "README.md")
.map((name) => name.slice(0, -".md".length))
.sort();
}
catch {
return [];
}
}