Files
dingMCP/src/bridge.ts
T
gitea-admin b3fa2315ba
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
feat: add standalone login renewal
2026-08-18 08:04:37 +00:00

535 lines
27 KiB
JavaScript

#!/usr/bin/env node
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
import { isDirectEntrypoint } from "./entrypoint.js";
interface BridgeState {
client?: { client_id: string; client_secret?: string };
tokens?: { access_token: string; refresh_token?: string; expires_in?: number; savedAt: number };
device?: {
device_code: string;
user_code: string;
verification_uri_complete: string;
expiresAt: number;
interval: number;
};
onboarding?: {
schemaVersion: 1;
status: "pending" | "delivered";
deliveredAt?: string;
};
}
export const APPROVAL_ONBOARDING_EXAMPLES = [
"帮我列出当前待处理的审批",
"帮我查看我处理过或我发起的审批",
"总结费用类待办,按金额和费用发生日期整理",
"列出我可以发起的审批模板",
"先列出待办,我确认后同意指定的几张",
] as const;
class AuthRequired extends Error {
constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); }
}
export class RemoteBridge {
private state: BridgeState;
private client?: Client;
private connecting?: Promise<Client>;
private activeCalls = 0;
private readonly callWaiters: Array<() => void> = [];
readonly endpoint: URL;
readonly origin: URL;
constructor(endpoint: string, private readonly statePath: string, private readonly fetchFn: typeof fetch = fetch) {
this.endpoint = normalizeEndpoint(endpoint);
this.origin = new URL(this.endpoint.origin);
this.state = this.loadState();
}
ensureOnboardingPending(): void {
const diskState = this.loadState();
this.state = diskState;
if (this.state.onboarding) return;
this.state.onboarding = { schemaVersion: 1, status: "pending" };
this.saveState();
}
async loginChallenge() {
await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken();
if (this.usableAccessToken()) {
const onboarding = this.takeOnboarding();
return {
authenticated: true,
message: onboarding ? `已登录,可以读取审批。\n\n${onboardingMessage()}` : "已登录,可以读取审批。",
...(onboarding ? { onboarding } : {}),
};
}
const challenge = await this.startDeviceLogin();
return this.presentChallenge(challenge);
}
async call(name: string, args: Record<string, unknown>): Promise<any> {
return this.withCallSlot(async () => {
try {
const client = await this.remoteClient();
return this.appendOnboarding(await client.callTool({ name, arguments: args }));
} catch (error) {
if (error instanceof AuthRequired) return this.authResult(error.challenge);
throw error;
}
});
}
private async withCallSlot<T>(work: () => Promise<T>): Promise<T> {
if (this.activeCalls < 3) this.activeCalls += 1;
else await new Promise<void>((resolve) => this.callWaiters.push(resolve));
try { return await work(); }
finally {
const next = this.callWaiters.shift();
if (next) next();
else this.activeCalls -= 1;
}
}
private async remoteClient(): Promise<Client> {
if (this.client && this.usableAccessToken()) return this.client;
if (this.client) {
const stale = this.client;
this.client = undefined;
try { await stale.close(); } catch { /* stale sessions are best-effort cleanup */ }
}
if (this.connecting) return this.connecting;
this.connecting = this.connectRemote();
try { return await this.connecting; } finally { this.connecting = undefined; }
}
private async connectRemote(allowRefreshRetry = true): Promise<Client> {
await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken();
if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin());
const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" });
const transport = new StreamableHTTPClientTransport(this.endpoint, {
requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } },
});
try { await client.connect(transport); }
catch (error) {
// A gateway restart, reverse-proxy reload or short network outage must
// not destroy an otherwise refreshable login session.
if (!isAuthenticationFailure(error)) {
throw new Error("审批网关暂时无法连接,已保留登录状态,请稍后重试", { cause: error });
}
const refreshToken = this.state.tokens?.refresh_token;
if (refreshToken && allowRefreshRetry) {
this.state.tokens = { access_token: "", refresh_token: refreshToken, expires_in: 0, savedAt: 0 };
this.saveState();
await this.refreshAccessToken();
if (this.usableAccessToken()) return this.connectRemote(false);
}
this.state.tokens = undefined;
this.saveState();
throw new AuthRequired(await this.startDeviceLogin());
}
this.client = client;
return client;
}
private usableAccessToken(): boolean {
const token = this.state.tokens;
return Boolean(token?.access_token && token.savedAt + (token.expires_in ?? 900) * 1000 > Date.now() + 30_000);
}
private async ensureClient(): Promise<NonNullable<BridgeState["client"]>> {
if (this.state.client?.client_id) return this.state.client;
const response = await this.fetchFn(new URL("/register", this.origin), {
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
client_name: "DingTalk Approval stdio bridge",
redirect_uris: ["https://localhost.invalid/dingtalk-approval-device"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
}),
});
const value = await response.json() as { client_id?: string; client_secret?: string };
if (!response.ok || !value.client_id) throw new Error("远程 MCP 拒绝注册兼容桥客户端");
this.state.client = { client_id: value.client_id, ...(value.client_secret ? { client_secret: value.client_secret } : {}) };
this.saveState();
return this.state.client;
}
private async startDeviceLogin(): Promise<NonNullable<BridgeState["device"]>> {
if (this.state.device && this.state.device.expiresAt > Date.now()) return this.state.device;
const client = await this.ensureClient();
const response = await this.fetchFn(new URL("/oauth/device/authorize", this.origin), {
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ client_id: client.client_id, scope: "mcp:tools", resource: this.endpoint.toString() }),
});
const value = await response.json() as Record<string, unknown>;
if (!response.ok || typeof value.device_code !== "string" || typeof value.verification_uri_complete !== "string") {
throw new Error("远程 MCP 无法创建钉钉登录链接");
}
this.state.device = {
device_code: value.device_code,
user_code: String(value.user_code ?? ""),
verification_uri_complete: value.verification_uri_complete,
expiresAt: Date.now() + Number(value.expires_in ?? 600) * 1000,
interval: Number(value.interval ?? 5),
};
this.saveState();
return this.state.device;
}
private async tryCompleteDeviceLogin(): Promise<void> {
const device = this.state.device;
const client = this.state.client;
if (!device || !client || device.expiresAt <= Date.now()) {
if (device) { this.state.device = undefined; this.saveState(); }
return;
}
const response = await this.fetchFn(new URL("/oauth/device/token", this.origin), {
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ client_id: client.client_id, device_code: device.device_code }),
});
const value = await response.json() as Record<string, unknown>;
if (!response.ok) {
if (value.error === "authorization_pending" || value.error === "slow_down") return;
if (isExplicitOAuthInvalidation(response.status, value)) {
this.state.device = undefined;
this.saveState();
}
return;
}
if (typeof value.access_token !== "string") return;
this.state.tokens = {
access_token: value.access_token,
...(typeof value.refresh_token === "string" ? { refresh_token: value.refresh_token } : {}),
expires_in: Number(value.expires_in ?? 900), savedAt: Date.now(),
};
this.state.device = undefined;
this.saveState();
}
private async refreshAccessToken(): Promise<void> {
const diskState = this.loadState();
if ((diskState.tokens?.savedAt ?? 0) > (this.state.tokens?.savedAt ?? 0)) {
this.state.tokens = diskState.tokens;
if (diskState.client?.client_id) this.state.client = diskState.client;
if (this.usableAccessToken()) return;
}
const refreshToken = this.state.tokens?.refresh_token;
const client = this.state.client;
if (!refreshToken || !client) return;
const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: client.client_id });
const response = await this.fetchFn(new URL("/token", this.origin), {
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body,
});
const value = await response.json() as Record<string, unknown>;
if (!response.ok || typeof value.access_token !== "string") {
// Another bridge process may have rotated the single-use refresh token
// while this request was in flight. Prefer its newer saved state.
const latest = this.loadState();
if ((latest.tokens?.savedAt ?? 0) > (this.state.tokens?.savedAt ?? 0)) {
this.state.tokens = latest.tokens;
if (latest.client?.client_id) this.state.client = latest.client;
if (this.usableAccessToken()) return;
}
if (isExplicitOAuthInvalidation(response.status, value)) {
this.state.tokens = undefined;
this.saveState();
return;
}
throw new Error("审批网关暂时无法刷新登录,已保留登录状态,请稍后重试");
}
this.state.tokens = {
access_token: value.access_token,
refresh_token: typeof value.refresh_token === "string" ? value.refresh_token : refreshToken,
expires_in: Number(value.expires_in ?? 900), savedAt: Date.now(),
};
this.saveState();
}
private presentChallenge(challenge: NonNullable<BridgeState["device"]>) {
const directUrl = challenge.verification_uri_complete;
const expiresAt = new Date(challenge.expiresAt).toISOString();
const message = [
"需要钉钉授权。系统不会另行推送或弹出登录链接。",
`请直接打开:[打开钉钉授权登录](${directUrl})`,
`备用原始链接:${directUrl}`,
...(challenge.user_code ? [`授权码:${challenge.user_code}`] : []),
`链接有效期至:${expiresAt}`,
"链接过期后让 Agent 重新获取即可,无需重新安装客户端。",
"Agent 必须在当前回复正文中原样显示以上链接,不得只说“已生成”或“已推送”。",
"登录完成后重新调用原工具。",
].join("\n");
return {
authenticated: false,
authChallenge: {
provider: "dingtalk", type: "link", directUrl,
userCode: challenge.user_code, expiresAt,
pollAfterSeconds: challenge.interval,
},
// Some stdio MCP hosts render only content.text and discard
// structuredContent. Keep the structured challenge for capable agents,
// but always put the short-lived URL in the portable text fallback.
message,
};
}
private authResult(challenge: NonNullable<BridgeState["device"]>) {
return bridgeAuthToolResult(this.presentChallenge(challenge));
}
private takeOnboarding() {
const diskState = this.loadState();
if (diskState.onboarding?.status === "delivered") {
this.state.onboarding = diskState.onboarding;
return undefined;
}
if (diskState.onboarding?.status === "pending") this.state.onboarding = diskState.onboarding;
if (this.state.onboarding?.status !== "pending") return undefined;
const onboarding = {
firstRun: true as const,
examples: [...APPROVAL_ONBOARDING_EXAMPLES],
writeSafety: "审批写操作只会在用户明确指定动作和目标后执行。",
};
this.state.onboarding = { schemaVersion: 1, status: "delivered", deliveredAt: new Date().toISOString() };
this.saveState();
return onboarding;
}
private appendOnboarding(result: any): any {
if (result?.isError === true) return result;
const onboarding = this.takeOnboarding();
if (!onboarding) return result;
const record = result && typeof result === "object" && !Array.isArray(result) ? result : {};
const content = Array.isArray(record.content) ? [...record.content] : [];
content.push({ type: "text", text: onboardingMessage() });
const structured = record.structuredContent && typeof record.structuredContent === "object" && !Array.isArray(record.structuredContent)
? record.structuredContent : {};
return { ...record, content, structuredContent: { ...structured, onboarding } };
}
private loadState(): BridgeState {
if (!existsSync(this.statePath)) return {};
try { return JSON.parse(readFileSync(this.statePath, "utf8")) as BridgeState; } catch { return {}; }
}
private saveState(): void {
mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
chmodSync(dirname(this.statePath), 0o700);
const temporary = `${this.statePath}.tmp-${process.pid}`;
writeFileSync(temporary, JSON.stringify(this.state), { mode: 0o600 });
chmodSync(temporary, 0o600);
renameSync(temporary, this.statePath);
}
}
type BridgeLoginResult = Awaited<ReturnType<RemoteBridge["loginChallenge"]>>;
function onboardingMessage(): string {
return [
"首次登录已完成。你可以直接这样使用钉钉审批:",
...APPROVAL_ONBOARDING_EXAMPLES.map((example) => `- “${example}”`),
"审批写操作只会在你明确指定动作和目标后执行。",
].join("\n");
}
export function bridgeAuthToolResult(result: BridgeLoginResult) {
const content: Array<
{ type: "text"; text: string }
| { type: "resource_link"; name: string; title: string; uri: string; description: string }
> = [{ type: "text", text: result.message }];
if ("authChallenge" in result) {
content.push({
type: "resource_link",
name: "dingtalk-approval-login",
title: "打开钉钉授权登录",
uri: result.authChallenge.directUrl,
description: "当前用户的短期钉钉授权登录链接",
});
}
return { content, structuredContent: { result } };
}
function isAuthenticationFailure(error: unknown): boolean {
const record = error && typeof error === "object" ? error as Record<string, unknown> : {};
const status = Number(record.status ?? record.statusCode ?? record.code);
if (status === 401 || status === 403) return true;
const message = error instanceof Error ? error.message : String(error);
return /^(?:MCP error[^:]*:\s*)?(?:Unauthorized|Forbidden|Invalid[_ -]?token)\b/i.test(message)
|| /\bHTTP\s+(?:401|403)\b/i.test(message);
}
function isExplicitOAuthInvalidation(status: number, value: Record<string, unknown>): boolean {
const code = typeof value.error === "string" ? value.error : "";
return status >= 400 && status < 500
&& ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code);
}
function normalizeEndpoint(value: string): URL {
const url = new URL(value);
if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址");
return url;
}
function parseEndpoint(argv: string[]): string {
const index = argv.indexOf("--url");
return (index >= 0 ? argv[index + 1] : undefined) ?? process.env.DINGTALK_APPROVAL_MCP_URL ?? "";
}
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async () => {
const result = await bridge.loginChallenge();
return bridgeAuthToolResult(result);
});
server.registerTool("approval_list_todos", {
title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办。", inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(200).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_todos", args));
server.registerTool("approval_list_history", {
title: "列出我的审批历史", description: "实时列出当前用户处理过或本人发起的钉钉OA和薪福通审批;默认最近30天,可指定最长120天的时间窗口;结果仅用于读取。", inputSchema: {
scope: z.enum(["handled", "submitted"]),
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(200).default(50),
startDate: z.string().max(40).default("").describe("可选;YYYY-MM-DD(上海时区)或带时区 ISO 8601;默认最近30天"),
endDate: z.string().max(40).default("").describe("可选且包含结束日;单次跨度最多120天"),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_history", args));
server.registerTool("approval_get_detail", {
title: "获取审批详情", description: "读取待办或历史列表返回的审批引用对应的实时详情。", inputSchema: {
approvalId: z.string().min(20).max(4096), view: z.enum(["summary", "expense", "raw"]).default("summary"),
rawPath: z.string().max(1024).default(""), rawCursor: z.number().int().min(0).default(0),
rawLimit: z.number().int().min(1).max(50).default(20),
rawQuery: z.string().max(200).default("").describe("raw 视图可选的有界字段搜索"),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_detail", args));
server.registerTool("approval_get_actions", {
title: "获取审批可执行动作", description: "实时检查同意、拒绝和退回能力。", inputSchema: { approvalId: z.string().min(20).max(4096) },
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_actions", args));
server.registerTool("approval_process_batch", {
title: "处理审批", description: "仅在用户明确授权后处理本人待办;在授权范围内按Schema容量内部调用,不向用户解释引用、容量或分批方式。写前校验整批引用,任一无效则零写入,不自动重试。", inputSchema: {
action: z.enum(["approve", "reject", "return"]), remark: z.string().max(1000).default(""),
selectionId: z.string().min(20).max(4096).optional(),
items: z.array(z.object({ approvalId: z.string().min(20).max(4096), targetId: z.string().min(20).max(4096).optional() }))
.max(APPROVAL_BATCH_LIMIT, `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条审批`).default([]),
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_process_batch", args));
server.registerTool("approval_list_submission_templates", {
title: "列出发起审批候选模板", description: "实时列出候选模板;是否可由 API 构造需继续读取 submissionCapability。", inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(100).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_submission_templates", args));
server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、复杂字段格式和 API 构造能力。", inputSchema: {
templateId: z.string().min(20).max(4096),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_schema", args));
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", inputSchema: {
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]),
query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_options", args));
const submissionInputSchema = {
templateId: z.string().min(20).max(4096),
values: z.record(z.string().min(1).max(200), z.unknown()),
deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-1,由网关按当前登录用户实时解析并验证"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
})).max(20).default([]),
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_prepare_submission", args));
server.registerTool("approval_submit", {
title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: {
confirmationId: z.string().min(20).max(4096), ...submissionInputSchema,
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_submit", args));
server.registerTool("approval_revoke_submission", {
title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: {
revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""),
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_revoke_submission", args));
server.registerTool("approval_get_submission_status", {
title: "核验本人已发起审批状态", description: "只读核验本人发起的薪福通单据状态;仍可撤回时返回新的短期revokeId。", inputSchema: {
source: z.literal("xft"), businessId: z.string().regex(/^\d{10,20}$/),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_status", args));
return server;
}
export async function startBridge(endpoint: string, statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE
?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json")): Promise<void> {
const bridge = new RemoteBridge(endpoint, resolve(statePath));
const server = createBridgeMcpServer(bridge);
await server.connect(new StdioServerTransport());
}
async function runLogin(endpoint: string, statePath: string): Promise<void> {
syncBundledSkillForLegacyUpdate(statePath);
const bridge = new RemoteBridge(endpoint, resolve(statePath));
bridge.ensureOnboardingPending();
process.stdout.write(`${JSON.stringify(await bridge.loginChallenge())}\n`);
}
export function syncBundledSkillForLegacyUpdate(
statePath: string,
bundled = resolve(dirname(fileURLToPath(import.meta.url)), "../../skill/dingtalk-approval"),
): void {
const resolvedState = resolve(statePath);
if (basename(resolvedState) !== "session.json" || basename(dirname(resolvedState)) !== "state") return;
if (!existsSync(join(bundled, "SKILL.md")) || !existsSync(join(bundled, "references", "create-approval.md"))) return;
const target = resolve(dirname(resolvedState), "../skill/dingtalk-approval");
const parent = dirname(target);
mkdirSync(parent, { recursive: true, mode: 0o700 });
const staging = join(parent, `.dingtalk-approval.staging-${process.pid}`);
const backup = join(parent, `.dingtalk-approval.backup-${process.pid}`);
rmSync(staging, { recursive: true, force: true });
rmSync(backup, { recursive: true, force: true });
try {
cpSync(bundled, staging, { recursive: true });
if (existsSync(target)) renameSync(target, backup);
renameSync(staging, target);
rmSync(backup, { recursive: true, force: true });
} catch (error) {
if (!existsSync(target) && existsSync(backup)) renameSync(backup, target);
rmSync(staging, { recursive: true, force: true });
throw error;
}
}
if (isDirectEntrypoint(import.meta.url, process.argv[1])) {
const args = process.argv.slice(2);
const endpoint = parseEndpoint(args);
const statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE
?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json");
const task = args.includes("--login") || args.includes("--post-install-login")
? runLogin(endpoint, statePath)
: startBridge(endpoint, statePath);
task.catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
}