Files
dingMCP/src/client-installer.ts
T

1286 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import {
chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync,
} 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;
mirrors?: string[];
sha256: string;
}
export interface ClientReleaseManifest {
schemaVersion: 3;
version: string;
createdAt: string;
gatewayUrl: string;
signing: { algorithm: "Ed25519"; keyId: string };
distribution?: { manifestMirrors?: string[] };
requirements: {
node: string;
managedNode?: { version: string; platforms: string[] };
};
artifacts: {
installer: ReleaseArtifact;
publicKey: ReleaseArtifact & { keyId: string };
client: ReleaseArtifact;
skill: ReleaseArtifact;
bootstrapWindows?: ReleaseArtifact;
bootstrapPosix?: ReleaseArtifact;
};
}
export interface ClientInstallState {
schemaVersion: 1;
version: string;
installedAt: string;
manifestUrl: string;
manifestMirrors?: string[];
gatewayUrl: string;
clientSha256: string;
skillSha256: string;
runtimeDir: string;
skillFile: string;
nodeExecutable?: string;
managedNode?: boolean;
nodeVersion?: 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;
}
type DownloadContext = Pick<CommandContext, "env" | "platform" | "fetchFn">;
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;
const NPM_CHECK_TIMEOUT_MS = 15_000;
const NPM_INSTALL_TIMEOUT_MS = 180_000;
const INSTALL_LOCK_STALE_MS = 15 * 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) => { writeSync(1, value); }),
stderr: overrides.stderr ?? ((value) => { writeSync(2, 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 === "login") result = loginClient(paths, 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.stderr(`[dingtalk-approval] 失败 ${message}\n`);
context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`);
} else context.stderr(`${message}\n`);
return 1;
}
}
interface ParsedArgs {
command: string;
manifestUrl?: string;
manifestMirrorUrls: 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 stringLists = new Map<string, string[]>();
const flags = new Set<string>();
let pathKind: string | undefined;
const stringOptions = new Set(["--manifest-url", "--manifest-mirror-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 (value === "--manifest-mirror-url") {
stringLists.set(value, [...(stringLists.get(value) ?? []), next]);
} else {
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", "--manifest-mirror-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
: command === "login" ? ["--app-root", "--json"]
: 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(), ...stringLists.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");
if ((stringLists.get("--manifest-mirror-url")?.length ?? 0) > 4) throw new Error("--manifest-mirror-url 最多允许 4 个");
return {
command,
manifestUrl: strings.get("--manifest-url"),
manifestMirrorUrls: stringLists.get("--manifest-mirror-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();
mkdirPrivate(paths.root);
reportInstallProgress(context, "启动", "正在取得单实例安装锁");
const releaseLock = acquireClientInstallLock(paths.root);
try {
return await installClientLocked(paths, args, context);
} finally {
releaseLock();
}
}
async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Promise<Record<string, unknown>> {
reportInstallProgress(context, "1/6", "检查 Node、npm 与本地安装状态");
const npmCheck = checkNpm(context);
if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`);
const previous = readState(paths.stateFile);
const manifestUrls = configuredManifestUrls(args, previous);
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
const publicKey = readFileSync(publicKeyPath);
reportInstallProgress(context, "2/6", "下载并验证签名发布清单");
const { manifest } = await downloadSignedManifest(context, manifestUrls, publicKey, (failed, next) => {
reportInstallProgress(context, "2/6", `下载源不可用(${failed.origin}),切换至 ${next.origin}`);
});
const persistedManifestUrls = uniqueUrls([
...manifestUrls,
...(manifest.distribution?.manifestMirrors ?? []).map((value) => normalizedManifestUrl(value, "manifest mirror URL")),
]);
if (persistedManifestUrls.length > 5) throw new Error("签名清单与本地配置合计最多允许 4 个备用源");
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 {
reportInstallProgress(context, "3/6", `下载并校验 ${manifest.version} 客户端发布物`);
const clientArchive = join(temporary, "client.tgz");
const [, skillBody, publicArtifact] = await Promise.all([
downloadArtifact(context, manifest.artifacts.client, clientArchive, (failed, next) => {
reportInstallProgress(context, "3/6", `客户端包下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
downloadArtifact(context, manifest.artifacts.skill, join(temporary, "SKILL.md"), (failed, next) => {
reportInstallProgress(context, "3/6", `Skill 下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
downloadArtifact(context, manifest.artifacts.publicKey, join(temporary, "release-public.pem"), (failed, next) => {
reportInstallProgress(context, "3/6", `发布公钥下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
]);
if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) {
throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装");
}
mkdirPrivate(paths.versionsDir);
cleanupInterruptedInstallStaging(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)) {
reportInstallProgress(context, "4/6", "离线展开客户端运行时;首次安装可能需要一些时间");
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],
{ cache: join(temporary, "npm-cache"), timeoutMs: NPM_INSTALL_TIMEOUT_MS },
);
if (installed.error || installed.status !== 0) {
rmSync(staging, { recursive: true, force: true });
throw new Error(`客户端运行时安装失败:${npmFailureMessage(installed, NPM_INSTALL_TIMEOUT_MS)}`);
}
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);
} else {
reportInstallProgress(context, "4/6", "复用已校验的本地客户端运行时");
}
reportInstallProgress(context, "5/6", "写入稳定启动器、Skill 与安装状态");
installBundledSkill(paths, join(versionDir, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval"), skillBody);
writeAtomic(paths.publicKeyFile, publicArtifact, 0o600);
const nodeRuntime = activeNodeRuntime(paths);
const state: ClientInstallState = {
schemaVersion: 1,
version: manifest.version,
installedAt: new Date().toISOString(),
manifestUrl: persistedManifestUrls[0]!.toString(),
...(persistedManifestUrls.length > 1 ? { manifestMirrors: persistedManifestUrls.slice(1).map(String) } : {}),
gatewayUrl: manifest.gatewayUrl,
clientSha256: manifest.artifacts.client.sha256,
skillSha256: manifest.artifacts.skill.sha256,
runtimeDir: relative(paths.root, versionDir),
skillFile: relative(paths.root, paths.skillFile),
nodeExecutable: nodeRuntime.storedExecutable,
managedNode: nodeRuntime.managed,
nodeVersion: process.versions.node,
};
writeLaunchers(paths, context.platform, nodeRuntime.executable);
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);
reportInstallProgress(context, "6/6", "连接网关并准备登录状态");
const postInstall = attemptLogin(clientEntry, manifest.gatewayUrl, paths, context);
reportInstallProgress(context, "完成", "客户端安装成功,正在输出最终 JSON");
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, context.platform);
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 configuredNode = configuredNodeExecutable(paths, state);
checks.push({
name: "nodeRuntime",
ok: existsSync(configuredNode),
path: configuredNode,
managed: state.managedNode === true,
version: state.nodeVersion ?? "legacy",
});
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"], { timeoutMs: NPM_CHECK_TIMEOUT_MS });
if (result.error) return { ok: false, message: npmFailureMessage(result, NPM_CHECK_TIMEOUT_MS) };
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 未返回版本号" };
}
export function runNpm(
context: Pick<CommandContext, "env" | "platform">,
args: string[],
options: { cache?: string; timeoutMs: number },
) {
const invocation = npmInvocation(context, args);
const npmEnv = npmChildEnvironment(context.env);
return spawnSync(invocation.command, invocation.args, {
encoding: "utf8",
timeout: options.timeoutMs,
killSignal: "SIGTERM",
maxBuffer: 2 * 1024 * 1024,
env: {
...npmEnv,
...(options.cache ? { npm_config_cache: options.cache } : {}),
npm_config_update_notifier: "false",
},
windowsHide: true,
});
}
export function npmChildEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const child = { ...env };
for (const key of Object.keys(child)) {
const normalized = key.toLowerCase();
if (normalized === "node_use_env_proxy") {
delete child[key];
continue;
}
if (normalized !== "node_options") continue;
const value = child[key] ?? "";
const cleaned = value
.replace(/(^|[\t\r\n ])--use-env-proxy(?=$|[\t\r\n ])/g, "$1")
.replace(/[\t ]{2,}/g, " ")
.trim();
if (cleaned) child[key] = cleaned;
else delete child[key];
}
return child;
}
export function npmFailureMessage(
result: { error?: Error; signal: NodeJS.Signals | null; status: number | null; stderr: string; stdout: string },
timeoutMs: number,
): string {
const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code;
if (errorCode === "ETIMEDOUT") return `npm 超过 ${Math.ceil(timeoutMs / 1000)} 秒仍未完成,已安全终止;请检查磁盘、杀毒软件或宿主工具超时设置`;
if (result.error) return result.error.message;
if (result.signal) return `npm 被信号 ${result.signal} 终止`;
const details = (result.stderr || result.stdout).trim();
return details ? details.slice(-4_000) : `npm 退出码 ${result.status ?? "unknown"},未返回诊断文本`;
}
function reportInstallProgress(context: Pick<CommandContext, "stderr">, stage: string, message: string): void {
context.stderr(`[dingtalk-approval] ${stage} ${message}\n`);
}
export function acquireClientInstallLock(root: string): () => void {
const lockDir = join(root, ".install-lock");
const ownerFile = join(lockDir, "owner.json");
const token = randomUUID();
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
mkdirSync(lockDir, { mode: 0o700 });
if (process.platform !== "win32") chmodSync(lockDir, 0o700);
try {
writeAtomic(ownerFile, Buffer.from(`${JSON.stringify({ pid: process.pid, token, startedAt: new Date().toISOString() })}\n`), 0o600);
} catch (error) {
rmSync(lockDir, { recursive: true, force: true });
throw error;
}
return () => {
try {
const owner = JSON.parse(readFileSync(ownerFile, "utf8")) as { token?: unknown };
if (owner.token === token) rmSync(lockDir, { recursive: true, force: true });
} catch { /* another process already recovered or removed the lock */ }
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
if (installLockIsActive(lockDir)) {
throw new Error("另一个安装或更新进程仍在运行;请等待它结束,不要并发重试");
}
rmSync(lockDir, { recursive: true, force: true });
}
}
throw new Error("无法恢复上次被中断的安装锁,请稍后重试");
}
function installLockIsActive(lockDir: string): boolean {
let recent = true;
try { recent = Date.now() - statSync(lockDir).mtimeMs < INSTALL_LOCK_STALE_MS; }
catch { return false; }
try {
const owner = JSON.parse(readFileSync(join(lockDir, "owner.json"), "utf8")) as { pid?: unknown };
if (Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0) return recent && processIsAlive(Number(owner.pid));
} catch { /* fall back to the lock directory age */ }
return recent;
}
function processIsAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
function cleanupInterruptedInstallStaging(versionsDir: string): void {
for (const entry of readdirSync(versionsDir)) {
if (!entry.startsWith(".staging-")) continue;
rmSync(join(versionsDir, entry), { recursive: true, force: true });
}
}
function npmInvocation(context: Pick<CommandContext, "env" | "platform">, args: string[]): { command: string; args: string[] } {
const pathSeparator = context.platform === "win32" ? ";" : ":";
const candidates = [
context.env.npm_execpath,
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
resolve(dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
...(context.env.PATH ?? "").split(pathSeparator).filter(Boolean).map((entry) => join(entry.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npm-cli.js")),
];
const npmCli = candidates.find((candidate) => candidate && existsSync(candidate));
if (npmCli) return { command: process.execPath, args: [npmCli, ...args] };
if (context.platform !== "win32") return { command: "npm", args };
throw new Error("Windows 环境未找到可由 Node 安全启动的 npm-cli.js;请运行公开安装引导修复私有 Node 运行时");
}
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 = [
"1. 检查 ok=true,并保存 cliPath、skillPath、mcpConfig、loginCommand 和 postInstall;不得自行猜测或改写路径。",
"2. 按当前 Agent 的官方方式安装整个 skillPath,并只新增或替换名为 dingtalk-approval 的本地 stdio MCP。必须原样使用 mcpConfig;Windows 保留返回的 .cmd command 和空 args,禁止把网关 URL 配成 MCP。",
"3. 按当前 Agent 的官方流程信任连接器;不能热加载时完整重启 Agent或新建会重新读取 MCP 配置的会话。",
"4. 立即处理 postInstall:若有 authChallenge.directUrl,必须在当前回复中原样展示,不得声称已另行推送,也不得等待用户首次查询审批。",
"5. 用户授权后调用 approval_login;登录成功后按 verifyTool 做一次双来源只读验证,确认钉钉 OA 与薪福通均为 ok,并展示一次性使用示例。",
"6. 登录链接过期时绝不重新安装:MCP 已加载就再次调用 approval_login;尚未加载就执行 loginCommand。",
"7. 登录和双来源验证全部完成前不得宣告可用;安装期间不得执行任何审批写操作。",
];
return {
ok: true,
status: "installed",
version: state.version,
root: paths.root,
bridgePath: paths.bridgeCommand,
cliPath: paths.cliCommand,
loginCommand: loginCommand(paths),
skillPath: paths.skillDir,
mcpConfig: mcpConfig(paths),
legacyEntriesRemoved: legacyRemoved,
legacyCleanupFailed,
configurationModified: false,
postInstall: { ...postInstall, loginCommand: loginCommand(paths) },
nodeRuntime: {
version: state.nodeVersion ?? process.versions.node,
managed: state.managedNode === true,
executable: configuredNodeExecutable(paths, state),
},
nextSteps,
};
}
function loginClient(paths: ClientPaths, context: Pick<CommandContext, "env">): Record<string, unknown> {
const state = assertInstalled(paths);
const runtime = resolveInside(paths.root, state.runtimeDir);
const clientEntry = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js");
if (!existsSync(clientEntry)) throw new Error("客户端运行时缺少 Bridge 入口,请运行 update 修复安装");
return {
ok: true,
operation: "login",
...attemptLogin(clientEntry, state.gatewayUrl, paths, context),
loginCommand: loginCommand(paths),
};
}
function loginCommand(paths: ClientPaths): { command: string; args: ["login", "--json"] } {
return { command: paths.cliCommand, args: ["login", "--json"] };
}
function attemptLogin(
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, [...nodeProxyArgs(context.env), clientEntry, "--url", gatewayUrl, "--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 重试。",
};
}
export function nodeProxyArgs(
env: NodeJS.ProcessEnv,
allowedFlags: ReadonlySet<string> = process.allowedNodeEnvironmentFlags,
): string[] {
const proxyConfigured = Object.entries(env).some(([key, value]) =>
["https_proxy", "http_proxy", "all_proxy"].includes(key.toLowerCase()) && Boolean(value?.trim()));
if (!proxyConfigured || !allowedFlags.has("--use-env-proxy")) return [];
const nodeOptions = Object.entries(env).find(([key]) => key.toLowerCase() === "node_options")?.[1] ?? "";
const enabledByNodeOptions = /(^|[\t\r\n ])--use-env-proxy(?=$|[\t\r\n ])/.test(nodeOptions);
const enabledByDedicatedVariable = Object.entries(env).some(([key, value]) =>
key.toLowerCase() === "node_use_env_proxy" && /^(?:1|true)$/i.test(value?.trim() ?? ""));
return enabledByNodeOptions || enabledByDedicatedVariable ? [] : ["--use-env-proxy"];
}
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 installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillBody: Buffer): void {
const createReference = join(bundledSkillDir, "references", "create-approval.md");
const draftReference = join(bundledSkillDir, "references", "local-drafts.md");
if (!existsSync(join(bundledSkillDir, "SKILL.md"))
|| !existsSync(createReference)
|| !existsSync(draftReference)) {
throw new Error("客户端包缺少完整 Skill 资源");
}
const parent = dirname(paths.skillDir);
mkdirPrivate(parent);
const staging = join(parent, `.dingtalk-approval.staging-${process.pid}-${randomUUID()}`);
const backup = join(parent, `.dingtalk-approval.backup-${process.pid}-${randomUUID()}`);
try {
cpSync(bundledSkillDir, staging, { recursive: true });
writeAtomic(join(staging, "SKILL.md"), skillBody, 0o600);
if (existsSync(paths.skillDir)) renameSync(paths.skillDir, backup);
renameSync(staging, paths.skillDir);
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
} catch (error) {
if (!existsSync(paths.skillDir) && existsSync(backup)) renameSync(backup, paths.skillDir);
if (existsSync(staging)) rmSync(staging, { recursive: true, force: true });
throw error;
}
}
function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform, nodeExecutable: string): 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 proxyConfigured = Object.entries(env).some(([key, value]) => ["https_proxy", "http_proxy", "all_proxy"].includes(key.toLowerCase()) && Boolean(value?.trim()));\nconst nodeOptions = Object.entries(env).find(([key]) => key.toLowerCase() === "node_options")?.[1] ?? "";\nconst proxyAlreadyEnabled = /(^|[\\t\\r\\n ])--use-env-proxy(?=$|[\\t\\r\\n ])/.test(nodeOptions) || Object.entries(env).some(([key, value]) => key.toLowerCase() === "node_use_env_proxy" && /^(?:1|true)$/i.test(value?.trim() ?? ""));\nconst proxyArgs = proxyConfigured && process.allowedNodeEnvironmentFlags.has("--use-env-proxy") && !proxyAlreadyEnabled ? ["--use-env-proxy"] : [];\nconst child = spawn(process.execPath, [...proxyArgs, 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);
const bodies = clientLauncherBodies(platform, nodeExecutable);
writeAtomic(paths.cliCommand, Buffer.from(bodies.cli), 0o700);
writeAtomic(paths.bridgeCommand, Buffer.from(bodies.bridge), 0o700);
}
export function clientLauncherBodies(
platform: NodeJS.Platform,
nodeExecutable: string,
): { cli: string; bridge: string } {
if (platform === "win32") {
if (/["\r\n]/.test(nodeExecutable)) throw new Error("Node 可执行文件路径包含 Windows launcher 不支持的字符");
const command = nodeExecutable.replace(/%/g, "%%");
return {
cli: `@echo off\r\n"${command}" "%~dp0launcher.mjs" cli %*\r\n`,
bridge: `@echo off\r\n"${command}" "%~dp0launcher.mjs" bridge %*\r\n`,
};
}
const command = shellQuote(nodeExecutable);
return {
cli: `#!/usr/bin/env sh\nexec ${command} "$(dirname "$0")/launcher.mjs" cli "$@"\n`,
bridge: `#!/usr/bin/env sh\nexec ${command} "$(dirname "$0")/launcher.mjs" bridge "$@"\n`,
};
}
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");
if (!base.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
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 urls = artifactUrls(artifact, `${name} URL`);
if (urls.length !== new Set(urls.map(String)).size) throw new Error(`发布物 ${name} 包含重复下载地址`);
}
const manifestMirrors = manifest.distribution?.manifestMirrors ?? [];
if (!Array.isArray(manifestMirrors)) throw new Error("manifest mirrors 必须是数组");
if (manifestMirrors.length > 4) throw new Error("manifest mirrors 最多允许 4 个");
for (const value of manifestMirrors) normalizedManifestUrl(value, "manifest mirror URL");
if (manifestMirrors.length !== new Set(manifestMirrors).size) throw new Error("manifest mirrors 包含重复地址");
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 天有效期,请获取新的安装链接");
}
class DownloadFailure extends Error {
constructor(message: string, readonly fallbackAllowed: boolean, options?: ErrorOptions) {
super(message, options);
this.name = "DownloadFailure";
}
}
async function download(context: DownloadContext, url: URL): Promise<Buffer> {
if (context.fetchFn === fetch) {
try {
return await downloadWithCurl(url, context.env, context.platform);
} catch (error) {
if (!(error instanceof NativeDownloaderUnavailable)) throw error;
}
}
return downloadWithFetch(context.fetchFn, url);
}
class NativeDownloaderUnavailable extends Error {}
export async function downloadWithCurl(
url: URL,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): Promise<Buffer> {
const temporary = mkdtempSync(join(tmpdir(), "dingtalk-approval-download-"));
const target = join(temporary, "body");
try {
const command = platform === "win32" ? "curl.exe" : "curl";
const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, rejectResult) => {
const child = spawn(command, [
"--fail", "--location", "--silent", "--show-error",
"--proto", "=https", "--proto-redir", "=https", "--tlsv1.2",
"--connect-timeout", "10", "--max-time", String(Math.ceil(DOWNLOAD_TIMEOUT_MS / 1_000)),
"--max-redirs", "5", "--max-filesize", String(MAX_DOWNLOAD_BYTES),
"--output", target, "--write-out", "%{http_code}", url.toString(),
], {
env,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
const outputLimit = 256 * 1024;
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
let timedOut = false;
let outputExceeded = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback();
};
const rejectOversizedOutput = () => {
if (outputExceeded) return;
outputExceeded = true;
child.kill("SIGTERM");
};
child.stdout.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes > outputLimit) rejectOversizedOutput();
else stdout.push(chunk);
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBytes += chunk.length;
if (stderrBytes > outputLimit) rejectOversizedOutput();
else stderr.push(chunk);
});
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, DOWNLOAD_TIMEOUT_MS + 5_000);
child.once("error", (error: NodeJS.ErrnoException) => {
settle(() => {
if (error.code === "ENOENT") rejectResult(new NativeDownloaderUnavailable("系统 curl 不可用"));
else rejectResult(new DownloadFailure(`curl 启动失败:${error.message}`, false, { cause: error }));
});
});
child.once("close", (status) => {
settle(() => {
if (timedOut) {
rejectResult(new DownloadFailure(`curl 下载超时(${Math.ceil(DOWNLOAD_TIMEOUT_MS / 1_000)} 秒):${url}`, true));
} else if (outputExceeded) {
rejectResult(new DownloadFailure(`curl 输出超过 256KB 安全限制:${url}`, false));
} else {
resolveResult({
status,
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
});
}
});
});
});
const curlCode = result.status;
const httpStatus = Number.parseInt(result.stdout.trim().slice(-3), 10);
if (curlCode !== 0) {
const networkFailure = [5, 6, 7, 18, 28, 35, 52, 55, 56].includes(curlCode ?? -1);
const fallbackAllowed = networkFailure || (curlCode === 22 && httpStatus === 404);
const detail = result.stderr.trim().slice(-2_000) || `curl 退出码 ${curlCode ?? "unknown"}`;
throw new DownloadFailure(`curl 下载失败${Number.isFinite(httpStatus) && httpStatus > 0 ? `(HTTP ${httpStatus})` : ""}:${url};${detail}`, fallbackAllowed);
}
if (!existsSync(target)) throw new Error(`curl 未生成下载文件:${url}`);
const size = statSync(target).size;
if (size > MAX_DOWNLOAD_BYTES) throw new Error(`下载文件超过 25MB 限制:${url}`);
return readFileSync(target);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
}
async function downloadWithFetch(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 DownloadFailure(`下载失败 ${response.status}:${url}`, response.status === 404);
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 DownloadFailure(`下载超时(30 秒):${url}`, true, { cause: error });
if (error instanceof DownloadFailure) throw error;
if (error instanceof TypeError || isNetworkError(error)) {
throw new DownloadFailure(`网络连接失败:${url}`, true, { cause: error });
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function downloadSignedManifest(
context: DownloadContext,
urls: URL[],
publicKey: Buffer,
onFallback?: (failed: URL, next: URL) => void,
): Promise<{ manifest: ClientReleaseManifest; source: URL }> {
let lastError: unknown;
for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]!;
try {
const manifestBytes = await download(context, url);
const signatureBytes = await download(context, new URL("manifest.sig", url));
return { manifest: verifySignedManifest(manifestBytes, signatureBytes, publicKey, url.toString()), source: url };
} catch (error) {
lastError = error;
const next = urls[index + 1];
if (!next || !(error instanceof DownloadFailure) || !error.fallbackAllowed) throw error;
onFallback?.(url, next);
}
}
throw lastError instanceof Error ? lastError : new Error("所有签名清单下载源均不可用");
}
async function downloadArtifact(
context: DownloadContext,
artifact: ReleaseArtifact,
target: string,
onFallback?: (failed: URL, next: URL) => void,
): Promise<Buffer> {
const urls = artifactUrls(artifact, "artifact URL");
let lastError: unknown;
for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]!;
try {
const body = await download(context, url);
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
writeAtomic(target, body, 0o600);
return body;
} catch (error) {
lastError = error;
const next = urls[index + 1];
if (!next || !(error instanceof DownloadFailure) || !error.fallbackAllowed) throw error;
onFallback?.(url, next);
}
}
throw lastError instanceof Error ? lastError : new Error("所有发布物下载源均不可用");
}
function configuredManifestUrls(args: ParsedArgs, previous: ClientInstallState | undefined): URL[] {
const primary = normalizedManifestUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL");
const inherited = args.manifestUrl ? [] : (previous?.manifestMirrors ?? []);
const urls = uniqueUrls([primary, ...inherited.map((value) => normalizedManifestUrl(value, "manifest mirror URL")),
...args.manifestMirrorUrls.map((value) => normalizedManifestUrl(value, "manifest mirror URL"))]);
if (urls.length > 5) throw new Error("manifest 下载地址最多允许 1 个主源和 4 个备用源");
return urls;
}
function normalizedManifestUrl(value: string, label: string): URL {
const url = normalizedHttpsUrl(value, label);
if (!url.pathname.endsWith("/manifest.json") || url.search || url.hash) throw new Error(`${label} 必须是 HTTPS manifest.json 地址`);
return url;
}
function artifactUrls(artifact: ReleaseArtifact, label: string): URL[] {
if (artifact.mirrors !== undefined && !Array.isArray(artifact.mirrors)) throw new Error(`${label} mirrors 必须是数组`);
if ((artifact.mirrors?.length ?? 0) > 4) throw new Error(`${label} mirrors 最多允许 4 个`);
return [artifact.url, ...(artifact.mirrors ?? [])].map((value) => normalizedHttpsUrl(value, label));
}
function uniqueUrls(values: URL[]): URL[] {
return [...new Map(values.map((value) => [value.toString(), value])).values()];
}
function isNetworkError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code
?? ((error as { cause?: NodeJS.ErrnoException } | undefined)?.cause?.code);
return typeof code === "string" && ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "ETIMEDOUT"].includes(code);
}
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 activeNodeRuntime(paths: ClientPaths): { executable: string; storedExecutable: string; managed: boolean } {
const executable = realpathSync(process.execPath);
const managed = isInside(paths.root, executable);
return {
executable,
storedExecutable: managed ? relative(paths.root, executable) : executable,
managed,
};
}
function configuredNodeExecutable(paths: ClientPaths, state: ClientInstallState): string {
if (!state.nodeExecutable) return process.execPath;
if (state.managedNode === true) return resolveInside(paths.root, state.nodeExecutable);
if (!isAbsolute(state.nodeExecutable)) throw new Error("安装状态中的系统 Node 路径不是绝对路径");
return resolve(state.nodeExecutable);
}
function shellQuote(value: string): string {
if (/\0|\r|\n/.test(value)) throw new Error("Node 可执行文件路径包含 shell launcher 不支持的字符");
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function scheduleSelfCleanup(root: string, platform: NodeJS.Platform): void {
const child = platform === "win32"
? spawn("powershell.exe", [
"-NoProfile", "-NonInteractive", "-Command",
"Start-Sleep -Milliseconds 900; Remove-Item -LiteralPath $args[0] -Recurse -Force", root,
], { detached: true, stdio: "ignore", windowsHide: true })
: spawn("/bin/sh", ["-c", "sleep 1; rm -rf -- \"$1\"", "dingtalk-approval-cleanup", root], {
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|login|uninstall|print-path|print-config|doctor [--manifest-url URL] [--manifest-mirror-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); }
}