UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

284 lines (260 loc) • 10.2 kB
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; }