feat: bootstrap portable Node runtimes
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-08-18 13:53:54 +00:00
parent 2e275b684d
commit dd189c2acb
22 changed files with 900 additions and 96 deletions
+2 -2
View File
@@ -117,7 +117,7 @@ export class RemoteBridge {
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 client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.9" });
const transport = new StreamableHTTPClientTransport(this.endpoint, {
requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } },
});
@@ -388,7 +388,7 @@ function parseEndpoint(argv: string[]): string {
}
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }, {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.9" }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。",
});
server.registerTool("approval_login", {
+97 -21
View File
@@ -20,12 +20,17 @@ export interface ClientReleaseManifest {
createdAt: string;
gatewayUrl: string;
signing: { algorithm: "Ed25519"; keyId: string };
requirements: { node: string };
requirements: {
node: string;
managedNode?: { version: string; platforms: string[] };
};
artifacts: {
installer: ReleaseArtifact;
publicKey: ReleaseArtifact & { keyId: string };
client: ReleaseArtifact;
skill: ReleaseArtifact;
bootstrapWindows?: ReleaseArtifact;
bootstrapPosix?: ReleaseArtifact;
};
}
@@ -39,6 +44,9 @@ export interface ClientInstallState {
skillSha256: string;
runtimeDir: string;
skillFile: string;
nodeExecutable?: string;
managedNode?: boolean;
nodeVersion?: string;
}
export interface ClientPaths {
@@ -292,7 +300,11 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
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]);
const installed = runNpm(
context,
["install", "--prefix", staging, "--offline", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", clientArchive],
join(temporary, "npm-cache"),
);
if (installed.status !== 0) {
rmSync(staging, { recursive: true, force: true });
throw new Error(`客户端运行时安装失败:${(installed.stderr || installed.stdout || "npm 执行失败").trim()}`);
@@ -305,7 +317,7 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
}
installBundledSkill(paths, join(versionDir, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval"), skillBody);
writeAtomic(paths.publicKeyFile, publicArtifact, 0o600);
writeLaunchers(paths, context.platform);
const nodeRuntime = activeNodeRuntime(paths);
const state: ClientInstallState = {
schemaVersion: 1,
version: manifest.version,
@@ -316,7 +328,11 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
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);
@@ -338,7 +354,7 @@ function uninstallClient(paths: ClientPaths, args: ParsedArgs, context: CommandC
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);
if (runningInsideRoot) scheduleSelfCleanup(paths.root, context.platform);
else rmSync(paths.root, { recursive: true, force: true });
}
return {
@@ -381,6 +397,14 @@ function doctor(paths: ClientPaths, context: CommandContext): Record<string, unk
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 }); }
@@ -398,25 +422,31 @@ function checkNpm(context: Pick<CommandContext, "env" | "platform">): { ok: bool
return version ? { ok: true, value: version } : { ok: false, message: "npm --version 未返回版本号" };
}
function runNpm(context: Pick<CommandContext, "env" | "platform">, args: string[]) {
function runNpm(context: Pick<CommandContext, "env" | "platform">, args: string[], cache?: string) {
const invocation = npmInvocation(context, args);
return spawnSync(invocation.command, invocation.args, {
encoding: "utf8",
env: { ...context.env, npm_config_update_notifier: "false" },
env: {
...context.env,
...(cache ? { npm_config_cache: cache } : {}),
npm_config_update_notifier: "false",
},
windowsHide: true,
});
}
function npmInvocation(context: Pick<CommandContext, "env" | "platform">, args: string[]): { command: string; args: string[] } {
if (context.platform !== "win32") return { command: "npm", args };
const pathSeparator = context.platform === "win32" ? ";" : ":";
const candidates = [
context.env.npm_execpath,
...(context.env.PATH ?? "").split(";").filter(Boolean).map((entry) => join(entry.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npm-cli.js")),
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
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) throw new Error("Windows 环境未找到可由 Node 安全启动的 npm-cli.js;请修复 Node/npm 安装后重试");
return { command: process.execPath, args: [npmCli, ...args] };
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(
@@ -503,6 +533,11 @@ function installResult(
legacyCleanupFailed,
configurationModified: false,
postInstall: { ...postInstall, loginCommand: loginCommand(paths) },
nodeRuntime: {
version: state.nodeVersion ?? process.versions.node,
managed: state.managedNode === true,
executable: configuredNodeExecutable(paths, state),
},
nextSteps,
};
}
@@ -634,17 +669,32 @@ function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillB
}
}
function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform): void {
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 child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`;
writeAtomic(paths.launcherFile, Buffer.from(launcher), 0o700);
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") {
writeAtomic(paths.cliCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" cli %*\r\n`), 0o700);
writeAtomic(paths.bridgeCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" bridge %*\r\n`), 0o700);
} else {
writeAtomic(paths.cliCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" cli "$@"\n`), 0o700);
writeAtomic(paths.bridgeCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" bridge "$@"\n`), 0o700);
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 {
@@ -820,11 +870,37 @@ function assertSafeClientRoot(root: string, home: string): void {
}
}
function scheduleSelfCleanup(root: string): void {
const cleanupRoot = mkdtempSync(join(tmpdir(), "dingtalk-approval-cleanup-"));
const helper = join(cleanupRoot, "cleanup.mjs");
writeFileSync(helper, `import { rmSync } from "node:fs";\nsetTimeout(() => { rmSync(${JSON.stringify(root)}, { recursive: true, force: true }); rmSync(${JSON.stringify(cleanupRoot)}, { recursive: true, force: true }); }, 750);\n`, { mode: 0o700 });
const child = spawn(process.execPath, [helper], { detached: true, stdio: "ignore" });
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();
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function createApprovalMcpServer(
invoiceDedup?: InvoiceDeduplicationService,
): McpServer {
const server = new McpServer(
{ name: "dingtalk-approval-mcp", version: "1.1.0-rc.8" },
{ name: "dingtalk-approval-mcp", version: "1.1.0-rc.9" },
{ instructions: APPROVAL_MCP_INSTRUCTIONS },
);
const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup);