fix: harden proxied client installation
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:
+172
-30
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
export interface ReleaseArtifact {
|
||||
url: string;
|
||||
mirrors?: string[];
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export interface ClientReleaseManifest {
|
||||
createdAt: string;
|
||||
gatewayUrl: string;
|
||||
signing: { algorithm: "Ed25519"; keyId: string };
|
||||
distribution?: { manifestMirrors?: string[] };
|
||||
requirements: {
|
||||
node: string;
|
||||
managedNode?: { version: string; platforms: string[] };
|
||||
@@ -39,6 +41,7 @@ export interface ClientInstallState {
|
||||
version: string;
|
||||
installedAt: string;
|
||||
manifestUrl: string;
|
||||
manifestMirrors?: string[];
|
||||
gatewayUrl: string;
|
||||
clientSha256: string;
|
||||
skillSha256: string;
|
||||
@@ -211,6 +214,7 @@ export async function runClientInstaller(
|
||||
interface ParsedArgs {
|
||||
command: string;
|
||||
manifestUrl?: string;
|
||||
manifestMirrorUrls: string[];
|
||||
publicKey?: string;
|
||||
json: boolean;
|
||||
yes: boolean;
|
||||
@@ -225,17 +229,22 @@ function parseClientArgs(argv: string[]): ParsedArgs {
|
||||
if (values[0] === "--print-path") values.splice(0, 1, "print-path");
|
||||
const command = values.shift() ?? "";
|
||||
const strings = new Map<string, string>();
|
||||
const stringLists = new Map<string, string[]>();
|
||||
const flags = new Set<string>();
|
||||
let pathKind: string | undefined;
|
||||
const stringOptions = new Set(["--manifest-url", "--public-key", "--app-root", "--format"]);
|
||||
const stringOptions = new Set(["--manifest-url", "--manifest-mirror-url", "--public-key", "--app-root", "--format"]);
|
||||
const flagOptions = new Set(["--json", "--yes", "--allow-downgrade"]);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index]!;
|
||||
if (stringOptions.has(value)) {
|
||||
const next = values[index + 1];
|
||||
if (!next || next.startsWith("--")) throw new Error(`${value} 缺少参数`);
|
||||
if (strings.has(value)) throw new Error(`${value} 不能重复`);
|
||||
strings.set(value, next);
|
||||
if (value === "--manifest-mirror-url") {
|
||||
stringLists.set(value, [...(stringLists.get(value) ?? []), next]);
|
||||
} else {
|
||||
if (strings.has(value)) throw new Error(`${value} 不能重复`);
|
||||
strings.set(value, next);
|
||||
}
|
||||
index += 1;
|
||||
} else if (flagOptions.has(value)) {
|
||||
if (flags.has(value)) throw new Error(`${value} 不能重复`);
|
||||
@@ -245,20 +254,22 @@ function parseClientArgs(argv: string[]): ParsedArgs {
|
||||
else throw new Error(`多余参数:${value}`);
|
||||
}
|
||||
const allowed = new Set(command === "install" || command === "update"
|
||||
? ["--manifest-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
|
||||
? ["--manifest-url", "--manifest-mirror-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
|
||||
: command === "login" ? ["--app-root", "--json"]
|
||||
: command === "uninstall" ? ["--app-root", "--json", "--yes"]
|
||||
: command === "print-path" ? ["--app-root", "--json"]
|
||||
: command === "print-config" ? ["--app-root", "--json", "--format"]
|
||||
: command === "doctor" ? ["--app-root", "--json"] : []);
|
||||
for (const name of [...strings.keys(), ...flags]) {
|
||||
for (const name of [...strings.keys(), ...stringLists.keys(), ...flags]) {
|
||||
if (!allowed.has(name)) throw new Error(`${command || "该命令"} 不支持参数 ${name}`);
|
||||
}
|
||||
const format = strings.get("--format");
|
||||
if (format && format !== "mcp-json") throw new Error("--format 只支持 mcp-json");
|
||||
if ((stringLists.get("--manifest-mirror-url")?.length ?? 0) > 4) throw new Error("--manifest-mirror-url 最多允许 4 个");
|
||||
return {
|
||||
command,
|
||||
manifestUrl: strings.get("--manifest-url"),
|
||||
manifestMirrorUrls: stringLists.get("--manifest-mirror-url") ?? [],
|
||||
publicKey: strings.get("--public-key"),
|
||||
json: flags.has("--json"),
|
||||
yes: flags.has("--yes"),
|
||||
@@ -286,16 +297,18 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context
|
||||
const npmCheck = checkNpm(context);
|
||||
if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`);
|
||||
const previous = readState(paths.stateFile);
|
||||
const manifestUrl = normalizedHttpsUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL");
|
||||
if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
|
||||
const manifestUrls = configuredManifestUrls(args, previous);
|
||||
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)),
|
||||
const { manifest } = await downloadSignedManifest(context.fetchFn, manifestUrls, publicKey, (failed, next) => {
|
||||
reportInstallProgress(context, "2/6", `下载源不可用(${failed.origin}),切换至 ${next.origin}`);
|
||||
});
|
||||
const persistedManifestUrls = uniqueUrls([
|
||||
...manifestUrls,
|
||||
...(manifest.distribution?.manifestMirrors ?? []).map((value) => normalizedManifestUrl(value, "manifest mirror URL")),
|
||||
]);
|
||||
const manifest = verifySignedManifest(manifestBytes, signatureBytes, publicKey, manifestUrl.toString());
|
||||
if (persistedManifestUrls.length > 5) throw new Error("签名清单与本地配置合计最多允许 4 个备用源");
|
||||
if (previous && compareVersions(manifest.version, previous.version) < 0 && !args.allowDowngrade) {
|
||||
throw new Error(`拒绝从 ${previous.version} 降级到 ${manifest.version};如确需降级请显式使用 --allow-downgrade`);
|
||||
}
|
||||
@@ -304,9 +317,15 @@ 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, 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")),
|
||||
downloadArtifact(context.fetchFn, 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) => {
|
||||
reportInstallProgress(context, "3/6", `Skill 下载源不可用(${failed.origin}),切换至 ${next.origin}`);
|
||||
}),
|
||||
downloadArtifact(context.fetchFn, manifest.artifacts.publicKey, join(temporary, "release-public.pem"), (failed, next) => {
|
||||
reportInstallProgress(context, "3/6", `发布公钥下载源不可用(${failed.origin}),切换至 ${next.origin}`);
|
||||
}),
|
||||
]);
|
||||
if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) {
|
||||
throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装");
|
||||
@@ -347,7 +366,8 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context
|
||||
schemaVersion: 1,
|
||||
version: manifest.version,
|
||||
installedAt: new Date().toISOString(),
|
||||
manifestUrl: manifestUrl.toString(),
|
||||
manifestUrl: persistedManifestUrls[0]!.toString(),
|
||||
...(persistedManifestUrls.length > 1 ? { manifestMirrors: persistedManifestUrls.slice(1).map(String) } : {}),
|
||||
gatewayUrl: manifest.gatewayUrl,
|
||||
clientSha256: manifest.artifacts.client.sha256,
|
||||
skillSha256: manifest.artifacts.skill.sha256,
|
||||
@@ -455,13 +475,14 @@ export function runNpm(
|
||||
options: { cache?: string; timeoutMs: number },
|
||||
) {
|
||||
const invocation = npmInvocation(context, args);
|
||||
const npmEnv = npmChildEnvironment(context.env);
|
||||
return spawnSync(invocation.command, invocation.args, {
|
||||
encoding: "utf8",
|
||||
timeout: options.timeoutMs,
|
||||
killSignal: "SIGTERM",
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
env: {
|
||||
...context.env,
|
||||
...npmEnv,
|
||||
...(options.cache ? { npm_config_cache: options.cache } : {}),
|
||||
npm_config_update_notifier: "false",
|
||||
},
|
||||
@@ -469,6 +490,26 @@ export function runNpm(
|
||||
});
|
||||
}
|
||||
|
||||
export function npmChildEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const child = { ...env };
|
||||
for (const key of Object.keys(child)) {
|
||||
const normalized = key.toLowerCase();
|
||||
if (normalized === "node_use_env_proxy") {
|
||||
delete child[key];
|
||||
continue;
|
||||
}
|
||||
if (normalized !== "node_options") continue;
|
||||
const value = child[key] ?? "";
|
||||
const cleaned = value
|
||||
.replace(/(^|[\t\r\n ])--use-env-proxy(?=$|[\t\r\n ])/g, "$1")
|
||||
.replace(/[\t ]{2,}/g, " ")
|
||||
.trim();
|
||||
if (cleaned) child[key] = cleaned;
|
||||
else delete child[key];
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
export function npmFailureMessage(
|
||||
result: { error?: Error; signal: NodeJS.Signals | null; status: number | null; stderr: string; stdout: string },
|
||||
timeoutMs: number,
|
||||
@@ -676,7 +717,7 @@ function attemptLogin(
|
||||
verifyTool: { name: "approval_list_todos" as const, arguments: { source: "all" as const, query: "" as const, limit: 1 as const } },
|
||||
completionCriteria: "登录确认并完成钉钉 OA 与薪福通双来源只读验证",
|
||||
};
|
||||
const result = spawnSync(process.execPath, [clientEntry, "--url", gatewayUrl, "--login"], {
|
||||
const result = spawnSync(process.execPath, [...nodeProxyArgs(context.env), clientEntry, "--url", gatewayUrl, "--login"], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
@@ -734,6 +775,20 @@ function attemptLogin(
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeProxyArgs(
|
||||
env: NodeJS.ProcessEnv,
|
||||
allowedFlags: ReadonlySet<string> = process.allowedNodeEnvironmentFlags,
|
||||
): string[] {
|
||||
const proxyConfigured = Object.entries(env).some(([key, value]) =>
|
||||
["https_proxy", "http_proxy", "all_proxy"].includes(key.toLowerCase()) && Boolean(value?.trim()));
|
||||
if (!proxyConfigured || !allowedFlags.has("--use-env-proxy")) return [];
|
||||
const nodeOptions = Object.entries(env).find(([key]) => key.toLowerCase() === "node_options")?.[1] ?? "";
|
||||
const enabledByNodeOptions = /(^|[\t\r\n ])--use-env-proxy(?=$|[\t\r\n ])/.test(nodeOptions);
|
||||
const enabledByDedicatedVariable = Object.entries(env).some(([key, value]) =>
|
||||
key.toLowerCase() === "node_use_env_proxy" && /^(?:1|true)$/i.test(value?.trim() ?? ""));
|
||||
return enabledByNodeOptions || enabledByDedicatedVariable ? [] : ["--use-env-proxy"];
|
||||
}
|
||||
|
||||
function safeOnboarding(value: unknown): PostInstallResult["onboarding"] | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
@@ -775,7 +830,7 @@ function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillB
|
||||
|
||||
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`;
|
||||
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 proxyConfigured = Object.entries(env).some(([key, value]) => ["https_proxy", "http_proxy", "all_proxy"].includes(key.toLowerCase()) && Boolean(value?.trim()));\nconst nodeOptions = Object.entries(env).find(([key]) => key.toLowerCase() === "node_options")?.[1] ?? "";\nconst proxyAlreadyEnabled = /(^|[\\t\\r\\n ])--use-env-proxy(?=$|[\\t\\r\\n ])/.test(nodeOptions) || Object.entries(env).some(([key, value]) => key.toLowerCase() === "node_use_env_proxy" && /^(?:1|true)$/i.test(value?.trim() ?? ""));\nconst proxyArgs = proxyConfigured && process.allowedNodeEnvironmentFlags.has("--use-env-proxy") && !proxyAlreadyEnabled ? ["--use-env-proxy"] : [];\nconst child = spawn(process.execPath, [...proxyArgs, 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);
|
||||
@@ -805,13 +860,19 @@ function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string,
|
||||
if (manifest.schemaVersion !== 3 || !VERSION_PATTERN.test(manifest.version)) throw new Error("发布清单版本或版本号无效");
|
||||
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing.keyId !== keyId) throw new Error("发布清单未绑定当前发布公钥");
|
||||
const base = normalizedHttpsUrl(manifestUrl, "manifest URL");
|
||||
if (!base.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
|
||||
const gateway = normalizedHttpsUrl(manifest.gatewayUrl, "gateway URL");
|
||||
if (gateway.pathname !== "/mcp" || gateway.search || gateway.hash) throw new Error("gateway URL 必须是 HTTPS /mcp 地址");
|
||||
for (const [name, artifact] of Object.entries(manifest.artifacts ?? {})) {
|
||||
if (!artifact || !/^[a-f0-9]{64}$/.test(artifact.sha256)) throw new Error(`发布物 ${name} 缺少有效 SHA-256`);
|
||||
const url = normalizedHttpsUrl(artifact.url, `${name} URL`);
|
||||
if (url.origin !== base.origin) throw new Error(`发布物 ${name} 必须与清单同源`);
|
||||
const urls = artifactUrls(artifact, `${name} URL`);
|
||||
if (urls.length !== new Set(urls.map(String)).size) throw new Error(`发布物 ${name} 包含重复下载地址`);
|
||||
}
|
||||
const manifestMirrors = manifest.distribution?.manifestMirrors ?? [];
|
||||
if (!Array.isArray(manifestMirrors)) throw new Error("manifest mirrors 必须是数组");
|
||||
if (manifestMirrors.length > 4) throw new Error("manifest mirrors 最多允许 4 个");
|
||||
for (const value of manifestMirrors) normalizedManifestUrl(value, "manifest mirror URL");
|
||||
if (manifestMirrors.length !== new Set(manifestMirrors).size) throw new Error("manifest mirrors 包含重复地址");
|
||||
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("发布公钥 artifact 的 keyId 不匹配");
|
||||
const createdAt = Date.parse(manifest.createdAt);
|
||||
if (!Number.isFinite(createdAt)) throw new Error("发布清单 createdAt 无效");
|
||||
@@ -819,6 +880,13 @@ function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string,
|
||||
if (createdAt < Date.now() - MANIFEST_MAX_AGE_MS) throw new Error("发布清单已超过 180 天有效期,请获取新的安装链接");
|
||||
}
|
||||
|
||||
class DownloadFailure extends Error {
|
||||
constructor(message: string, readonly fallbackAllowed: boolean, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "DownloadFailure";
|
||||
}
|
||||
}
|
||||
|
||||
async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(new Error("下载超时")), DOWNLOAD_TIMEOUT_MS);
|
||||
@@ -828,7 +896,7 @@ async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
|
||||
headers: { accept: "application/octet-stream, application/json;q=0.9, */*;q=0.8" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`下载失败 ${response.status}:${url}`);
|
||||
if (!response.ok) throw new DownloadFailure(`下载失败 ${response.status}:${url}`, response.status === 404);
|
||||
const length = Number(response.headers.get("content-length") ?? 0);
|
||||
if (Number.isFinite(length) && length > MAX_DOWNLOAD_BYTES) throw new Error(`下载文件超过 25MB 限制:${url}`);
|
||||
if (!response.body) return Buffer.alloc(0);
|
||||
@@ -847,20 +915,94 @@ async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new Error(`下载超时(30 秒):${url}`, { cause: error });
|
||||
if (controller.signal.aborted) throw new DownloadFailure(`下载超时(30 秒):${url}`, true, { cause: error });
|
||||
if (error instanceof DownloadFailure) throw error;
|
||||
if (error instanceof TypeError || isNetworkError(error)) {
|
||||
throw new DownloadFailure(`网络连接失败:${url}`, true, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArtifact(fetchFn: typeof fetch, manifestUrl: URL, artifact: ReleaseArtifact, target: string): Promise<Buffer> {
|
||||
const url = normalizedHttpsUrl(artifact.url, "artifact URL");
|
||||
if (url.origin !== manifestUrl.origin) throw new Error("发布物必须与清单同源");
|
||||
const body = await download(fetchFn, url);
|
||||
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
|
||||
writeAtomic(target, body, 0o600);
|
||||
return body;
|
||||
async function downloadSignedManifest(
|
||||
fetchFn: typeof fetch,
|
||||
urls: URL[],
|
||||
publicKey: Buffer,
|
||||
onFallback?: (failed: URL, next: URL) => void,
|
||||
): Promise<{ manifest: ClientReleaseManifest; source: URL }> {
|
||||
let lastError: unknown;
|
||||
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));
|
||||
return { manifest: verifySignedManifest(manifestBytes, signatureBytes, publicKey, url.toString()), source: url };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const next = urls[index + 1];
|
||||
if (!next || !(error instanceof DownloadFailure) || !error.fallbackAllowed) throw error;
|
||||
onFallback?.(url, next);
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("所有签名清单下载源均不可用");
|
||||
}
|
||||
|
||||
async function downloadArtifact(
|
||||
fetchFn: typeof fetch,
|
||||
artifact: ReleaseArtifact,
|
||||
target: string,
|
||||
onFallback?: (failed: URL, next: URL) => void,
|
||||
): Promise<Buffer> {
|
||||
const urls = artifactUrls(artifact, "artifact URL");
|
||||
let lastError: unknown;
|
||||
for (let index = 0; index < urls.length; index += 1) {
|
||||
const url = urls[index]!;
|
||||
try {
|
||||
const body = await download(fetchFn, url);
|
||||
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
|
||||
writeAtomic(target, body, 0o600);
|
||||
return body;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const next = urls[index + 1];
|
||||
if (!next || !(error instanceof DownloadFailure) || !error.fallbackAllowed) throw error;
|
||||
onFallback?.(url, next);
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("所有发布物下载源均不可用");
|
||||
}
|
||||
|
||||
function configuredManifestUrls(args: ParsedArgs, previous: ClientInstallState | undefined): URL[] {
|
||||
const primary = normalizedManifestUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL");
|
||||
const inherited = args.manifestUrl ? [] : (previous?.manifestMirrors ?? []);
|
||||
const urls = uniqueUrls([primary, ...inherited.map((value) => normalizedManifestUrl(value, "manifest mirror URL")),
|
||||
...args.manifestMirrorUrls.map((value) => normalizedManifestUrl(value, "manifest mirror URL"))]);
|
||||
if (urls.length > 5) throw new Error("manifest 下载地址最多允许 1 个主源和 4 个备用源");
|
||||
return urls;
|
||||
}
|
||||
|
||||
function normalizedManifestUrl(value: string, label: string): URL {
|
||||
const url = normalizedHttpsUrl(value, label);
|
||||
if (!url.pathname.endsWith("/manifest.json") || url.search || url.hash) throw new Error(`${label} 必须是 HTTPS manifest.json 地址`);
|
||||
return url;
|
||||
}
|
||||
|
||||
function artifactUrls(artifact: ReleaseArtifact, label: string): URL[] {
|
||||
if (artifact.mirrors !== undefined && !Array.isArray(artifact.mirrors)) throw new Error(`${label} mirrors 必须是数组`);
|
||||
if ((artifact.mirrors?.length ?? 0) > 4) throw new Error(`${label} mirrors 最多允许 4 个`);
|
||||
return [artifact.url, ...(artifact.mirrors ?? [])].map((value) => normalizedHttpsUrl(value, label));
|
||||
}
|
||||
|
||||
function uniqueUrls(values: URL[]): URL[] {
|
||||
return [...new Map(values.map((value) => [value.toString(), value])).values()];
|
||||
}
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||
?? ((error as { cause?: NodeJS.ErrnoException } | undefined)?.cause?.code);
|
||||
return typeof code === "string" && ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "ETIMEDOUT"].includes(code);
|
||||
}
|
||||
|
||||
function resolvePublicKeyPath(paths: ClientPaths, explicit: string | undefined): string {
|
||||
@@ -1018,7 +1160,7 @@ function writeResult(value: Record<string, unknown>, args: ParsedArgs, context:
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return "用法:dingtalk-approval install|update|login|uninstall|print-path|print-config|doctor [--manifest-url URL] [--format mcp-json] [--json]";
|
||||
return "用法:dingtalk-approval install|update|login|uninstall|print-path|print-config|doctor [--manifest-url URL] [--manifest-mirror-url URL] [--format mcp-json] [--json]";
|
||||
}
|
||||
|
||||
if (isClientDirectEntrypoint(import.meta.url, process.argv[1])) {
|
||||
|
||||
Reference in New Issue
Block a user