@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
284 lines (260 loc) • 10.2 kB
text/typescript
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
export const KXM_UPDATE_SCHEMA = "kxm.update.v1" as const;
export const KXM_UPDATE_CACHE = "update-check.json";
const CHECK_TIMEOUT_MS = 2_500;
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const SHA256_DIGEST = /^sha256:([0-9a-fA-F]{64})$/;
export type KxmUpdateSource = "npm" | "github";
export type KxmApplySource = KxmUpdateSource | "local";
export interface KxmReleaseAsset {
name: string;
sha256: string;
}
export interface KxmUpdateConfig {
schema: typeof KXM_UPDATE_SCHEMA;
auto: boolean;
source: KxmUpdateSource;
}
export interface KxmUpdateNotice {
current: string;
latest?: string;
available: boolean;
auto: boolean;
source: KxmApplySource;
message: string;
asset?: KxmReleaseAsset;
localRoot?: string;
}
export class KxmUpdateConfigError extends Error {
readonly code = "kxm_update_config_invalid" as const;
constructor(message: string) {
super(message);
this.name = "KxmUpdateConfigError";
}
}
export function kxmReleaseAssetName(version: string): string {
return `kxm-${version}.tgz`;
}
export function kxmReleaseDownloadUrl(version: string, name = kxmReleaseAssetName(version)): string {
return `https://github.com/kontextmind/kxm/releases/download/v${version}/${name}`;
}
/** `gh` is optional. Hosts without it download the same asset with curl and still verify the digest. */
export function ghReleaseDownloadAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
const result = spawnSync("gh", ["--version"], {
encoding: "utf8",
env,
windowsHide: true,
timeout: 15_000,
});
return !result.error && result.status === 0;
}
export function readInstalledKxmVersion(root: string): string {
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: unknown };
if (typeof pkg.version !== "string" || !parseSemver(pkg.version)) {
throw new KxmUpdateConfigError("package.json version is missing or not a semver");
}
return pkg.version;
}
/**
* `readInstalledKxmVersion` without the raw filesystem failure. The installed
* version is read to decide whether a candidate is an upgrade, a downgrade or
* the same thing — so a directory that has no readable `package.json` must come
* back as "unknown" and be refused in words, not surface as an uncaught ENOENT
* stack from `kxm update`.
*/
export function installedKxmVersion(root: string): string | undefined {
try {
return readInstalledKxmVersion(root);
} catch {
return undefined;
}
}
export function parseSemver(value: string): [number, number, number] | undefined {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
if (!match) return undefined;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function compareSemver(left: string, right: string): number | undefined {
const a = parseSemver(left);
const b = parseSemver(right);
if (!a || !b) return undefined;
for (let i = 0; i < 3; i += 1) {
if (a[i]! !== b[i]!) return a[i]! < b[i]! ? -1 : 1;
}
return 0;
}
export function formatKxmUpdateNotice(notice: KxmUpdateNotice): string {
if (!notice.available) return notice.message;
const apply = notice.auto ? "kxm update (auto)" : "kxm update";
return `kxm ${notice.current} → ${notice.latest} available · ${apply}`;
}
function withAsset(notice: KxmUpdateNotice, asset?: KxmReleaseAsset): KxmUpdateNotice {
return asset ? { ...notice, asset } : notice;
}
export function noticeFromVersions(
current: string,
latest: string | undefined,
config: KxmUpdateConfig,
checkError?: string,
asset?: KxmReleaseAsset,
): KxmUpdateNotice {
if (checkError || !latest) {
return withAsset({
current,
auto: config.auto,
source: config.source,
available: false,
message: checkError ? `kxm update check unavailable (${checkError})` : `kxm ${current}`,
}, asset);
}
const cmp = compareSemver(current, latest);
if (cmp === undefined) {
return withAsset({
current,
latest,
auto: config.auto,
source: config.source,
available: false,
message: "kxm update check unavailable (non-semver)",
}, asset);
}
if (cmp >= 0) {
return withAsset({
current,
latest,
auto: config.auto,
source: config.source,
available: false,
message: `kxm ${current}`,
}, asset);
}
const available: KxmUpdateNotice = {
current,
latest,
auto: config.auto,
source: config.source,
available: true,
message: "",
...(asset ? { asset } : {}),
};
available.message = formatKxmUpdateNotice(available);
return available;
}
export function readUpdateCache(stateDir: string, now = Date.now()): KxmUpdateNotice | undefined {
const path = join(stateDir, KXM_UPDATE_CACHE);
if (!existsSync(path)) return undefined;
try {
const row = JSON.parse(readFileSync(path, "utf8")) as {
checkedAt?: unknown;
notice?: KxmUpdateNotice;
};
if (typeof row.checkedAt !== "number" || !row.notice || now - row.checkedAt > CACHE_TTL_MS) return undefined;
if (typeof row.notice.current !== "string" || typeof row.notice.available !== "boolean") return undefined;
return row.notice;
} catch {
return undefined;
}
}
export function writeUpdateCache(stateDir: string, notice: KxmUpdateNotice, now = Date.now()): void {
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, KXM_UPDATE_CACHE), `${JSON.stringify({ checkedAt: now, notice })}\n`, { encoding: "utf8" });
}
function githubReleaseAsset(assets: unknown, version: string): KxmReleaseAsset | undefined {
if (!Array.isArray(assets)) return undefined;
const name = kxmReleaseAssetName(version);
const match = assets.find((item) => item && typeof item === "object" && (item as { name?: unknown }).name === name);
if (!match || typeof match !== "object") return undefined;
const digest = (match as { digest?: unknown }).digest;
if (typeof digest !== "string") return undefined;
const parsed = SHA256_DIGEST.exec(digest.trim());
if (!parsed) return undefined;
return { name, sha256: parsed[1]!.toLowerCase() };
}
export async function fetchLatestKxmVersion(
source: KxmUpdateSource,
env: NodeJS.ProcessEnv,
fetchImpl: typeof fetch = fetch,
): Promise<{ latest?: string; error?: string; asset?: KxmReleaseAsset }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
try {
if (source === "npm") {
const response = await fetchImpl("https://registry.npmjs.org/@kontextmind/kxm/latest", { signal: controller.signal });
if (!response.ok) return { error: `npm_http_${response.status}` };
const body = await response.json() as { version?: unknown };
if (typeof body.version !== "string" || !parseSemver(body.version)) return { error: "npm_version_invalid" };
return { latest: body.version };
}
const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
const token = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim();
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetchImpl("https://api.github.com/repos/kontextmind/kxm/releases/latest", {
signal: controller.signal,
headers,
});
if (!response.ok) return { error: `github_http_${response.status}` };
const body = await response.json() as { tag_name?: unknown; assets?: unknown };
if (typeof body.tag_name !== "string") return { error: "github_tag_invalid" };
const version = body.tag_name.replace(/^v/, "");
if (!parseSemver(version)) return { error: "github_version_invalid" };
const asset = githubReleaseAsset(body.assets, version);
return asset ? { latest: version, asset } : { latest: version };
} catch (error) {
const aborted = error instanceof Error && error.name === "AbortError";
return { error: aborted ? "timeout" : "unreachable" };
} finally {
clearTimeout(timer);
}
}
export type KxmPackageUpdateStep =
| { kind: "download"; command: "gh" | "curl"; args: string[] }
| { kind: "verify"; path: string; sha256: string }
| { kind: "install"; command: "npm"; args: string[] };
export function verifyReleaseAssetDigest(path: string, sha256: string): boolean {
if (!existsSync(path)) return false;
try {
const actual = createHash("sha256").update(readFileSync(path)).digest("hex");
return actual === sha256.toLowerCase();
} catch {
return false;
}
}
/** npm is the default source and npm verifies registry integrity itself. "local" installs this repository's checkout. "github" downloads the release tarball (gh, or curl without gh) and verifies its sha256. */
export function planKxmPackageUpdate(
source: KxmApplySource,
latest: string,
releaseDir: string,
asset?: KxmReleaseAsset,
downloader: "gh" | "curl" = "gh",
localRoot?: string,
): KxmPackageUpdateStep[] {
if (source === "local") {
if (!localRoot) return [];
return [{ kind: "install", command: "npm", args: ["install", "--global", "--omit=peer", localRoot] }];
}
if (source === "npm") {
return [{ kind: "install", command: "npm", args: ["install", "--global", "--omit=peer", `@kontextmind/kxm@${latest}`] }];
}
const name = asset?.name ?? kxmReleaseAssetName(latest);
const steps: KxmPackageUpdateStep[] = [
downloader === "curl"
? {
kind: "download",
command: "curl",
args: ["--fail", "--silent", "--show-error", "--location", "--output", join(releaseDir, name), kxmReleaseDownloadUrl(latest, name)],
}
: {
kind: "download",
command: "gh",
args: ["release", "download", `v${latest}`, "--repo", "kontextmind/kxm", "--pattern", name, "--dir", releaseDir, "--clobber"],
},
];
if (asset?.sha256) {
steps.push({ kind: "verify", path: join(releaseDir, name), sha256: asset.sha256 });
}
steps.push({ kind: "install", command: "npm", args: ["install", "--global", "--omit=peer", join(releaseDir, name)] });
return steps;
}