mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* 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.
265 lines
8.6 KiB
JavaScript
265 lines
8.6 KiB
JavaScript
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { execFileSync } = require("child_process");
|
|
const os = require("os");
|
|
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 = new Set([
|
|
"github.com",
|
|
"objects.githubusercontent.com",
|
|
"registry.npmmirror.com",
|
|
]);
|
|
|
|
const PLATFORM_MAP = {
|
|
darwin: "darwin",
|
|
linux: "linux",
|
|
win32: "windows",
|
|
};
|
|
|
|
const ARCH_MAP = {
|
|
x64: "amd64",
|
|
arm64: "arm64",
|
|
};
|
|
|
|
const platform = PLATFORM_MAP[process.platform];
|
|
const arch = ARCH_MAP[process.arch];
|
|
|
|
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 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.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 = [
|
|
"--fail", "--location", "--silent", "--show-error",
|
|
"--connect-timeout", "10", "--max-time", "120",
|
|
"--max-redirs", "3",
|
|
"--output", destPath,
|
|
];
|
|
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
|
|
// errors when the certificate revocation list server is unreachable
|
|
if (isWindows) args.unshift("--ssl-revoke-best-effort");
|
|
args.push(url);
|
|
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
}
|
|
|
|
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 {
|
|
// 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);
|
|
|
|
if (isWindows) {
|
|
execFileSync("powershell", [
|
|
"-Command",
|
|
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
|
|
], { stdio: "ignore" });
|
|
} else {
|
|
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
|
|
stdio: "ignore",
|
|
});
|
|
}
|
|
|
|
const binaryName = NAME + (isWindows ? ".exe" : "");
|
|
const extractedBinary = path.join(tmpDir, binaryName);
|
|
|
|
fs.copyFileSync(extractedBinary, dest);
|
|
fs.chmodSync(dest, 0o755);
|
|
console.log(`${NAME} v${VERSION} installed successfully`);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function getExpectedChecksum(archiveName, checksumsDir) {
|
|
const dir = checksumsDir || path.join(__dirname, "..");
|
|
const checksumsPath = path.join(dir, "checksums.txt");
|
|
|
|
if (!fs.existsSync(checksumsPath)) {
|
|
console.error(
|
|
"[WARN] checksums.txt not found, skipping checksum verification"
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const content = fs.readFileSync(checksumsPath, "utf8");
|
|
for (const line of content.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
const idx = trimmed.indexOf(" ");
|
|
if (idx === -1) continue;
|
|
const hash = trimmed.slice(0, idx);
|
|
const name = trimmed.slice(idx + 2);
|
|
if (name === archiveName) return hash;
|
|
}
|
|
|
|
throw new Error(`Checksum entry not found for ${archiveName}`);
|
|
}
|
|
|
|
function verifyChecksum(archivePath, expectedHash) {
|
|
if (expectedHash === null) return;
|
|
|
|
// Stream the file to avoid loading the entire archive into memory.
|
|
// Archives can be 10-100MB; streaming keeps RSS constant.
|
|
const hash = crypto.createHash("sha256");
|
|
const fd = fs.openSync(archivePath, "r");
|
|
try {
|
|
const buf = Buffer.alloc(64 * 1024);
|
|
let bytesRead;
|
|
while ((bytesRead = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
|
|
hash.update(buf.subarray(0, bytesRead));
|
|
}
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
const actual = hash.digest("hex");
|
|
|
|
if (actual.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
throw new Error(
|
|
`[SECURITY] Checksum mismatch for ${path.basename(archivePath)}: expected ${expectedHash} but got ${actual}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
if (!platform || !arch) {
|
|
console.error(
|
|
`Unsupported platform: ${process.platform}-${process.arch}`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// When triggered as a postinstall hook under npx, skip the binary download.
|
|
// The "install" wizard doesn't need it, and run.js calls install.js directly
|
|
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
|
|
const isNpxPostinstall =
|
|
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
|
|
|
|
if (isNpxPostinstall) {
|
|
process.exit(0);
|
|
}
|
|
|
|
try {
|
|
install();
|
|
} catch (err) {
|
|
console.error(`Failed to install ${NAME}:`, err.message);
|
|
console.error(
|
|
`\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\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, resolveMirrorUrls };
|