feat(install): enhance binary URL resolution with environment variabl… (#690)

* feat(install): enhance binary URL resolution with environment variable support

* fix(install): defer mirror resolution into install() to surface friendly errors

resolveMirrorUrl was called at module scope, so an invalid
LARK_CLI_DOWNLOAD_HOST (e.g. file://) threw before the try/catch in the
postinstall entrypoint, dumping a raw stack trace instead of the recovery
guidance with proxy/registry/host-override options.

Move resolution into install() via getMirrorUrl() so the throw is caught
and the user sees the actionable help text.

* fix(install): keep npmmirror fallback when npm_config_registry is set

resolveMirrorUrl returned a single URL, so any non-default
npm_config_registry replaced the npmmirror fallback entirely. Corporate
npm proxies (Verdaccio, Artifactory, Nexus) often only serve npm package
metadata and don't host /-/binary/<pkg>/..., turning previously-working
installs into 404s when GitHub is unreachable.

Switch to resolveMirrorUrls returning an ordered chain:
  - LARK_CLI_DOWNLOAD_HOST set → [override] only (explicit user choice;
    no silent leak to npmmirror).
  - Otherwise → [derived_from_registry?, npmmirror_default]; npmmirror
    is always the final entry, restoring the pre-PR safety net.

install() now walks [GITHUB_URL, ...mirrorUrls] and stops at the first
success.

* fix(install): skip GitHub when LARK_CLI_DOWNLOAD_HOST is set

The download loop unconditionally tried GITHUB_URL first, even when the
user explicitly named a download host. In locked-down networks, probing
github.com can trigger DLP / firewall alerts and contradicts the
explicit-override semantics ("use only this host, nothing else").

When LARK_CLI_DOWNLOAD_HOST is set, the chain is now just [override].
When it isn't, behavior is unchanged: [GITHUB_URL, derived?, npmmirror].

* refactor(install): drop LARK_CLI_DOWNLOAD_HOST env override

Issue #640 only asked for --registry to influence the binary download.
The LARK_CLI_DOWNLOAD_HOST escape hatch was added speculatively for
locked-down networks but is YAGNI — users in those environments already
have npm-level mirrors (--registry) or proxy controls (https_proxy).

Removing it shrinks the surface area:
  - delete parseDownloadBase() and its strict https-only validation
  - drop the install() branch that skipped GitHub on explicit override
  - simplify failure-help message to two recovery options

Resolution chain becomes [GITHUB, derived_from_npm_config_registry?,
npmmirror_default]. The npmmirror tail still preserves the pre-PR safety
net when a corp registry doesn't actually serve /-/binary/<pkg>/...

End-to-end verified on Linux + Windows via real `npm install -g <tgz>`:
all four user scenarios pass, with the issue #640 path (--registry=
npmmirror + GitHub blocked) finishing in 2s on Linux / 6s on Windows.
This commit is contained in:
sang-neo03
2026-04-29 16:46:30 +08:00
committed by GitHub
parent 7fc963f455
commit ea056d132e
2 changed files with 203 additions and 12 deletions

View File

@@ -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/<pkg>/..., 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/<pkg>/...
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 };

View File

@@ -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/<pkg>/..., 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/<pkg>/..., 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]
);
});
});