#!/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"; 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 = process.env, platform = process.platform, home = homedir()) { 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) { const der = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" }); return createHash("sha256").update(der).digest("hex"); } export function verifySignedManifest(manifestBytes, signatureBytes, publicKeyPem, manifestUrl) { let envelope; try { envelope = JSON.parse(signatureBytes.toString("utf8")); } catch { throw new Error("发布清单签名文件不是有效 JSON"); } const keyId = releaseKeyId(publicKeyPem); if (envelope.schemaVersion !== 1 || envelope.algorithm !== "Ed25519" || envelope.keyId !== keyId) { throw new Error("发布清单签名的算法或密钥编号不受信任"); } let signature; 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; try { manifest = JSON.parse(manifestBytes.toString("utf8")); } catch { throw new Error("发布清单不是有效 JSON"); } validateManifest(manifest, manifestUrl, keyId); return manifest; } export async function runClientInstaller(argv = process.argv.slice(2), overrides = {}) { const context = { 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; 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; } } function parseClientArgs(argv) { const values = [...argv]; if (values[0] === "--print-path") values.splice(0, 1, "print-path"); const command = values.shift() ?? ""; const strings = new Map(); const stringLists = new Map(); const flags = new Set(); let pathKind; 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, }; } async function installClient(paths, args, context) { requireNodeVersion(); mkdirPrivate(paths.root); reportInstallProgress(context, "启动", "正在取得单实例安装锁"); const releaseLock = acquireClientInstallLock(paths.root); try { return await installClientLocked(paths, args, context); } finally { releaseLock(); } } async function installClientLocked(paths, args, context) { 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 = { 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 = []; 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, args, context) { if (!args.yes) throw new Error("卸载会删除运行时、Skill 和 OAuth 登录会话;请使用 --yes 明确确认"); const state = readState(paths.stateFile); if (existsSync(paths.root) && !state) throw new Error("客户端目录缺少有效安装记录,拒绝递归删除;请人工核对该目录"); const legacyCleanupFailed = []; 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, args) { 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; return { ok: true, ...(kind ? { path: values[kind] } : values) }; } function printConfig(paths, args) { 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, context) { const checks = []; 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) { 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, args, options) { 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) { 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, timeoutMs) { const errorCode = result.error?.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, stage, message) { context.stderr(`[dingtalk-approval] ${stage} ${message}\n`); } export function acquireClientInstallLock(root) { 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")); if (owner.token === token) rmSync(lockDir, { recursive: true, force: true }); } catch { /* another process already recovered or removed the lock */ } }; } catch (error) { if (error.code !== "EEXIST") throw error; if (installLockIsActive(lockDir)) { throw new Error("另一个安装或更新进程仍在运行;请等待它结束,不要并发重试"); } rmSync(lockDir, { recursive: true, force: true }); } } throw new Error("无法恢复上次被中断的安装锁,请稍后重试"); } function installLockIsActive(lockDir) { 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")); 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) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } } function cleanupInterruptedInstallStaging(versionsDir) { for (const entry of readdirSync(versionsDir)) { if (!entry.startsWith(".staging-")) continue; rmSync(join(versionsDir, entry), { recursive: true, force: true }); } } function npmInvocation(context, args) { 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 = process.env, platform = process.platform, home = homedir(), failed = []) { const removed = []; 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, platform, home) { 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) { 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, state, legacyRemoved, legacyCleanupFailed, postInstall) { 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, context) { 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) { return { command: paths.cliCommand, args: ["login", "--json"] }; } function attemptLogin(clientEntry, gatewayUrl, paths, context) { const shared = { required: true, attempted: true, confirmTool: { name: "approval_login", arguments: {} }, verifyTool: { name: "approval_list_todos", arguments: { source: "all", query: "", limit: 1 } }, 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; try { value = JSON.parse(line ?? ""); } 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 : 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, allowedFlags = process.allowedNodeEnvironmentFlags) { 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) { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value; if (record.firstRun !== true || !Array.isArray(record.examples)) return undefined; const examples = record.examples.filter((item) => 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) { return { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } }; } function installBundledSkill(paths, bundledSkillDir, skillBody) { 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, platform, nodeExecutable) { 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, nodeExecutable) { 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, manifestUrl, keyId) { 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 { fallbackAllowed; constructor(message, fallbackAllowed, options) { super(message, options); this.fallbackAllowed = fallbackAllowed; this.name = "DownloadFailure"; } } async function download(context, url) { 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, env = process.env, platform = process.platform) { 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((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 = []; const stderr = []; let stdoutBytes = 0; let stderrBytes = 0; let settled = false; let timedOut = false; let outputExceeded = false; const settle = (callback) => { if (settled) return; settled = true; clearTimeout(timeout); callback(); }; const rejectOversizedOutput = () => { if (outputExceeded) return; outputExceeded = true; child.kill("SIGTERM"); }; child.stdout.on("data", (chunk) => { stdoutBytes += chunk.length; if (stdoutBytes > outputLimit) rejectOversizedOutput(); else stdout.push(chunk); }); child.stderr.on("data", (chunk) => { 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) => { 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, url) { 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 = []; 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, urls, publicKey, onFallback) { let lastError; 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, artifact, target, onFallback) { const urls = artifactUrls(artifact, "artifact URL"); let lastError; 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, previous) { 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, label) { 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, label) { 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) { return [...new Map(values.map((value) => [value.toString(), value])).values()]; } function isNetworkError(error) { const code = error?.code ?? (error?.cause?.code); return typeof code === "string" && ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "ETIMEDOUT"].includes(code); } function resolvePublicKeyPath(paths, explicit) { 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() { 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, right) { 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) { if (!existsSync(path)) return undefined; try { const value = JSON.parse(readFileSync(path, "utf8")); return value.schemaVersion === 1 && VERSION_PATTERN.test(value.version) ? value : undefined; } catch { return undefined; } } function assertInstalled(paths) { const state = readState(paths.stateFile); if (!state) throw new Error("客户端尚未安装或安装状态已损坏,请重新运行 install"); return state; } function cleanupOldVersions(paths, keep) { 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, name) { try { const metadata = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); return metadata.name === name; } catch { return false; } } function existsOrSymlink(path) { try { lstatSync(path); return true; } catch { return false; } } function normalizedHttpsUrl(value, label) { let 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) { return createHash("sha256").update(value).digest("hex"); } function safeName(value) { return value.replace(/[^a-zA-Z0-9.-]/g, "-"); } function mkdirPrivate(path) { mkdirSync(path, { recursive: true, mode: 0o700 }); if (process.platform !== "win32") chmodSync(path, 0o700); } function writeAtomic(path, body, mode) { 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, platform) { return clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: root }, platform, dirname(root)); } function resolveInside(root, child) { const result = resolve(root, child); if (!isInside(root, result)) throw new Error("安装状态包含越界路径"); return result; } function isInside(root, child) { if (!child) return false; const value = relative(resolve(root), resolve(child)); return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value)); } function isSafeLegacySessionPath(path, home) { return isInside(home, path) && basename(path) === "dingtalk-approval-bridge"; } function assertSafeClientRoot(root, home) { 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) { const executable = realpathSync(process.execPath); const managed = isInside(paths.root, executable); return { executable, storedExecutable: managed ? relative(paths.root, executable) : executable, managed, }; } function configuredNodeExecutable(paths, state) { 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) { if (/\0|\r|\n/.test(value)) throw new Error("Node 可执行文件路径包含 shell launcher 不支持的字符"); return `'${value.replace(/'/g, `'"'"'`)}'`; } function scheduleSelfCleanup(root, platform) { 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, args, context) { 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() { 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, argv1) { if (!argv1) return false; const modulePath = fileURLToPath(moduleUrl); try { return realpathSync(modulePath) === realpathSync(argv1); } catch { return resolve(modulePath) === resolve(argv1); } } //# sourceMappingURL=client-installer.js.map