#!/usr/bin/env node import { createHash, createPublicKey, sign as signData } from "node:crypto"; import { cpSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { basename, join, resolve, sep } from "node:path"; import { execFileSync } from "node:child_process"; import { parseArgs } from "node:util"; const { values } = parseArgs({ options: { "gateway-url": { type: "string" }, "download-base-url": { type: "string" }, "signing-key": { type: "string", default: ".secrets/release-signing-private.pem" }, out: { type: "string", default: "release" }, } }); const gatewayUrl = exactHttps(values["gateway-url"], "/mcp", "--gateway-url"); const downloadBaseUrl = httpsBase(values["download-base-url"], "--download-base-url"); const out = resolve(String(values.out)); const projectRoot = resolve("."); const signingKeyPath = resolve(String(values["signing-key"])); const publicKeyPath = resolve("distribution/release-signing-public.pem"); const packageMetadata = JSON.parse(readFileSync(resolve("package.json"), "utf8")); const packageLock = JSON.parse(readFileSync(resolve("package-lock.json"), "utf8")); const nodeRuntime = JSON.parse(readFileSync(resolve("distribution/node-runtime.json"), "utf8")); const version = String(packageMetadata.version ?? "").trim(); if (!version) throw new Error("package.json 缺少版本号"); validateNodeRuntime(nodeRuntime); if (out === projectRoot || !out.startsWith(`${projectRoot}${sep}`)) throw new Error("--out 必须是项目目录下的专用子目录"); assertPrivateKey(signingKeyPath); const privateKey = readFileSync(signingKeyPath); const publicKey = readFileSync(publicKeyPath); const derivedPublic = createPublicKey(privateKey).export({ type: "spki", format: "pem" }); if (normalizePem(derivedPublic) !== normalizePem(publicKey)) throw new Error("发布私钥与仓库固定公钥不匹配"); const keyId = releaseKeyId(publicKey); const clientStaging = join(out, ".client-staging"); rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true, mode: 0o755 }); execFileSync("npm", ["run", "build"], { stdio: "inherit", env: npmEnvironment() }); mkdirSync(join(clientStaging, "dist", "src"), { recursive: true }); for (const name of ["bridge.js", "entrypoint.js", "client-installer.js", "approval-contract.js"]) { cpSync(resolve("dist", "src", name), join(clientStaging, "dist", "src", name)); } cpSync(resolve("skills/dingtalk-approval"), join(clientStaging, "skill", "dingtalk-approval"), { recursive: true }); const clientPackage = { name: "dingtalk-approval-client", version, description: "Lightweight user client for the protected DingTalk OA and XFT approval gateway.", type: "module", bin: { "dingtalk-approval": "dist/src/client-installer.js", "dingtalk-approval-bridge": "dist/src/bridge.js", }, files: ["dist/src", "skill/dingtalk-approval"], engines: { node: ">=22.13" }, dependencies: { "@modelcontextprotocol/sdk": lockedVersion("@modelcontextprotocol/sdk"), zod: lockedVersion("zod"), }, bundledDependencies: ["@modelcontextprotocol/sdk", "zod"], }; writeFileSync(join(clientStaging, "package.json"), `${JSON.stringify(clientPackage, null, 2)}\n`, "utf8"); execFileSync("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: clientStaging, stdio: "inherit", env: npmEnvironment(), }); const packed = JSON.parse(execFileSync("npm", ["pack", "--json", "--pack-destination", out], { cwd: clientStaging, encoding: "utf8", env: npmEnvironment(), })); const clientName = packed?.[0]?.filename; if (typeof clientName !== "string") throw new Error("npm pack 没有返回客户端包文件名"); const installerName = "dingtalk-approval-installer.mjs"; const publicKeyName = "release-signing-public.pem"; const skillName = "dingtalk-approval-SKILL.md"; cpSync(resolve("dist/src/client-installer.js"), join(out, installerName)); cpSync(publicKeyPath, join(out, publicKeyName)); cpSync(resolve("skills/dingtalk-approval/SKILL.md"), join(out, skillName)); rmSync(clientStaging, { recursive: true, force: true }); const baseArtifacts = { installer: artifact(installerName), publicKey: { ...artifact(publicKeyName), keyId }, client: artifact(clientName), skill: artifact(skillName), }; const commonBootstrapReplacements = new Map([ ["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`], ["{{INSTALLER_URL}}", baseArtifacts.installer.url], ["{{INSTALLER_SHA256}}", baseArtifacts.installer.sha256], ["{{PUBLIC_KEY_URL}}", baseArtifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", baseArtifacts.publicKey.sha256], ["{{NODE_SOURCE_BASES_POWERSHELL}}", nodeRuntime.sourceBases.map((value) => JSON.stringify(value)).join(", ")], ["{{NODE_SOURCE_BASES_POSIX}}", nodeRuntime.sourceBases.join(" ")], ...Object.entries(nodeRuntime.platforms).flatMap(([platform, runtime]) => { const prefix = `{{NODE_${platform.replace(/-/g, "_").toUpperCase()}_`; return [[`${prefix}FILE}}`, runtime.file], [`${prefix}SHA256}}`, runtime.sha256]]; }), ]); const bootstrapWindowsName = "dingtalk-approval-bootstrap.ps1"; const bootstrapPosixName = "dingtalk-approval-bootstrap.sh"; renderTemplate("distribution/bootstrap-windows.template.ps1", join(out, bootstrapWindowsName), commonBootstrapReplacements); renderTemplate("distribution/bootstrap-posix.template.sh", join(out, bootstrapPosixName), commonBootstrapReplacements); execFileSync("sh", ["-n", join(out, bootstrapPosixName)]); const artifacts = { ...baseArtifacts, bootstrapWindows: artifact(bootstrapWindowsName), bootstrapPosix: artifact(bootstrapPosixName), }; const manifest = { schemaVersion: 3, version, createdAt: new Date().toISOString(), gatewayUrl, signing: { algorithm: "Ed25519", keyId }, requirements: { node: `>=${nodeRuntime.minimumVersion}`, managedNode: { version: nodeRuntime.version, platforms: Object.keys(nodeRuntime.platforms).sort() }, }, artifacts, }; const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); writeFileSync(join(out, "manifest.json"), manifestBytes); const signature = signData(null, manifestBytes, privateKey).toString("base64"); writeFileSync(join(out, "manifest.sig"), `${JSON.stringify({ schemaVersion: 1, algorithm: "Ed25519", keyId, signature, }, null, 2)}\n`, "utf8"); const template = readFileSync(resolve("distribution/agent-install.template.md"), "utf8"); const replacements = new Map([ ["{{VERSION}}", version], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`], ["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl], ["{{INSTALLER_URL}}", artifacts.installer.url], ["{{INSTALLER_SHA256}}", artifacts.installer.sha256], ["{{PUBLIC_KEY_URL}}", artifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", artifacts.publicKey.sha256], ["{{BOOTSTRAP_WINDOWS_URL}}", artifacts.bootstrapWindows.url], ["{{BOOTSTRAP_WINDOWS_SHA256}}", artifacts.bootstrapWindows.sha256], ["{{BOOTSTRAP_POSIX_URL}}", artifacts.bootstrapPosix.url], ["{{BOOTSTRAP_POSIX_SHA256}}", artifacts.bootstrapPosix.sha256], ["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion], ["{{RELEASE_KEY_ID}}", keyId], ]); let prompt = template; for (const [from, to] of replacements) prompt = prompt.replaceAll(from, to); if (/\{\{[^}]+\}\}/.test(prompt)) throw new Error("安装指令仍包含未替换占位符"); writeFileSync(join(out, "install.md"), prompt, "utf8"); const checksums = Object.values(artifacts).map((value) => `${value.sha256} ${basename(new URL(value.url).pathname)}`); checksums.push(`${sha256(join(out, "manifest.json"))} manifest.json`, `${sha256(join(out, "manifest.sig"))} manifest.sig`); writeFileSync(join(out, "SHA256SUMS"), `${checksums.join("\n")}\n`, "utf8"); console.log(`发布目录:${out}`); console.log(`签名密钥:${keyId}`); console.log(`给用户的唯一链接:${downloadBaseUrl}/install.md`); function artifact(name) { return { url: `${downloadBaseUrl}/${basename(name)}`, sha256: sha256(join(out, name)) }; } function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } function releaseKeyId(value) { const der = createPublicKey(value).export({ type: "spki", format: "der" }); return createHash("sha256").update(der).digest("hex"); } function normalizePem(value) { return Buffer.from(value).toString("utf8").replace(/\r/g, "").trim(); } function npmEnvironment() { return { ...process.env, npm_config_cache: join(projectRoot, ".npm-cache") }; } function lockedVersion(name) { const value = packageLock.packages?.[`node_modules/${name}`]?.version; if (typeof value !== "string" || !/^\d+\.\d+\.\d+/.test(value)) throw new Error(`package-lock.json 缺少 ${name} 的固定版本`); return value; } function renderTemplate(source, target, replacements) { let body = readFileSync(resolve(source), "utf8"); for (const [from, to] of replacements) body = body.replaceAll(from, String(to)); if (/\{\{[^}]+\}\}/.test(body)) throw new Error(`${source} 仍包含未替换占位符`); writeFileSync(target, body, "utf8"); } function validateNodeRuntime(value) { if (value?.schemaVersion !== 1 || !/^\d+\.\d+\.\d+$/.test(value.version) || !/^\d+\.\d+\.\d+$/.test(value.minimumVersion)) { throw new Error("node-runtime.json 版本信息无效"); } if (!Array.isArray(value.sourceBases) || value.sourceBases.length < 2) throw new Error("Node 运行时至少需要两个下载源"); for (const source of value.sourceBases) { const url = parseHttps(source, "Node source base"); if (!url.pathname.endsWith(`/v${value.version}/`) || url.search || url.hash) { throw new Error("Node 下载源必须固定精确版本目录"); } } const expected = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64"]; if (JSON.stringify(Object.keys(value.platforms ?? {}).sort()) !== JSON.stringify(expected)) { throw new Error("Node 运行时必须完整覆盖 Windows/Linux/macOS 的 x64 与 ARM64"); } for (const [platform, runtime] of Object.entries(value.platforms)) { if (typeof runtime.file !== "string" || !runtime.file.includes(`v${value.version}`) || !/^[a-f0-9]{64}$/.test(runtime.sha256)) throw new Error(`Node 运行时 ${platform} 无效`); if (platform.startsWith("win32-") ? !runtime.file.endsWith(".zip") : !runtime.file.endsWith(".tar.gz")) { throw new Error(`Node 运行时 ${platform} 使用了不支持的归档格式`); } } } function assertPrivateKey(path) { const stat = statSync(path); if (!stat.isFile()) throw new Error("发布签名私钥不是普通文件"); if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("发布签名私钥权限必须为 600"); } function exactHttps(value, pathname, flag) { const url = parseHttps(value, flag); if (url.pathname !== pathname || url.search || url.hash) throw new Error(`${flag} 必须是 HTTPS ${pathname} 地址`); return url.toString().replace(/\/$/, ""); } function httpsBase(value, flag) { const url = parseHttps(value, flag); if (url.search || url.hash) throw new Error(`${flag} 不能包含 query 或 hash`); return url.toString().replace(/\/$/, ""); } function parseHttps(value, flag) { if (!value) throw new Error(`缺少 ${flag}`); const url = new URL(String(value)); if (url.protocol !== "https:" || url.username || url.password) throw new Error(`${flag} 必须使用无内嵌凭证的 HTTPS URL`); return url; }