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