@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
197 lines (175 loc) • 7.1 kB
JavaScript
/**
* Bump the package version across all tracked surfaces.
*
* Usage:
* node scripts/kxm-bump-version.mjs <new-version> # explicit version
* node scripts/kxm-bump-version.mjs patch # bump patch
* node scripts/kxm-bump-version.mjs minor # bump minor
* node scripts/kxm-bump-version.mjs major # bump major
*
* Surfaces updated (must stay in sync with scripts/check-versions.mjs — both
* take their package list from scripts/package-surfaces.mjs):
* 1. package.json .version
* 2. package-lock.json .version + .packages[""].version
* + .packages[<workspace path>].version
* 3. plugins/kxm/package.json .version
* 4. plugins/kxm/.claude-plugin/plugin.json .version
* 5. .claude-plugin/marketplace.json .plugins[name=kxm].version
* 6. plugins/kxm/src/mcp-server.ts const VERSION = "..."
* 7. plugins/kxm/dist/mcp-server.js const VERSION = "..." (rebuilt)
* 8. each workspace manifest scanned under packages/ —
* packages/<name>/package.json and packages/<tier>/<name>/package.json
*
* The lockfile is patched by key, never by searching for the old version string:
* a third-party dependency that happens to carry the same version as the
* product must not be rewritten into a phantom artifact.
*
* After running this script, run `npm run build` to rebuild dist bundles.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { workspaceManifestPaths } from "./package-surfaces.mjs";
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
export function parseSemver(version) {
const match = SEMVER_RE.exec(version);
if (!match) return undefined;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4],
build: match[5],
};
}
export function bumpVersion(current, kind) {
const parsed = parseSemver(current);
if (!parsed) throw new Error(`invalid current version: ${current}`);
switch (kind) {
case "major":
return `${parsed.major + 1}.0.0`;
case "minor":
return `${parsed.major}.${parsed.minor + 1}.0`;
case "patch":
return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
default:
throw new Error(`unknown bump kind: ${kind} (use major, minor, or patch)`);
}
}
/** Replace a version string in a JSON file using targeted string patching. */
function patchJsonVersion(filePath, oldVersion, newVersion) {
const raw = readFileSync(filePath, "utf8");
const escaped = oldVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`("version"\\s*:\\s*)"${escaped}"`, "g");
const patched = raw.replace(pattern, `$1"${newVersion}"`);
if (patched === raw) {
throw new Error(`no version field found to patch in ${filePath}`);
}
writeFileSync(filePath, patched, "utf8");
}
/** Replace a version string in a TypeScript/JavaScript source file. */
function patchSourceVersion(filePath, newVersion) {
const raw = readFileSync(filePath, "utf8");
const patched = raw.replace(
/const VERSION = "[^"]+"/,
`const VERSION = "${newVersion}"`,
);
if (patched === raw) {
throw new Error(`no VERSION constant found in ${filePath}`);
}
writeFileSync(filePath, patched, "utf8");
}
/**
* Patch the lockfile structurally: the root entry and each workspace package
* entry, by key. Everything else — including an unrelated dependency whose
* version string equals ours — is left byte-for-byte alone.
*/
function patchLockVersions(root, newVersion) {
const file = resolve(root, "package-lock.json");
const raw = readFileSync(file, "utf8");
let data;
try {
data = JSON.parse(raw);
} catch (error) {
throw new Error(`package-lock.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
const touched = [];
if (typeof data.version === "string") {
data.version = newVersion;
touched.push("version");
}
if (data.packages?.[""]) {
data.packages[""].version = newVersion;
touched.push('packages[""]');
}
for (const rel of workspaceManifestPaths(root)) {
const entry = data.packages?.[rel];
if (!entry) {
throw new Error(`package-lock.json has no entry for workspace package ${rel}; run npm install to resync the lock`);
}
entry.version = newVersion;
touched.push(`packages[${rel}]`);
}
if (touched.length === 0) throw new Error("no version fields found to patch in package-lock.json");
const trailingNewline = raw.endsWith("\n") ? "\n" : "";
writeFileSync(file, `${JSON.stringify(data, null, 2)}${trailingNewline}`, "utf8");
return touched;
}
export function applyVersionBump(root, newVersion) {
if (!parseSemver(newVersion)) {
throw new Error(`invalid semver: ${newVersion}`);
}
// Read current version
const pkgPath = resolve(root, "package.json");
const oldVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version;
// JSON manifests (targeted patch preserves formatting). Workspace packages are
// scanned, not hard-coded: a new package must not be releasable-then-forgotten.
const jsonFiles = [
"package.json",
"plugins/kxm/package.json",
"plugins/kxm/.claude-plugin/plugin.json",
".claude-plugin/marketplace.json",
...workspaceManifestPaths(root).map((rel) => `${rel}/package.json`),
];
for (const rel of jsonFiles) {
patchJsonVersion(resolve(root, rel), oldVersion, newVersion);
}
// Lockfile by key, after the manifests so a missing workspace entry fails loudly.
patchLockVersions(root, newVersion);
// TypeScript source
patchSourceVersion(resolve(root, "plugins/kxm/src/mcp-server.ts"), newVersion);
// Generated dist (update in place if it exists; check:generated rebuilds anyway)
const distPath = resolve(root, "plugins/kxm/dist/mcp-server.js");
try {
patchSourceVersion(distPath, newVersion);
} catch {
// dist may not exist yet; will be rebuilt by npm run build
}
return { oldVersion, newVersion };
}
export function main(argv = process.argv.slice(2), root = process.cwd()) {
const arg = argv[0];
if (!arg) {
process.stderr.write("usage: node scripts/kxm-bump-version.mjs <version|patch|minor|major>\n");
return 1;
}
const pkgPath = resolve(root, "package.json");
const currentVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version;
let newVersion;
if (SEMVER_RE.test(arg)) {
newVersion = arg;
} else {
newVersion = bumpVersion(currentVersion, arg);
}
if (newVersion === currentVersion) {
process.stdout.write(`version unchanged: ${currentVersion}\n`);
return 0;
}
const result = applyVersionBump(root, newVersion);
process.stdout.write(`bumped ${result.oldVersion} -> ${result.newVersion}\n`);
return 0;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = main();
}