Files
larksuite-cli/scripts/install.js

292 lines
9.4 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");
class ChecksumError extends Error {}
class NetworkError extends Error {}
class PackageIntegrityError extends Error {}
const VERSION = require("../package.json").version;
const REPO = "larksuite/cli";
const NAME = "lark-cli";
const PLATFORM_MAP = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const ARCH_MAP = {
x64: "amd64",
arm64: "arm64",
};
const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const ALLOWED_INITIAL_HOSTS = new Set([
"github.com",
"registry.npmmirror.com",
]);
const CURL_CONNECT_TIMEOUT_SEC = 10;
const CURL_MAX_TIME_SEC = 120;
const CURL_MAX_REDIRS = 5;
const DEFAULT_CHECKSUM_PATH = path.join(__dirname, "..", "checksums.txt");
// Defensive: escape single quotes for PowerShell literal-string embedding.
// tmpDir comes from mkdtempSync so is controlled, but this hardens against
// future refactors that route external input into the script.
function escapeSingleQuotes(s) {
return s.replace(/'/g, "''");
}
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
function download(url, destPath) {
// JS-layer pre-check: initial URL must be https and in allowlist.
// Redirect targets are NOT host-checked; we rely on curl's
// --proto-redir =https + --max-redirs + SHA256 verify for safety.
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
throw new NetworkError(`Non-HTTPS URL rejected: ${url}`);
}
if (!ALLOWED_INITIAL_HOSTS.has(parsed.hostname)) {
throw new NetworkError(`Untrusted initial host: ${parsed.hostname}`);
}
const args = [
"--fail", // HTTP 4xx/5xx -> non-zero exit
"--location", // follow redirects
"--proto", "=https", // initial URL: https only
"--proto-redir", "=https", // redirect targets: https only
"--max-redirs", String(CURL_MAX_REDIRS),
"--tlsv1.2", // minimum TLS 1.2
"--connect-timeout", String(CURL_CONNECT_TIMEOUT_SEC),
"--max-time", String(CURL_MAX_TIME_SEC),
"--silent", "--show-error",
"--output", destPath,
];
if (isWindows) {
// Schannel CRL check hard-fails when the CRL server is unreachable;
// this flag was in the original install.js and is preserved to
// avoid regression for users in corporate networks.
args.unshift("--ssl-revoke-best-effort");
}
// URL is always the last positional arg.
args.push(url);
try {
execFileSync("curl", args, {
stdio: ["ignore", "ignore", "pipe"],
});
} catch (err) {
if (err.code === "ENOENT") {
// ENOENT is NOT a NetworkError: another source won't help (curl
// is missing). Throw plain Error so the fallback loop re-raises
// instead of silently trying the next URL.
throw new Error(
"curl is required for installation but was not found in PATH. " +
"Install curl or manually download the binary from " +
`https://github.com/${REPO}/releases/tag/v${VERSION}`
);
}
const stderr = err.stderr ? err.stderr.toString().trim() : "";
const exitCode = err.status != null ? err.status : "unknown";
throw new NetworkError(
`curl exited with code ${exitCode}${stderr ? ": " + stderr : ""}`
);
}
}
function downloadWithFallback(urls, destPath) {
const attempts = [];
for (const url of urls) {
try {
download(url, destPath);
return url;
} catch (err) {
if (err instanceof NetworkError) {
attempts.push({ url, error: err.message });
continue;
}
// ChecksumError, plain Error (ENOENT), or any other type:
// re-raise immediately without trying the next source.
throw err;
}
}
const detail = attempts
.map((a) => ` - ${a.url}\n ${a.error}`)
.join("\n");
throw new NetworkError(`All download sources failed:\n${detail}`);
}
function extract(archivePath, tmpDir) {
if (isWindows) {
const script =
`$ErrorActionPreference = 'Stop'\n` +
`Expand-Archive -LiteralPath '${escapeSingleQuotes(archivePath)}' ` +
`-DestinationPath '${escapeSingleQuotes(tmpDir)}' -Force\n`;
const scriptPath = path.join(tmpDir, "extract.ps1");
fs.writeFileSync(scriptPath, script, { encoding: "utf-8" });
execFileSync("powershell", [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-File", scriptPath,
], { stdio: "ignore" });
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
});
}
}
function verifyChecksum(filePath, expectedHash) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath);
stream.on("error", reject);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => {
const actual = hash.digest("hex");
const expected = expectedHash.toLowerCase();
if (actual !== expected) {
reject(new ChecksumError(
`SHA256 mismatch for ${path.basename(filePath)}\n` +
` expected: ${expected}\n` +
` actual: ${actual}`
));
return;
}
resolve();
});
});
}
function getExpectedChecksum(archiveFilename, checksumPath = DEFAULT_CHECKSUM_PATH) {
if (!fs.existsSync(checksumPath)) {
// Packaging bug, not a tamper signal — routed separately.
throw new PackageIntegrityError("checksums.txt missing from package");
}
const contents = fs.readFileSync(checksumPath, "utf-8");
const lineRegex = /^([0-9a-fA-F]{64})\s+\*?(.+)$/;
for (const rawLine of contents.split("\n")) {
const line = rawLine.trim();
if (line === "" || line.startsWith("#")) continue;
const match = line.match(lineRegex);
if (!match) continue;
const [, hash, filename] = match;
if (filename.trim() === archiveFilename) {
return hash.toLowerCase();
}
}
throw new ChecksumError(`No checksum entry for ${archiveFilename}`);
}
async function install() {
const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
if (!platform || !arch) {
throw new Error(
`Unsupported platform: ${process.platform}-${process.arch}. ` +
`Download manually from ` +
`https://github.com/${REPO}/releases/tag/v${VERSION}`
);
}
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const sources = [
`https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`,
`https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`,
];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
// 1. Early fail: if the bundled checksums.txt is broken,
// report now before spending bandwidth.
const expectedHash = getExpectedChecksum(archiveName);
// 2. Multi-source download; only NetworkError triggers fallback.
const sourceUrl = downloadWithFallback(sources, archivePath);
// 3. Integrity check outside the fallback loop. Mismatch aborts
// the entire install, does NOT try the next source.
await verifyChecksum(archivePath, expectedHash);
// 4. Extract (safe: bytes match the official release).
extract(archivePath, tmpDir);
// 5. Copy binary into place and chmod.
const binaryName = NAME + (isWindows ? ".exe" : "");
const extractedBinary = path.join(tmpDir, binaryName);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(extractedBinary, dest);
fs.chmodSync(dest, 0o755);
console.log(
`${NAME} v${VERSION} installed successfully ` +
`(from ${new URL(sourceUrl).hostname})`
);
} finally {
// 6. Always clean up the temp directory.
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
if (require.main === module) {
install().catch((err) => {
if (err instanceof PackageIntegrityError) {
console.error(`\n${NAME} install aborted: the installed package looks broken.\n`);
console.error(err.message);
console.error(
`\nRe-install the package; if the issue persists, please report it:\n` +
` https://github.com/${REPO}/issues\n`
);
} else if (err instanceof ChecksumError) {
console.error(`\n[SECURITY] ${NAME} install aborted due to integrity check failure:\n`);
console.error(err.message);
console.error(
`\nRetry the install; if it persists, report it and download manually:\n` +
` https://github.com/${REPO}/releases/tag/v${VERSION}\n`
);
} else if (err instanceof NetworkError) {
console.error(`\n${NAME} install failed due to network errors:\n`);
console.error(err.message);
console.error(
`\nIf you are behind a firewall or on a restricted network, try configuring a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli\n`
);
} else {
console.error(`\n${NAME} install failed:\n${err.stack || err.message}`);
}
process.exit(1);
});
}
module.exports = {
verifyChecksum,
getExpectedChecksum,
ChecksumError,
NetworkError,
PackageIntegrityError,
};