Files
larksuite-cli/scripts/install.js

348 lines
12 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",
riscv64: "riscv64",
};
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;
}
/**
* Decide from a `curl --version` output whether curl is >= 7.70.0 — the
* release (2020-04-29) that introduced --ssl-revoke-best-effort. Kept pure
* (no I/O) so the version-comparison logic can be unit tested without
* spawning a process. Reads the leading "curl X.Y.Z" token, ignoring the
* trailing "libcurl/X.Y.Z" that may report a different version.
*
* @param {string} versionOutput raw stdout of `curl --version`
* @returns {boolean} true when the parsed version is >= 7.70.0
*/
function isCurlVersionSupported(versionOutput) {
const match = String(versionOutput).match(/^\s*curl\s+(\d+)\.(\d+)\.(\d+)/i);
if (!match) return false;
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
return major > 7 || (major === 7 && minor >= 70);
}
// Memoized probe result. curl's version is invariant for the lifetime of the
// install, while download() runs once per mirror URL — so probe at most once.
let _curlSupportsSslRevokeBestEffort;
/**
* Detect whether the system curl supports --ssl-revoke-best-effort. Older
* versions (notably the curl 7.55.1 shipped with older Windows 10 builds)
* exit with "unknown option" if the flag is passed.
*
* @returns {boolean} true when curl >= 7.70.0 is available
*/
function curlSupportsSslRevokeBestEffort() {
if (_curlSupportsSslRevokeBestEffort !== undefined) {
return _curlSupportsSslRevokeBestEffort;
}
try {
const output = execFileSync("curl", ["--version"], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 5000,
});
_curlSupportsSslRevokeBestEffort = isCurlVersionSupported(output);
} catch (_) {
_curlSupportsSslRevokeBestEffort = false;
}
return _curlSupportsSslRevokeBestEffort;
}
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.
// Only use it when the system curl is new enough (>= 7.70.0).
if (isWindows && curlSupportsSslRevokeBestEffort()) {
args.unshift("--ssl-revoke-best-effort");
}
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
function extractZipWindows(archivePath, destDir) {
const psOpts = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
const psStdio = ["ignore", "inherit", "inherit"];
const psEnv = {
...process.env,
LARK_CLI_ARCHIVE: archivePath,
LARK_CLI_DEST: destDir,
};
try {
const dotnet =
"$ErrorActionPreference='Stop';" +
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:LARK_CLI_ARCHIVE,$env:LARK_CLI_DEST)";
execFileSync("powershell.exe", [...psOpts, dotnet], { stdio: psStdio, env: psEnv });
} catch (primaryErr) {
try {
const cmdlet =
"$ErrorActionPreference='Stop';" +
"Expand-Archive -LiteralPath $env:LARK_CLI_ARCHIVE -DestinationPath $env:LARK_CLI_DEST -Force";
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
} catch (secondErr) {
try {
execFileSync("tar", ["-xf", archivePath, "-C", destDir], { stdio: psStdio });
} catch (fallbackErr) {
throw new Error(
`Failed to extract ${archivePath}. ` +
`.NET ZipFile attempt: ${primaryErr.message}. ` +
`Expand-Archive fallback: ${secondErr.message}. ` +
`tar fallback: ${fallbackErr.message}`
);
}
}
}
}
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) {
extractZipWindows(archivePath, tmpDir);
} 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, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };