fix: prefer native domestic installer downloads
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 17:12:20 +00:00
parent a08a2c1a96
commit 3b7d6a5a24
10 changed files with 270 additions and 40 deletions
+117 -10
View File
@@ -103,6 +103,7 @@ interface CommandContext {
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+))?$/;
@@ -301,7 +302,7 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
const publicKey = readFileSync(publicKeyPath);
reportInstallProgress(context, "2/6", "下载并验证签名发布清单");
const { manifest } = await downloadSignedManifest(context.fetchFn, manifestUrls, publicKey, (failed, next) => {
const { manifest } = await downloadSignedManifest(context, manifestUrls, publicKey, (failed, next) => {
reportInstallProgress(context, "2/6", `下载源不可用(${failed.origin}),切换至 ${next.origin}`);
});
const persistedManifestUrls = uniqueUrls([
@@ -317,13 +318,13 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context
reportInstallProgress(context, "3/6", `下载并校验 ${manifest.version} 客户端发布物`);
const clientArchive = join(temporary, "client.tgz");
const [, skillBody, publicArtifact] = await Promise.all([
downloadArtifact(context.fetchFn, manifest.artifacts.client, clientArchive, (failed, next) => {
downloadArtifact(context, manifest.artifacts.client, clientArchive, (failed, next) => {
reportInstallProgress(context, "3/6", `客户端包下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
downloadArtifact(context.fetchFn, manifest.artifacts.skill, join(temporary, "SKILL.md"), (failed, next) => {
downloadArtifact(context, manifest.artifacts.skill, join(temporary, "SKILL.md"), (failed, next) => {
reportInstallProgress(context, "3/6", `Skill 下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
downloadArtifact(context.fetchFn, manifest.artifacts.publicKey, join(temporary, "release-public.pem"), (failed, next) => {
downloadArtifact(context, manifest.artifacts.publicKey, join(temporary, "release-public.pem"), (failed, next) => {
reportInstallProgress(context, "3/6", `发布公钥下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
]);
@@ -887,7 +888,113 @@ class DownloadFailure extends Error {
}
}
async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
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();
@@ -927,7 +1034,7 @@ async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
}
async function downloadSignedManifest(
fetchFn: typeof fetch,
context: DownloadContext,
urls: URL[],
publicKey: Buffer,
onFallback?: (failed: URL, next: URL) => void,
@@ -936,8 +1043,8 @@ async function downloadSignedManifest(
for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]!;
try {
const manifestBytes = await download(fetchFn, url);
const signatureBytes = await download(fetchFn, new URL("manifest.sig", url));
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;
@@ -950,7 +1057,7 @@ async function downloadSignedManifest(
}
async function downloadArtifact(
fetchFn: typeof fetch,
context: DownloadContext,
artifact: ReleaseArtifact,
target: string,
onFallback?: (failed: URL, next: URL) => void,
@@ -960,7 +1067,7 @@ async function downloadArtifact(
for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]!;
try {
const body = await download(fetchFn, url);
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;