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
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:
+2
-2
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user