diff --git a/scripts/install.js b/scripts/install.js index 70763ee87..334804170 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -10,15 +10,16 @@ const crypto = require("crypto"); const VERSION = require("../package.json").version.replace(/-.*$/, ""); const REPO = "larksuite/cli"; const NAME = "lark-cli"; +const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com"; // Allowlist gates the *initial* request URL only. curl --location follows // redirects (capped by --max-redirs 3) without re-checking the target host. // This is acceptable because checksum verification is the primary integrity // control; the allowlist is defense-in-depth to reject obviously wrong URLs. -const ALLOWED_HOSTS = [ +const ALLOWED_HOSTS = new Set([ "github.com", "objects.githubusercontent.com", "registry.npmmirror.com", -]; +]); const PLATFORM_MAP = { darwin: "darwin", @@ -38,18 +39,77 @@ const isWindows = process.platform === "win32"; const ext = isWindows ? ".zip" : ".tar.gz"; const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`; const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`; -const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`; const binDir = path.join(__dirname, "..", "bin"); const dest = path.join(binDir, NAME + (isWindows ? ".exe" : "")); +// Build the ordered list of binary mirror URLs to try. Resolution rules: +// 1. npm_config_registry — when the user has set a non-default +// registry (npmmirror clone, corp Verdaccio, +// Artifactory, …), include the derived path +// first. Many of these proxies don't actually +// host /-/binary//..., so we ALWAYS +// append the public npmmirror as a final +// fallback so the install does not regress +// from the previous behavior of "GitHub → +// npmmirror". +// 2. registry.npmmirror.com — public China mirror, always tried last. +// The default public npmjs registry is skipped in step 1 because it does not +// host binaries under /-/binary/... +// +// Non-https / malformed npm_config_registry is silently ignored so npm users +// with http-only internal registries don't have their installs broken. +function resolveMirrorUrls(env, archive, version) { + const binaryPath = `/-/binary/lark-cli/v${version}/${archive}`; + const defaultUrl = joinUrl(DEFAULT_MIRROR_HOST, binaryPath); + + const urls = []; + const registry = (env.npm_config_registry || "").trim(); + if (registry && !isDefaultNpmjsRegistry(registry) && isValidDownloadBase(registry)) { + const base = new URL(registry); + urls.push(joinUrl(base.origin + base.pathname, binaryPath)); + } + if (!urls.includes(defaultUrl)) urls.push(defaultUrl); + return urls; +} + +function joinUrl(base, suffix) { + return base.replace(/\/+$/, "") + suffix; +} + +function isValidDownloadBase(raw) { + try { + const parsed = new URL(raw); + return parsed.protocol === "https:" && !!parsed.hostname; + } catch (_) { + return false; + } +} + +function isDefaultNpmjsRegistry(url) { + try { + const { hostname } = new URL(url); + return hostname === "registry.npmjs.org"; + } catch (_) { + return false; + } +} + function assertAllowedHost(url) { const { hostname } = new URL(url); - if (!ALLOWED_HOSTS.includes(hostname)) { + if (!ALLOWED_HOSTS.has(hostname)) { throw new Error(`Download host not allowed: ${hostname}`); } } +// Resolve the mirror URL chain and admit each host. Called from install() so +// derived hosts only become trusted when actually needed. +function getMirrorUrls(env) { + const urls = resolveMirrorUrls(env, archiveName, VERSION); + for (const u of urls) ALLOWED_HOSTS.add(new URL(u).hostname); + return urls; +} + function download(url, destPath) { assertAllowedHost(url); const args = [ @@ -66,17 +126,31 @@ function download(url, destPath) { } function install() { + const mirrorUrls = getMirrorUrls(process.env); + const downloadUrls = [GITHUB_URL, ...mirrorUrls]; + fs.mkdirSync(binDir, { recursive: true }); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-")); const archivePath = path.join(tmpDir, archiveName); try { - try { - download(GITHUB_URL, archivePath); - } catch (err) { - download(MIRROR_URL, archivePath); + // Walk the chain in order; stop at the first success. Default chain: + // GitHub → derived(npm_config_registry)? → npmmirror. The npmmirror + // tail preserves the pre-PR safety net when a corporate proxy doesn't + // actually host /-/binary//... + let lastErr; + let downloaded = false; + for (const url of downloadUrls) { + try { + download(url, archivePath); + downloaded = true; + break; + } catch (e) { + lastErr = e; + } } + if (!downloaded) throw lastErr; const expectedHash = getExpectedChecksum(archiveName); verifyChecksum(archivePath, expectedHash); @@ -176,12 +250,15 @@ if (require.main === module) { } catch (err) { console.error(`Failed to install ${NAME}:`, err.message); console.error( - `\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` + + `\nIf you are behind a firewall or in a restricted network, try one of:\n` + + ` # 1. Use a proxy:\n` + ` export https_proxy=http://your-proxy:port\n` + - ` npm install -g @larksuite/cli` + ` npm install -g @larksuite/cli\n\n` + + ` # 2. Point to a corporate npm mirror that proxies /-/binary/lark-cli/...:\n` + + ` npm install -g @larksuite/cli --registry=https://your-corp-mirror/` ); process.exit(1); } } -module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost }; +module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls }; diff --git a/scripts/install.test.js b/scripts/install.test.js index a0aea9ba2..664cd2337 100644 --- a/scripts/install.test.js +++ b/scripts/install.test.js @@ -9,7 +9,7 @@ const os = require("os"); const crypto = require("crypto"); -const { getExpectedChecksum, verifyChecksum, assertAllowedHost } = require("./install.js"); +const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js"); describe("getExpectedChecksum", () => { function makeTmpChecksums(content) { @@ -164,3 +164,117 @@ describe("assertAllowedHost", () => { ); }); }); + +describe("resolveMirrorUrls", () => { + const ARCHIVE = "lark-cli-1.0.0-linux-amd64.tar.gz"; + const VERSION = "1.0.0"; + const DEFAULT = "https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz"; + + it("returns only the default mirror when no env vars are set", () => { + assert.deepEqual(resolveMirrorUrls({}, ARCHIVE, VERSION), [DEFAULT]); + }); + + it("does not derive from the default npmjs registry", () => { + // The public npmjs registry doesn't host /-/binary//..., so we must + // not point downloads at it. + assert.deepEqual( + resolveMirrorUrls( + { npm_config_registry: "https://registry.npmjs.org/" }, + ARCHIVE, + VERSION + ), + [DEFAULT] + ); + }); + + it("derives from non-default npm_config_registry AND keeps default as fallback", () => { + // Critical: a corporate npm proxy (Verdaccio/Artifactory/Nexus) often + // doesn't actually serve /-/binary//..., so we must keep the + // public npmmirror as a final fallback or installs regress vs. the + // pre-PR "GitHub → npmmirror" behavior. + assert.deepEqual( + resolveMirrorUrls( + { npm_config_registry: "https://corp.example.com/repository/npm-public/" }, + ARCHIVE, + VERSION + ), + [ + "https://corp.example.com/repository/npm-public/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz", + DEFAULT, + ] + ); + }); + + it("derived URL appears before the default in the chain", () => { + const urls = resolveMirrorUrls( + { npm_config_registry: "https://corp.example.com/" }, + ARCHIVE, + VERSION + ); + assert.equal(urls.length, 2); + assert.match(urls[0], /^https:\/\/corp\.example\.com\//); + assert.equal(urls[1], DEFAULT); + }); + + it("does not duplicate the default if the registry already points at it", () => { + // If npm_config_registry happens to be the public npmmirror, we still + // want a single entry, not two identical ones. + assert.deepEqual( + resolveMirrorUrls( + { npm_config_registry: "https://registry.npmmirror.com/" }, + ARCHIVE, + VERSION + ), + [DEFAULT] + ); + }); + + it("strips trailing slashes from the registry URL", () => { + assert.deepEqual( + resolveMirrorUrls( + { npm_config_registry: "https://corp.example.com///" }, + ARCHIVE, + VERSION + ), + [ + "https://corp.example.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz", + DEFAULT, + ] + ); + }); + + it("ignores empty/whitespace npm_config_registry", () => { + assert.deepEqual( + resolveMirrorUrls( + { npm_config_registry: "" }, + ARCHIVE, + VERSION + ), + [DEFAULT] + ); + }); + + it("silently falls back when npm_config_registry is non-https", () => { + // Implicit feature: don't break installs whose npm registry is plain http. + // The user didn't opt into binary-mirror behavior, so just use the default. + assert.deepEqual( + resolveMirrorUrls( + { npm_config_registry: "http://internal.example.com/" }, + ARCHIVE, + VERSION + ), + [DEFAULT] + ); + }); + + it("silently falls back when npm_config_registry is file://", () => { + assert.deepEqual( + resolveMirrorUrls( + { npm_config_registry: "file:///tmp" }, + ARCHIVE, + VERSION + ), + [DEFAULT] + ); + }); +});