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