mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
2 Commits
feat/sidec
...
fix/instal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
896e72d50d | ||
|
|
066e7011a1 |
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -45,6 +45,32 @@ jobs:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Fetch checksums.txt from GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release download "${GITHUB_REF_NAME}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern checksums.txt \
|
||||
--dir .
|
||||
|
||||
- name: Verify checksums.txt is present and matches current version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s checksums.txt
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
for plat in \
|
||||
"linux-amd64.tar.gz" \
|
||||
"linux-arm64.tar.gz" \
|
||||
"darwin-amd64.tar.gz" \
|
||||
"darwin-arm64.tar.gz" \
|
||||
"windows-amd64.zip" \
|
||||
"windows-arm64.zip"
|
||||
do
|
||||
grep -qE '^[0-9a-fA-F]{64}[[:space:]]+\*?lark-cli-'"${VERSION}"'-'"${plat}"'$' checksums.txt \
|
||||
|| { echo "checksums.txt missing valid entry for lark-cli-${VERSION}-${plat}"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"files": [
|
||||
"scripts/install.js",
|
||||
"scripts/run.js",
|
||||
"checksums.txt",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
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";
|
||||
@@ -21,78 +26,266 @@ const ARCH_MAP = {
|
||||
arm64: "arm64",
|
||||
};
|
||||
|
||||
const platform = PLATFORM_MAP[process.platform];
|
||||
const arch = ARCH_MAP[process.arch];
|
||||
|
||||
if (!platform || !arch) {
|
||||
console.error(
|
||||
`Unsupported platform: ${process.platform}-${process.arch}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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 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" : ""));
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
function download(url, destPath) {
|
||||
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
|
||||
// errors when the certificate revocation list server is unreachable
|
||||
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
|
||||
execSync(
|
||||
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
|
||||
{ stdio: ["ignore", "ignore", "pipe"] }
|
||||
);
|
||||
// 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 install() {
|
||||
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 {
|
||||
try {
|
||||
download(GITHUB_URL, archivePath);
|
||||
} catch (err) {
|
||||
download(MIRROR_URL, archivePath);
|
||||
}
|
||||
// 1. Early fail: if the bundled checksums.txt is broken,
|
||||
// report now before spending bandwidth.
|
||||
const expectedHash = getExpectedChecksum(archiveName);
|
||||
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
} else {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
// 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`);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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 setting a proxy:\n` +
|
||||
` export https_proxy=http://your-proxy:port\n` +
|
||||
` npm install -g @larksuite/cli`
|
||||
);
|
||||
process.exit(1);
|
||||
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,
|
||||
};
|
||||
|
||||
103
scripts/install.test.js
Normal file
103
scripts/install.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const {
|
||||
verifyChecksum,
|
||||
getExpectedChecksum,
|
||||
ChecksumError,
|
||||
PackageIntegrityError,
|
||||
} = require("./install.js");
|
||||
|
||||
function mktmpdir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "install-test-"));
|
||||
}
|
||||
|
||||
test("verifyChecksum: correct hash resolves", async () => {
|
||||
const dir = mktmpdir();
|
||||
try {
|
||||
const filePath = path.join(dir, "data.bin");
|
||||
const bytes = Buffer.from("hello world");
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
const correctHash = crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
await verifyChecksum(filePath, correctHash);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyChecksum: mismatched hash throws ChecksumError", async () => {
|
||||
const dir = mktmpdir();
|
||||
try {
|
||||
const filePath = path.join(dir, "data.bin");
|
||||
fs.writeFileSync(filePath, "hello world");
|
||||
const wrongHash = "0".repeat(64);
|
||||
|
||||
await assert.rejects(
|
||||
() => verifyChecksum(filePath, wrongHash),
|
||||
(err) => err instanceof ChecksumError,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("getExpectedChecksum: returns hash for listed archive", () => {
|
||||
const dir = mktmpdir();
|
||||
try {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
const knownHash = "a".repeat(64);
|
||||
fs.writeFileSync(
|
||||
checksumsPath,
|
||||
`${knownHash} lark-cli-1.0.0-linux-amd64.tar.gz\n`
|
||||
);
|
||||
|
||||
const result = getExpectedChecksum(
|
||||
"lark-cli-1.0.0-linux-amd64.tar.gz",
|
||||
checksumsPath,
|
||||
);
|
||||
assert.strictEqual(result, knownHash);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("getExpectedChecksum: throws PackageIntegrityError (not ChecksumError) when checksums.txt file is absent", () => {
|
||||
const dir = mktmpdir();
|
||||
try {
|
||||
const missingPath = path.join(dir, "does-not-exist.txt");
|
||||
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("lark-cli-1.0.0-linux-amd64.tar.gz", missingPath),
|
||||
(err) =>
|
||||
err instanceof PackageIntegrityError &&
|
||||
!(err instanceof ChecksumError),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("getExpectedChecksum: throws ChecksumError when entry missing", () => {
|
||||
const dir = mktmpdir();
|
||||
try {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
fs.writeFileSync(
|
||||
checksumsPath,
|
||||
`${"a".repeat(64)} some-other-archive.tar.gz\n`
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("nonexistent-archive.tar.gz", checksumsPath),
|
||||
(err) => err instanceof ChecksumError,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user