1286 lines
59 KiB
JavaScript
1286 lines
59 KiB
JavaScript
#!/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); }
|
||
}
|