feat: initial public release
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-08-17 03:04:09 +00:00
commit 504145aec6
62 changed files with 18318 additions and 0 deletions
+809
View File
@@ -0,0 +1,809 @@
#!/usr/bin/env node
import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
export interface ReleaseArtifact {
url: string;
sha256: string;
}
export interface ClientReleaseManifest {
schemaVersion: 3;
version: string;
createdAt: string;
gatewayUrl: string;
signing: { algorithm: "Ed25519"; keyId: string };
requirements: { node: string };
artifacts: {
installer: ReleaseArtifact;
publicKey: ReleaseArtifact & { keyId: string };
client: ReleaseArtifact;
skill: ReleaseArtifact;
};
}
export interface ClientInstallState {
schemaVersion: 1;
version: string;
installedAt: string;
manifestUrl: string;
gatewayUrl: string;
clientSha256: string;
skillSha256: string;
runtimeDir: string;
skillFile: string;
}
export interface ClientPaths {
root: string;
stateFile: string;
publicKeyFile: string;
versionsDir: string;
binDir: string;
skillDir: string;
skillFile: string;
launcherFile: string;
cliCommand: string;
bridgeCommand: string;
sessionFile: string;
}
interface PostInstallResult {
required: true;
attempted: true;
status: "authenticated" | "authorization_required" | "unavailable";
authenticated: boolean;
message: string;
authChallenge?: {
directUrl: string;
userCode?: string;
expiresAt?: string;
};
onboarding?: {
firstRun: true;
examples: string[];
writeSafety?: string;
};
confirmTool: { name: "approval_login"; arguments: Record<string, never> };
verifyTool: { name: "approval_list_todos"; arguments: { source: "all"; query: ""; limit: 1 } };
completionCriteria: string;
}
interface SignatureEnvelope {
schemaVersion: 1;
algorithm: "Ed25519";
keyId: string;
signature: string;
}
interface CommandContext {
argv: string[];
env: NodeJS.ProcessEnv;
platform: NodeJS.Platform;
home: string;
fetchFn: typeof fetch;
stdout: (value: string) => void;
stderr: (value: string) => void;
}
const LEGACY_BIN_NAMES = ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"];
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/;
const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
const DOWNLOAD_TIMEOUT_MS = 30_000;
const MANIFEST_MAX_AGE_MS = 180 * 24 * 60 * 60_000;
export function clientPaths(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
home = homedir(),
): ClientPaths {
const pathApi = platform === "win32" ? win32 : posix;
const override = env.DINGTALK_APPROVAL_CLIENT_HOME?.trim();
const root = pathApi.resolve(override || (platform === "win32"
? pathApi.join(env.LOCALAPPDATA?.trim() || pathApi.join(home, "AppData", "Local"), "DingTalkApproval")
: platform === "darwin"
? pathApi.join(home, "Library", "Application Support", "dingtalk-approval")
: pathApi.join(env.XDG_DATA_HOME?.trim() || pathApi.join(home, ".local", "share"), "dingtalk-approval")));
const binDir = pathApi.join(root, "bin");
const executableSuffix = platform === "win32" ? ".cmd" : "";
return {
root,
stateFile: pathApi.join(root, "install-state.json"),
publicKeyFile: pathApi.join(root, "release-signing-public.pem"),
versionsDir: pathApi.join(root, "versions"),
binDir,
skillDir: pathApi.join(root, "skill", "dingtalk-approval"),
skillFile: pathApi.join(root, "skill", "dingtalk-approval", "SKILL.md"),
launcherFile: pathApi.join(binDir, "launcher.mjs"),
cliCommand: pathApi.join(binDir, `dingtalk-approval${executableSuffix}`),
bridgeCommand: pathApi.join(binDir, `dingtalk-approval-bridge${executableSuffix}`),
sessionFile: pathApi.join(root, "state", "session.json"),
};
}
export function releaseKeyId(publicKeyPem: string | Buffer): string {
const der = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
return createHash("sha256").update(der).digest("hex");
}
export function verifySignedManifest(
manifestBytes: Buffer,
signatureBytes: Buffer,
publicKeyPem: string | Buffer,
manifestUrl: string,
): ClientReleaseManifest {
let envelope: SignatureEnvelope;
try { envelope = JSON.parse(signatureBytes.toString("utf8")) as SignatureEnvelope; }
catch { throw new Error("发布清单签名文件不是有效 JSON"); }
const keyId = releaseKeyId(publicKeyPem);
if (envelope.schemaVersion !== 1 || envelope.algorithm !== "Ed25519" || envelope.keyId !== keyId) {
throw new Error("发布清单签名的算法或密钥编号不受信任");
}
let signature: Buffer;
try { signature = Buffer.from(envelope.signature, "base64"); }
catch { throw new Error("发布清单签名编码无效"); }
if (signature.length === 0 || !verifySignature(null, manifestBytes, publicKeyPem, signature)) {
throw new Error("发布清单 Ed25519 签名无效");
}
let manifest: ClientReleaseManifest;
try { manifest = JSON.parse(manifestBytes.toString("utf8")) as ClientReleaseManifest; }
catch { throw new Error("发布清单不是有效 JSON"); }
validateManifest(manifest, manifestUrl, keyId);
return manifest;
}
export async function runClientInstaller(
argv = process.argv.slice(2),
overrides: Partial<Omit<CommandContext, "argv">> = {},
): Promise<number> {
const context: CommandContext = {
argv,
env: overrides.env ?? process.env,
platform: overrides.platform ?? process.platform,
home: overrides.home ?? homedir(),
fetchFn: overrides.fetchFn ?? fetch,
stdout: overrides.stdout ?? ((value) => { process.stdout.write(value); }),
stderr: overrides.stderr ?? ((value) => { process.stderr.write(value); }),
};
try {
const args = parseClientArgs(context.argv);
const paths = clientPaths(context.env, context.platform, context.home);
if (args.appRoot) Object.assign(paths, pathsForRoot(resolve(args.appRoot), context.platform));
assertSafeClientRoot(paths.root, context.home);
let result: Record<string, unknown>;
if (args.command === "install" || args.command === "update") result = await installClient(paths, args, context);
else if (args.command === "uninstall") result = uninstallClient(paths, args, context);
else if (args.command === "print-path") result = printPath(paths, args);
else if (args.command === "print-config") result = printConfig(paths, args);
else if (args.command === "doctor") result = doctor(paths, context);
else throw new Error(usage());
writeResult(result, args, context);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (context.argv.includes("--json") || context.argv.includes("mcp-json")) context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`);
else context.stderr(`${message}\n`);
return 1;
}
}
interface ParsedArgs {
command: string;
manifestUrl?: string;
publicKey?: string;
json: boolean;
yes: boolean;
allowDowngrade: boolean;
appRoot?: string;
pathKind?: string;
format?: "mcp-json";
}
function parseClientArgs(argv: string[]): ParsedArgs {
const values = [...argv];
if (values[0] === "--print-path") values.splice(0, 1, "print-path");
const command = values.shift() ?? "";
const strings = new Map<string, string>();
const flags = new Set<string>();
let pathKind: string | undefined;
const stringOptions = new Set(["--manifest-url", "--public-key", "--app-root", "--format"]);
const flagOptions = new Set(["--json", "--yes", "--allow-downgrade"]);
for (let index = 0; index < values.length; index += 1) {
const value = values[index]!;
if (stringOptions.has(value)) {
const next = values[index + 1];
if (!next || next.startsWith("--")) throw new Error(`${value} 缺少参数`);
if (strings.has(value)) throw new Error(`${value} 不能重复`);
strings.set(value, next);
index += 1;
} else if (flagOptions.has(value)) {
if (flags.has(value)) throw new Error(`${value} 不能重复`);
flags.add(value);
} else if (value.startsWith("--")) throw new Error(`未知参数:${value}`);
else if (command === "print-path" && !pathKind) pathKind = value;
else throw new Error(`多余参数:${value}`);
}
const allowed = new Set(command === "install" || command === "update"
? ["--manifest-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
: command === "uninstall" ? ["--app-root", "--json", "--yes"]
: command === "print-path" ? ["--app-root", "--json"]
: command === "print-config" ? ["--app-root", "--json", "--format"]
: command === "doctor" ? ["--app-root", "--json"] : []);
for (const name of [...strings.keys(), ...flags]) {
if (!allowed.has(name)) throw new Error(`${command || "该命令"} 不支持参数 ${name}`);
}
const format = strings.get("--format");
if (format && format !== "mcp-json") throw new Error("--format 只支持 mcp-json");
return {
command,
manifestUrl: strings.get("--manifest-url"),
publicKey: strings.get("--public-key"),
json: flags.has("--json"),
yes: flags.has("--yes"),
allowDowngrade: flags.has("--allow-downgrade"),
appRoot: strings.get("--app-root"),
pathKind,
format: format as "mcp-json" | undefined,
};
}
async function installClient(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Promise<Record<string, unknown>> {
requireNodeVersion();
const npmCheck = checkNpm(context);
if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`);
const previous = readState(paths.stateFile);
const manifestUrl = normalizedHttpsUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL");
if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
const publicKey = readFileSync(publicKeyPath);
const [manifestBytes, signatureBytes] = await Promise.all([
download(context.fetchFn, manifestUrl),
download(context.fetchFn, new URL("manifest.sig", manifestUrl)),
]);
const manifest = verifySignedManifest(manifestBytes, signatureBytes, publicKey, manifestUrl.toString());
if (previous && compareVersions(manifest.version, previous.version) < 0 && !args.allowDowngrade) {
throw new Error(`拒绝从 ${previous.version} 降级到 ${manifest.version};如确需降级请显式使用 --allow-downgrade`);
}
const temporary = mkdtempSync(join(tmpdir(), "dingtalk-approval-install-"));
try {
const clientArchive = join(temporary, "client.tgz");
await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive);
const skillBody = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md"));
const publicArtifact = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem"));
if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) {
throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装");
}
mkdirPrivate(paths.root);
mkdirPrivate(paths.versionsDir);
const versionName = `${safeName(manifest.version)}-${manifest.artifacts.client.sha256.slice(0, 12)}`;
const versionDir = join(paths.versionsDir, versionName);
const clientEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js");
const cliEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "client-installer.js");
if (!existsSync(clientEntry) || !existsSync(cliEntry)) {
if (existsSync(versionDir)) rmSync(versionDir, { recursive: true, force: true });
const staging = join(paths.versionsDir, `.staging-${process.pid}-${randomUUID()}`);
mkdirPrivate(staging);
const installed = runNpm(context, ["install", "--prefix", staging, "--offline", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", clientArchive]);
if (installed.status !== 0) {
rmSync(staging, { recursive: true, force: true });
throw new Error(`客户端运行时安装失败:${(installed.stderr || installed.stdout || "npm 执行失败").trim()}`);
}
if (!existsSync(join(staging, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))) {
rmSync(staging, { recursive: true, force: true });
throw new Error("客户端包缺少 Bridge 入口");
}
renameSync(staging, versionDir);
}
mkdirPrivate(paths.skillDir);
writeAtomic(paths.skillFile, skillBody, 0o600);
writeAtomic(paths.publicKeyFile, publicArtifact, 0o600);
writeLaunchers(paths, context.platform);
const state: ClientInstallState = {
schemaVersion: 1,
version: manifest.version,
installedAt: new Date().toISOString(),
manifestUrl: manifestUrl.toString(),
gatewayUrl: manifest.gatewayUrl,
clientSha256: manifest.artifacts.client.sha256,
skillSha256: manifest.artifacts.skill.sha256,
runtimeDir: relative(paths.root, versionDir),
skillFile: relative(paths.root, paths.skillFile),
};
writeAtomic(paths.stateFile, Buffer.from(`${JSON.stringify(state, null, 2)}\n`), 0o600);
const legacyCleanupFailed: string[] = [];
const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed);
cleanupOldVersions(paths, versionDir);
const postInstall = attemptPostInstallLogin(clientEntry, manifest.gatewayUrl, paths, context);
return installResult(paths, state, legacyRemoved, legacyCleanupFailed, postInstall);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
}
function uninstallClient(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Record<string, unknown> {
if (!args.yes) throw new Error("卸载会删除运行时、Skill 和 OAuth 登录会话;请使用 --yes 明确确认");
const state = readState(paths.stateFile);
if (existsSync(paths.root) && !state) throw new Error("客户端目录缺少有效安装记录,拒绝递归删除;请人工核对该目录");
const legacyCleanupFailed: string[] = [];
const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed);
const legacySession = join(context.env.XDG_CONFIG_HOME?.trim() || join(context.home, ".config"), "dingtalk-approval-bridge");
if (isSafeLegacySessionPath(legacySession, context.home)) rmSync(legacySession, { recursive: true, force: true });
const runningInsideRoot = isInside(paths.root, process.argv[1] ?? "");
if (existsSync(paths.root)) {
if (runningInsideRoot) scheduleSelfCleanup(paths.root);
else rmSync(paths.root, { recursive: true, force: true });
}
return {
ok: true,
status: runningInsideRoot ? "cleanup_scheduled" : "uninstalled",
removedVersion: state?.version,
legacyEntriesRemoved: legacyRemoved,
legacyCleanupFailed,
sessionRemoved: true,
hostCleanupRequired: true,
hostCleanup: "请按当前 Agent 的官方方式删除名为 dingtalk-approval 的 MCP/连接器条目。",
};
}
function printPath(paths: ClientPaths, args: ParsedArgs): Record<string, unknown> {
assertInstalled(paths);
if (args.pathKind && args.pathKind !== "bridge" && args.pathKind !== "skill" && args.pathKind !== "cli") {
throw new Error("print-path 只支持 bridge、skill 或 cli");
}
const values = { bridge: paths.bridgeCommand, skill: paths.skillDir, cli: paths.cliCommand };
const kind = args.pathKind as keyof typeof values | undefined;
return { ok: true, ...(kind ? { path: values[kind] } : values) };
}
function printConfig(paths: ClientPaths, args: ParsedArgs): Record<string, unknown> {
const state = assertInstalled(paths);
if (args.format === "mcp-json") return mcpConfig(paths);
return { ok: true, version: state.version, mcpConfig: mcpConfig(paths), skillPath: paths.skillDir };
}
function doctor(paths: ClientPaths, context: CommandContext): Record<string, unknown> {
const checks: Array<Record<string, unknown>> = [];
const majorMinor = process.versions.node.split(".").slice(0, 2).map(Number);
checks.push({ name: "node", ok: (majorMinor[0] ?? 0) > 22 || ((majorMinor[0] ?? 0) === 22 && (majorMinor[1] ?? 0) >= 13), value: process.version });
const npm = checkNpm(context);
checks.push({ name: "npm", ...npm });
const state = readState(paths.stateFile);
checks.push({ name: "state", ok: Boolean(state), path: paths.stateFile });
checks.push({ name: "bridge", ok: existsSync(paths.bridgeCommand), path: paths.bridgeCommand });
checks.push({ name: "skill", ok: existsSync(paths.skillFile), path: paths.skillFile });
checks.push({ name: "publicKey", ok: existsSync(paths.publicKeyFile), path: paths.publicKeyFile });
if (state) {
const runtime = resolveInside(paths.root, state.runtimeDir);
checks.push({ name: "runtime", ok: existsSync(join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js")), path: runtime });
try { normalizedHttpsUrl(state.gatewayUrl, "gateway URL"); checks.push({ name: "gateway", ok: true, value: state.gatewayUrl }); }
catch (error) { checks.push({ name: "gateway", ok: false, message: error instanceof Error ? error.message : String(error) }); }
}
const ok = checks.every((check) => check.ok === true);
return { ok, platform: context.platform, root: paths.root, checks };
}
function checkNpm(context: Pick<CommandContext, "env" | "platform">): { ok: boolean; value?: string; message?: string } {
const result = runNpm(context, ["--version"]);
if (result.error) return { ok: false, message: result.error.message };
if (result.status !== 0) return { ok: false, message: (result.stderr || result.stdout || `退出码 ${result.status}`).trim() };
const version = result.stdout.trim();
return version ? { ok: true, value: version } : { ok: false, message: "npm --version 未返回版本号" };
}
function runNpm(context: Pick<CommandContext, "env" | "platform">, args: string[]) {
const invocation = npmInvocation(context, args);
return spawnSync(invocation.command, invocation.args, {
encoding: "utf8",
env: { ...context.env, npm_config_update_notifier: "false" },
windowsHide: true,
});
}
function npmInvocation(context: Pick<CommandContext, "env" | "platform">, args: string[]): { command: string; args: string[] } {
if (context.platform !== "win32") return { command: "npm", args };
const candidates = [
context.env.npm_execpath,
...(context.env.PATH ?? "").split(";").filter(Boolean).map((entry) => join(entry.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npm-cli.js")),
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
];
const npmCli = candidates.find((candidate) => candidate && existsSync(candidate));
if (!npmCli) throw new Error("Windows 环境未找到可由 Node 安全启动的 npm-cli.js;请修复 Node/npm 安装后重试");
return { command: process.execPath, args: [npmCli, ...args] };
}
export function cleanupLegacyInstallations(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
home = homedir(),
failed: string[] = [],
): string[] {
const removed: string[] = [];
for (const binDir of legacyBinDirectories(env, platform, home)) {
for (const name of LEGACY_BIN_NAMES) {
const candidates = platform === "win32" ? [name, `${name}.cmd`, `${name}.ps1`] : [name];
for (const candidate of candidates) {
const path = join(binDir, candidate);
if (!existsOrSymlink(path) || !legacyEntryOwned(path)) continue;
try { unlinkSync(path); removed.push(path); }
catch { failed.push(path); }
}
}
const packageRoot = platform === "win32"
? join(binDir, "node_modules", "dingtalk-approval-mcp")
: resolve(binDir, "..", "lib", "node_modules", "dingtalk-approval-mcp");
if (exactPackage(packageRoot, "dingtalk-approval-mcp")) {
try { rmSync(packageRoot, { recursive: true, force: true }); removed.push(packageRoot); }
catch { failed.push(packageRoot); }
}
}
return removed;
}
function legacyBinDirectories(env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string): string[] {
const paths = new Set((env.PATH ?? "").split(platform === "win32" ? ";" : ":").filter(Boolean).map((value) => resolve(value)));
if (platform === "win32") {
if (env.APPDATA) paths.add(resolve(env.APPDATA, "npm"));
} else {
paths.add(resolve(home, ".local", "bin"));
paths.add(resolve(home, ".volta", "bin"));
const nvmVersions = join(home, ".nvm", "versions", "node");
if (existsSync(nvmVersions)) {
for (const version of readdirSync(nvmVersions)) paths.add(resolve(nvmVersions, version, "bin"));
}
}
return [...paths].filter((path) => existsSync(path));
}
function legacyEntryOwned(path: string): boolean {
try {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) return /(?:^|[\\/])dingtalk-approval-mcp(?:[\\/]|$)/.test(readlinkSync(path));
if (stat.isFile() && /\.(?:cmd|ps1)$/i.test(path)) {
return /(?:^|[\\/])dingtalk-approval-mcp(?:[\\/]|$)/.test(readFileSync(path, "utf8"));
}
} catch { return false; }
return false;
}
function installResult(
paths: ClientPaths,
state: ClientInstallState,
legacyRemoved: string[],
legacyCleanupFailed: string[],
postInstall: PostInstallResult,
): Record<string, unknown> {
const nextSteps = [
"按当前 Agent 的官方方式安装 skillPath,并添加名为 dingtalk-approval 的 stdio MCP。",
"立即处理 postInstall:若有 authChallenge.directUrl,必须在当前回复中原样展示,不得声称已另行推送,也不得等待用户首次查询审批。",
"用户授权后调用 approval_login;登录成功后按 verifyTool 做一次双来源只读验证,并展示一次性使用示例。",
"宿主若不能热加载 MCP,请新建会话或完整重启;恢复上述登录流程后再宣告安装完成,不得执行任何审批写操作。",
];
return {
ok: true,
status: "installed",
version: state.version,
root: paths.root,
bridgePath: paths.bridgeCommand,
cliPath: paths.cliCommand,
skillPath: paths.skillDir,
mcpConfig: mcpConfig(paths),
legacyEntriesRemoved: legacyRemoved,
legacyCleanupFailed,
configurationModified: false,
postInstall,
nextSteps,
};
}
function attemptPostInstallLogin(
clientEntry: string,
gatewayUrl: string,
paths: ClientPaths,
context: Pick<CommandContext, "env">,
): PostInstallResult {
const shared = {
required: true as const,
attempted: true as const,
confirmTool: { name: "approval_login" as const, arguments: {} },
verifyTool: { name: "approval_list_todos" as const, arguments: { source: "all" as const, query: "" as const, limit: 1 as const } },
completionCriteria: "登录确认并完成钉钉 OA 与薪福通双来源只读验证",
};
const result = spawnSync(process.execPath, [clientEntry, "--url", gatewayUrl, "--post-install-login"], {
encoding: "utf8",
timeout: 30_000,
maxBuffer: 256 * 1024,
env: { ...context.env, DINGTALK_APPROVAL_BRIDGE_STATE: paths.sessionFile },
});
if (result.error || result.status !== 0) {
return {
...shared,
status: "unavailable",
authenticated: false,
message: "客户端已安装,但暂时无法连接登录服务;请加载 MCP 后调用 approval_login 重试。",
};
}
const line = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
let value: Record<string, unknown>;
try { value = JSON.parse(line ?? "") as Record<string, unknown>; }
catch {
return {
...shared,
status: "unavailable",
authenticated: false,
message: "客户端已安装,但登录准备结果无法识别;请加载 MCP 后调用 approval_login 重试。",
};
}
const onboarding = safeOnboarding(value.onboarding);
if (value.authenticated === true) {
return {
...shared,
status: "authenticated",
authenticated: true,
message: typeof value.message === "string" ? value.message : "已复用现有登录,可以读取审批。",
...(onboarding ? { onboarding } : {}),
};
}
const challenge = value.authChallenge && typeof value.authChallenge === "object" && !Array.isArray(value.authChallenge)
? value.authChallenge as Record<string, unknown> : undefined;
if (challenge && typeof challenge.directUrl === "string") {
return {
...shared,
status: "authorization_required",
authenticated: false,
message: typeof value.message === "string" ? value.message : "需要完成钉钉授权登录。",
authChallenge: {
directUrl: challenge.directUrl,
...(typeof challenge.userCode === "string" ? { userCode: challenge.userCode } : {}),
...(typeof challenge.expiresAt === "string" ? { expiresAt: challenge.expiresAt } : {}),
},
};
}
return {
...shared,
status: "unavailable",
authenticated: false,
message: "客户端已安装,但登录服务未返回可用授权链接;请加载 MCP 后调用 approval_login 重试。",
};
}
function safeOnboarding(value: unknown): PostInstallResult["onboarding"] | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (record.firstRun !== true || !Array.isArray(record.examples)) return undefined;
const examples = record.examples.filter((item): item is string => typeof item === "string").slice(0, 8);
if (!examples.length) return undefined;
return {
firstRun: true,
examples,
...(typeof record.writeSafety === "string" ? { writeSafety: record.writeSafety } : {}),
};
}
function mcpConfig(paths: ClientPaths): Record<string, unknown> {
return { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } };
}
function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform): void {
mkdirPrivate(paths.binDir);
const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`;
writeAtomic(paths.launcherFile, Buffer.from(launcher), 0o700);
if (platform === "win32") {
writeAtomic(paths.cliCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" cli %*\r\n`), 0o700);
writeAtomic(paths.bridgeCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" bridge %*\r\n`), 0o700);
} else {
writeAtomic(paths.cliCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" cli "$@"\n`), 0o700);
writeAtomic(paths.bridgeCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" bridge "$@"\n`), 0o700);
}
}
function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string, keyId: string): void {
if (manifest.schemaVersion !== 3 || !VERSION_PATTERN.test(manifest.version)) throw new Error("发布清单版本或版本号无效");
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing.keyId !== keyId) throw new Error("发布清单未绑定当前发布公钥");
const base = normalizedHttpsUrl(manifestUrl, "manifest URL");
const gateway = normalizedHttpsUrl(manifest.gatewayUrl, "gateway URL");
if (gateway.pathname !== "/mcp" || gateway.search || gateway.hash) throw new Error("gateway URL 必须是 HTTPS /mcp 地址");
for (const [name, artifact] of Object.entries(manifest.artifacts ?? {})) {
if (!artifact || !/^[a-f0-9]{64}$/.test(artifact.sha256)) throw new Error(`发布物 ${name} 缺少有效 SHA-256`);
const url = normalizedHttpsUrl(artifact.url, `${name} URL`);
if (url.origin !== base.origin) throw new Error(`发布物 ${name} 必须与清单同源`);
}
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("发布公钥 artifact 的 keyId 不匹配");
const createdAt = Date.parse(manifest.createdAt);
if (!Number.isFinite(createdAt)) throw new Error("发布清单 createdAt 无效");
if (createdAt > Date.now() + 24 * 60 * 60_000) throw new Error("发布清单 createdAt 来自未来,拒绝安装");
if (createdAt < Date.now() - MANIFEST_MAX_AGE_MS) throw new Error("发布清单已超过 180 天有效期,请获取新的安装链接");
}
async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("下载超时")), DOWNLOAD_TIMEOUT_MS);
timeout.unref();
try {
const response = await fetchFn(url, {
headers: { accept: "application/octet-stream, application/json;q=0.9, */*;q=0.8" },
signal: controller.signal,
});
if (!response.ok) throw new Error(`下载失败 ${response.status}:${url}`);
const length = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(length) && length > MAX_DOWNLOAD_BYTES) throw new Error(`下载文件超过 25MB 限制:${url}`);
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_DOWNLOAD_BYTES) {
try { await reader.cancel("download size limit exceeded"); } catch { /* size rejection still wins */ }
throw new Error(`下载文件超过 25MB 限制:${url}`);
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
} catch (error) {
if (controller.signal.aborted) throw new Error(`下载超时(30 秒):${url}`, { cause: error });
throw error;
} finally {
clearTimeout(timeout);
}
}
async function downloadArtifact(fetchFn: typeof fetch, manifestUrl: URL, artifact: ReleaseArtifact, target: string): Promise<Buffer> {
const url = normalizedHttpsUrl(artifact.url, "artifact URL");
if (url.origin !== manifestUrl.origin) throw new Error("发布物必须与清单同源");
const body = await download(fetchFn, url);
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
writeAtomic(target, body, 0o600);
return body;
}
function resolvePublicKeyPath(paths: ClientPaths, explicit: string | undefined): string {
if (explicit) return resolve(explicit);
if (existsSync(paths.publicKeyFile)) return paths.publicKeyFile;
const adjacent = resolve(dirname(process.argv[1] ?? fileURLToPath(import.meta.url)), "release-signing-public.pem");
if (existsSync(adjacent)) return adjacent;
throw new Error("找不到发布公钥;首次安装必须提供 --public-key,更新则使用已固定的本地公钥");
}
function requireNodeVersion(): void {
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
if (major < 22 || (major === 22 && minor < 13)) throw new Error(`需要 Node.js >=22.13.0,当前为 ${process.version}`);
}
function compareVersions(left: string, right: string): number {
const a = VERSION_PATTERN.exec(left);
const b = VERSION_PATTERN.exec(right);
if (!a || !b) throw new Error("无法比较非法版本号");
for (let index = 1; index <= 3; index += 1) {
const difference = Number(a[index]) - Number(b[index]);
if (difference !== 0) return Math.sign(difference);
}
const arc = a[4] === undefined ? Number.POSITIVE_INFINITY : Number(a[4]);
const brc = b[4] === undefined ? Number.POSITIVE_INFINITY : Number(b[4]);
return Math.sign(arc - brc);
}
function readState(path: string): ClientInstallState | undefined {
if (!existsSync(path)) return undefined;
try {
const value = JSON.parse(readFileSync(path, "utf8")) as ClientInstallState;
return value.schemaVersion === 1 && VERSION_PATTERN.test(value.version) ? value : undefined;
} catch { return undefined; }
}
function assertInstalled(paths: ClientPaths): ClientInstallState {
const state = readState(paths.stateFile);
if (!state) throw new Error("客户端尚未安装或安装状态已损坏,请重新运行 install");
return state;
}
function cleanupOldVersions(paths: ClientPaths, keep: string): void {
if (!existsSync(paths.versionsDir)) return;
for (const entry of readdirSync(paths.versionsDir)) {
const path = join(paths.versionsDir, entry);
if (resolve(path) === resolve(keep)) continue;
try { rmSync(path, { recursive: true, force: true }); } catch { /* Windows may keep the currently running old CLI open. */ }
}
}
function exactPackage(root: string, name: string): boolean {
try {
const metadata = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: string };
return metadata.name === name;
} catch { return false; }
}
function existsOrSymlink(path: string): boolean {
try { lstatSync(path); return true; } catch { return false; }
}
function normalizedHttpsUrl(value: string, label: string): URL {
let url: URL;
try { url = new URL(value); } catch { throw new Error(`${label} 无效`); }
if (url.protocol !== "https:" || url.username || url.password) throw new Error(`${label} 必须是无内嵌凭证的 HTTPS URL`);
return url;
}
function sha256(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); }
function safeName(value: string): string { return value.replace(/[^a-zA-Z0-9.-]/g, "-"); }
function mkdirPrivate(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") chmodSync(path, 0o700);
}
function writeAtomic(path: string, body: Buffer, mode: number): void {
mkdirPrivate(dirname(path));
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
writeFileSync(temporary, body, { mode });
if (process.platform !== "win32") chmodSync(temporary, mode);
renameSync(temporary, path);
}
function pathsForRoot(root: string, platform: NodeJS.Platform): ClientPaths {
return clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: root }, platform, dirname(root));
}
function resolveInside(root: string, child: string): string {
const result = resolve(root, child);
if (!isInside(root, result)) throw new Error("安装状态包含越界路径");
return result;
}
function isInside(root: string, child: string): boolean {
if (!child) return false;
const value = relative(resolve(root), resolve(child));
return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
}
function isSafeLegacySessionPath(path: string, home: string): boolean {
return isInside(home, path) && basename(path) === "dingtalk-approval-bridge";
}
function assertSafeClientRoot(root: string, home: string): void {
const resolved = resolve(root);
const name = basename(resolved).toLowerCase();
if (resolved === resolve(home) || dirname(resolved) === resolved || !["dingtalk-approval", "dingtalkapproval"].includes(name)) {
throw new Error("客户端目录必须是专用的 dingtalk-approval 用户数据目录");
}
}
function scheduleSelfCleanup(root: string): void {
const cleanupRoot = mkdtempSync(join(tmpdir(), "dingtalk-approval-cleanup-"));
const helper = join(cleanupRoot, "cleanup.mjs");
writeFileSync(helper, `import { rmSync } from "node:fs";\nsetTimeout(() => { rmSync(${JSON.stringify(root)}, { recursive: true, force: true }); rmSync(${JSON.stringify(cleanupRoot)}, { recursive: true, force: true }); }, 750);\n`, { mode: 0o700 });
const child = spawn(process.execPath, [helper], { detached: true, stdio: "ignore" });
child.unref();
}
function writeResult(value: Record<string, unknown>, args: ParsedArgs, context: Pick<CommandContext, "stdout">): void {
if (args.command === "print-path" && args.pathKind && !args.json) {
context.stdout(`${String(value.path ?? "")}\n`);
return;
}
if (args.json) context.stdout(`${JSON.stringify(value)}\n`);
else context.stdout(`${JSON.stringify(value, null, 2)}\n`);
}
function usage(): string {
return "用法:dingtalk-approval install|update|uninstall|print-path|print-config|doctor [--manifest-url URL] [--format mcp-json] [--json]";
}
if (isClientDirectEntrypoint(import.meta.url, process.argv[1])) {
runClientInstaller().then((code) => { process.exitCode = code; });
}
function isClientDirectEntrypoint(moduleUrl: string, argv1: string | undefined): boolean {
if (!argv1) return false;
const modulePath = fileURLToPath(moduleUrl);
try { return realpathSync(modulePath) === realpathSync(argv1); }
catch { return resolve(modulePath) === resolve(argv1); }
}