UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

376 lines (347 loc) • 14.3 kB
#!/usr/bin/env node import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; const SHA256_DIGEST = /^sha256:([0-9a-fA-F]{64})$/i; export class KxmPublishNpmError extends Error { constructor(code, message, extra = {}) { super(message); this.name = "KxmPublishNpmError"; this.code = code; for (const [key, value] of Object.entries(extra)) { if (key === "message" || key === "code") continue; this[key] = value; } } } export function normalizeSha256Digest(value) { if (typeof value !== "string") return undefined; const match = SHA256_DIGEST.exec(value.trim()); return match ? match[1].toLowerCase() : undefined; } export function fileSha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } export function bufferSha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } export function interpretPublishedReleaseLookup(result) { if (result?.error === "network") { return { ok: false, code: "lookup_network", message: "release lookup network failure" }; } if (result?.error === "auth") { return { ok: false, code: "lookup_auth", message: "release lookup auth failure" }; } const status = result?.status; if (status === 404) { return { ok: false, code: "release_not_found", message: "release for tag not found" }; } if (status === 401 || status === 403) { return { ok: false, code: "lookup_auth", status, message: `release lookup auth failure (${status})` }; } if (typeof status !== "number" || status < 200 || status >= 300) { return { ok: false, code: "lookup_http", status, message: `release lookup failed (${status ?? "no status"})` }; } const body = result.body; if (!body || typeof body !== "object" || Array.isArray(body)) { return { ok: false, code: "lookup_http", message: "release lookup returned invalid body" }; } if (body.draft === true) { return { ok: false, code: "release_still_draft", id: body.id, message: "GitHub release is still a draft; must be published (draft: false) before npm publish", }; } const assets = Array.isArray(body.assets) ? body.assets : []; return { ok: true, id: body.id, draft: false, assets }; } export async function fetchPublishedRelease(api, tag) { const lookupRaw = await api.getReleaseByTag(tag); const lookup = interpretPublishedReleaseLookup(lookupRaw); if (lookup.ok) return lookup; if (lookup.code === "release_not_found" && typeof api.listReleases === "function") { const listRaw = await api.listReleases(1); if (Array.isArray(listRaw?.body)) { const match = listRaw.body.find((item) => item?.tag_name === tag); if (match?.draft === true) { return { ok: false, code: "release_still_draft", id: match.id, message: "GitHub release is still a draft; must be published (draft: false) before npm publish", }; } } } return lookup; } export function findPublishedAsset(assets, assetName) { if (!Array.isArray(assets)) { return { ok: false, code: "asset_not_found", message: "assets must be an array" }; } const found = assets.find((item) => item && typeof item === "object" && item.name === assetName); if (!found || typeof found.id !== "number") { return { ok: false, code: "asset_not_found", message: `asset ${assetName} not found on release` }; } return { ok: true, asset: found }; } export function createGithubReleaseApi({ token, repo }) { const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "User-Agent": "kxm-publish-npm", "X-GitHub-Api-Version": "2022-11-28", }; return { async getReleaseByTag(tag) { try { const res = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`, { headers }); const body = res.status === 204 ? null : await res.json().catch(() => null); return { status: res.status, body }; } catch (err) { return { error: "network", cause: err }; } }, async listReleases(page = 1) { try { const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, { headers }); const body = res.status === 204 ? null : await res.json().catch(() => null); return { status: res.status, body }; } catch (err) { return { error: "network", cause: err }; } }, async downloadReleaseAsset(assetId) { try { const res = await fetch(`https://api.github.com/repos/${repo}/releases/assets/${assetId}`, { headers: { ...headers, Accept: "application/octet-stream", }, redirect: "follow", }); if (!res.ok) { return { status: res.status, buffer: null }; } const arrayBuffer = await res.arrayBuffer(); return { status: res.status, buffer: Buffer.from(arrayBuffer) }; } catch (err) { return { error: "network", cause: err }; } }, }; } export async function defaultNpmPublish({ assetPath, npmToken, access = "public" }) { return new Promise((resolvePromise, rejectPromise) => { const args = ["publish", assetPath, "--access", access, `--//registry.npmjs.org/:_authToken=${npmToken}`]; const env = { ...process.env, NODE_AUTH_TOKEN: npmToken }; execFile("npm", args, { env }, (error, stdout, stderr) => { if (error) { const err = new KxmPublishNpmError("npm_publish_failed", stderr || stdout || error.message, { exitCode: typeof error.code === "number" ? error.code : 1, stdout, stderr, }); rejectPromise(err); } else { resolvePromise({ stdout, stderr }); } }); }); } export async function runKxmNpmPublish(input) { const token = typeof input.token === "string" ? input.token.trim() : ""; if (!token) { throw new KxmPublishNpmError("lookup_auth", "GITHUB_TOKEN is required"); } const npmToken = typeof input.npmToken === "string" ? input.npmToken.trim() : ""; if (!npmToken) { throw new KxmPublishNpmError("npm_auth_missing", "NODE_AUTH_TOKEN (or NPM_TOKEN) is required"); } const tag = typeof input.tag === "string" ? input.tag.trim() : ""; if (!tag) { throw new KxmPublishNpmError("input_missing", "tag is required"); } const repo = typeof input.repo === "string" ? input.repo.trim() : ""; if (!repo) { throw new KxmPublishNpmError("input_missing", "repository is required"); } const assetName = typeof input.assetName === "string" ? input.assetName.trim() : ""; if (!assetName) { throw new KxmPublishNpmError("input_missing", "assetName is required"); } const api = input.api ?? createGithubReleaseApi({ token, repo }); const publishFn = input.publishFn ?? defaultNpmPublish; const waitMs = typeof input.waitMs === "number" ? Math.max(0, input.waitMs) : 0; const pollIntervalMs = typeof input.pollIntervalMs === "number" ? Math.max(500, input.pollIntervalMs) : 5000; const deadline = Date.now() + waitMs; // Step 1: Verify release is published (draft: false), with optional polling window let lookup; while (true) { lookup = await fetchPublishedRelease(api, tag); if (lookup.ok) break; if (lookup.code === "release_still_draft" && Date.now() + pollIntervalMs <= deadline) { if (typeof input.onWait === "function") { input.onWait({ tag, remainingMs: Math.max(0, deadline - Date.now()) }); } await new Promise((r) => setTimeout(r, pollIntervalMs)); continue; } throw new KxmPublishNpmError(lookup.code, lookup.message, lookup); } // Step 2: Find asset on published release const assetLookup = findPublishedAsset(lookup.assets, assetName); if (!assetLookup.ok) { throw new KxmPublishNpmError(assetLookup.code, assetLookup.message, assetLookup); } const remoteAsset = assetLookup.asset; const remoteDigest = normalizeSha256Digest(remoteAsset.digest); // Step 3: Acquire asset and verify SHA-256 let assetPath = input.assetPath; let computedSha256; if (assetPath && existsSync(assetPath)) { computedSha256 = fileSha256(assetPath); } else { // Download asset via API const downloadRaw = await api.downloadReleaseAsset(remoteAsset.id); if (downloadRaw?.error || !downloadRaw?.buffer || downloadRaw.status < 200 || downloadRaw.status >= 300) { const status = downloadRaw?.status ?? "unknown"; throw new KxmPublishNpmError("download_failed", `failed to download asset ${assetName} (${status})`); } const tempDir = input.tempDir ?? tmpdir(); assetPath = join(tempDir, assetName); writeFileSync(assetPath, downloadRaw.buffer); computedSha256 = bufferSha256(downloadRaw.buffer); } // Verify against remote digest if present if (remoteDigest && computedSha256 !== remoteDigest) { throw new KxmPublishNpmError("digest_mismatch", `asset digest mismatch: computed ${computedSha256}, release has ${remoteDigest}`, { computed: computedSha256, expected: remoteDigest, }); } // Verify against declared SHA-256 if passed const declaredSha256 = typeof input.declaredSha256 === "string" ? input.declaredSha256.trim().toLowerCase() : undefined; if (declaredSha256 && computedSha256 !== declaredSha256) { throw new KxmPublishNpmError("digest_mismatch", `asset digest mismatch with declared: computed ${computedSha256}, expected ${declaredSha256}`, { computed: computedSha256, expected: declaredSha256, }); } // Step 4: Execute publish const publishResult = await publishFn({ assetPath, npmToken, access: "public", }); return { ok: true, published: true, tag, assetName, digest: `sha256:${computedSha256}`, stdout: publishResult?.stdout, }; } /** * npm can accept a publish minutes before `npm view <pkg>@<version>` answers. * Callers that report success should wait until this returns visible. */ export function npmViewVersion(version, exec = execFile) { return new Promise((resolve) => { exec("npm", ["view", `@kontextmind/kxm@${version}`, "version"], { encoding: "utf8", timeout: 30_000 }, (error, stdout) => { if (error) resolve(""); else resolve(String(stdout ?? "").trim()); }); }); } export async function waitForNpmVersion(version, options = {}) { const waitMs = Math.max(0, options.waitMs ?? 0); const intervalMs = Math.max(250, options.intervalMs ?? 15_000); const exec = options.npmView ?? ((value) => npmViewVersion(value)); const deadline = Date.now() + waitMs; let last = ""; for (;;) { const seen = String(await exec(version) ?? "").trim(); if (seen === version) return { visible: true, version }; last = seen || last; if (Date.now() >= deadline) return { visible: false, version, detail: last }; const pause = Math.min(intervalMs, Math.max(0, deadline - Date.now())); if (pause === 0) return { visible: false, version, detail: last }; await new Promise((resolve) => setTimeout(resolve, pause)); } } export async function main(env = process.env, stdout = process.stdout, stderr = process.stderr) { const token = env.GITHUB_TOKEN || env.GH_TOKEN; const npmToken = env.NODE_AUTH_TOKEN || env.NPM_TOKEN; const repo = env.GITHUB_REPOSITORY; // KXM_RELEASE_TAG is explicit for workflow_dispatch runs; GITHUB_REF_NAME // is the branch name for that event, not the requested release tag. const tag = env.KXM_RELEASE_TAG || env.TAG || env.GITHUB_REF_NAME; const assetName = env.KXM_ASSET; const assetPath = env.KXM_ASSET_PATH; const declaredSha256 = env.KXM_ASSET_SHA256; const tempDir = env.RUNNER_TEMP || tmpdir(); const waitMs = env.KXM_PUBLISH_WAIT_MS ? Number(env.KXM_PUBLISH_WAIT_MS) : 180000; // default 3 min in CI if (!repo || !tag || !assetName) { stderr.write("npm publish requires GITHUB_REPOSITORY, tag (GITHUB_REF_NAME), and KXM_ASSET\n"); return 1; } if (!token) { stderr.write("lookup_auth: GITHUB_TOKEN is required\n"); return 1; } if (!npmToken) { stderr.write("npm_auth_missing: NODE_AUTH_TOKEN (or NPM_TOKEN) is required\n"); return 1; } try { const result = await runKxmNpmPublish({ token, npmToken, repo, tag, assetName, assetPath, declaredSha256, tempDir, waitMs, onWait: ({ remainingMs }) => { const sec = Math.round(remainingMs / 1000); stdout.write(`release ${tag} is currently a draft; waiting for publish (${sec}s remaining)...\n`); }, }); const version = String(tag).replace(/^v/, ""); const visibleWaitRaw = env.KXM_NPM_VISIBLE_WAIT_MS; const visibleWaitMs = visibleWaitRaw == null || visibleWaitRaw === "" ? 20 * 60 * 1000 : Math.max(0, Number(visibleWaitRaw) || 0); const intervalRaw = env.KXM_NPM_VISIBLE_INTERVAL_MS; const intervalMs = intervalRaw == null || intervalRaw === "" ? 15_000 : Math.max(250, Number(intervalRaw) || 15_000); if (visibleWaitMs > 0) { stdout.write(`npm publish submitted for @kontextmind/kxm@${version}; waiting until npm view shows it\n`); const visible = await waitForNpmVersion(version, { waitMs: visibleWaitMs, intervalMs }); if (!visible.visible) { stderr.write(`npm_not_visible: npm view @kontextmind/kxm@${version} version did not appear within ${visibleWaitMs}ms\n`); return 1; } stdout.write(`npm view @kontextmind/kxm@${version} version is visible\n`); } stdout.write(`${JSON.stringify({ ...result, npmVisible: visibleWaitMs > 0 })}\n`); return 0; } catch (error) { const code = error instanceof KxmPublishNpmError ? error.code : "publish_failed"; const message = error instanceof Error ? error.message : "npm publish failed"; stderr.write(`${code}: ${message}\n`); return 1; } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const code = await main(); if (code !== 0) process.exitCode = code; }