fix: make client installation observable
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 14:16:17 +00:00
parent dd189c2acb
commit 40c5021c85
12 changed files with 233 additions and 21 deletions
+120 -16
View File
@@ -2,7 +2,7 @@
import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import {
chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
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";
@@ -106,6 +106,9 @@ 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,
@@ -177,8 +180,8 @@ export async function runClientInstaller(
platform: overrides.platform ?? process.platform,
home: overrides.home ?? homedir(),
fetchFn: overrides.fetchFn ?? fetch,
stdout: overrides.stdout ?? ((value) => { process.stdout.write(value); }),
stderr: overrides.stderr ?? ((value) => { process.stderr.write(value); }),
stdout: overrides.stdout ?? ((value) => { writeSync(1, value); }),
stderr: overrides.stderr ?? ((value) => { writeSync(2, value); }),
};
try {
const args = parseClientArgs(context.argv);
@@ -197,8 +200,10 @@ export async function runClientInstaller(
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (context.argv.includes("--json") || context.argv.includes("mcp-json")) context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`);
else context.stderr(`${message}\n`);
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;
}
}
@@ -266,6 +271,18 @@ function parseClientArgs(argv: string[]): ParsedArgs {
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);
@@ -273,6 +290,7 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
const publicKey = readFileSync(publicKeyPath);
reportInstallProgress(context, "2/6", "下载并验证签名发布清单");
const [manifestBytes, signatureBytes] = await Promise.all([
download(context.fetchFn, manifestUrl),
download(context.fetchFn, new URL("manifest.sig", manifestUrl)),
@@ -283,38 +301,45 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
}
const temporary = mkdtempSync(join(tmpdir(), "dingtalk-approval-install-"));
try {
reportInstallProgress(context, "3/6", `下载并校验 ${manifest.version} 客户端发布物`);
const clientArchive = join(temporary, "client.tgz");
await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive);
const skillBody = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md"));
const publicArtifact = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem"));
const [, skillBody, publicArtifact] = await Promise.all([
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive),
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md")),
downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem")),
]);
if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) {
throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装");
}
mkdirPrivate(paths.root);
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],
join(temporary, "npm-cache"),
{ cache: join(temporary, "npm-cache"), timeoutMs: NPM_INSTALL_TIMEOUT_MS },
);
if (installed.status !== 0) {
if (installed.error || installed.status !== 0) {
rmSync(staging, { recursive: true, force: true });
throw new Error(`客户端运行时安装失败:${(installed.stderr || installed.stdout || "npm 执行失败").trim()}`);
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);
@@ -337,7 +362,9 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
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 });
@@ -415,26 +442,103 @@ function doctor(paths: ClientPaths, context: CommandContext): Record<string, unk
}
function checkNpm(context: Pick<CommandContext, "env" | "platform">): { ok: boolean; value?: string; message?: string } {
const result = runNpm(context, ["--version"]);
if (result.error) return { ok: false, message: result.error.message };
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 未返回版本号" };
}
function runNpm(context: Pick<CommandContext, "env" | "platform">, args: string[], cache?: string) {
export function runNpm(
context: Pick<CommandContext, "env" | "platform">,
args: string[],
options: { cache?: string; timeoutMs: number },
) {
const invocation = npmInvocation(context, args);
return spawnSync(invocation.command, invocation.args, {
encoding: "utf8",
timeout: options.timeoutMs,
killSignal: "SIGTERM",
maxBuffer: 2 * 1024 * 1024,
env: {
...context.env,
...(cache ? { npm_config_cache: cache } : {}),
...(options.cache ? { npm_config_cache: options.cache } : {}),
npm_config_update_notifier: "false",
},
windowsHide: true,
});
}
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 = [