Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ jobs:
node-version: '20'
registry-url: 'https://registry.npmjs.org'

- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }

- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"scripts/install.js",
"scripts/install-wizard.js",
"scripts/run.js",
"checksums.txt",
"CHANGELOG.md"
],
"dependencies": {
Expand Down
110 changes: 85 additions & 25 deletions scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ 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 ALLOWED_HOSTS = [
"github.com",
"objects.githubusercontent.com",
"registry.npmmirror.com",
];

const PLATFORM_MAP = {
darwin: "darwin",
Expand All @@ -24,13 +30,6 @@ const ARCH_MAP = {
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}`;
Expand All @@ -40,12 +39,19 @@ const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));

fs.mkdirSync(binDir, { recursive: true });
function assertAllowedHost(url) {
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.includes(hostname)) {
throw new Error(`Download host not allowed: ${hostname}`);
}
}

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
Expand All @@ -56,6 +62,8 @@ function download(url, destPath) {
}

function install() {
fs.mkdirSync(binDir, { recursive: true });

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);

Expand All @@ -66,6 +74,9 @@ function install() {
download(MIRROR_URL, archivePath);
}

const expectedHash = getExpectedChecksum(archiveName);
verifyChecksum(archivePath, expectedHash);

if (isWindows) {
execFileSync("powershell", [
"-Command",
Expand All @@ -88,24 +99,73 @@ function install() {
}
}

// 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;
function getExpectedChecksum(archiveName, checksumsDir) {
const dir = checksumsDir || path.join(__dirname, "..");
const checksumsPath = path.join(dir, "checksums.txt");

if (isNpxPostinstall) {
process.exit(0);
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
Comment thread
MaxHuang22 marked this conversation as resolved.
}

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}`);
}

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);
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;

const content = fs.readFileSync(archivePath);
const actual = crypto.createHash("sha256").update(content).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 setting a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli`
);
process.exit(1);
}
}

module.exports = { getExpectedChecksum, verifyChecksum };
102 changes: 102 additions & 0 deletions scripts/install.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");

const crypto = require("crypto");

const { getExpectedChecksum, verifyChecksum } = require("./install.js");

describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
fs.writeFileSync(path.join(dir, "checksums.txt"), content, "utf8");
return dir;
}

it("returns correct hash from standard-format checksums.txt", () => {
const dir = makeTmpChecksums(
"abc123def456 lark-cli-1.0.0-darwin-arm64.tar.gz\n"
);
const hash = getExpectedChecksum(
"lark-cli-1.0.0-darwin-arm64.tar.gz",
dir
);
assert.equal(hash, "abc123def456");
});

it("returns correct entry when multiple entries exist", () => {
const dir = makeTmpChecksums(
"aaaa lark-cli-1.0.0-linux-amd64.tar.gz\n" +
"bbbb lark-cli-1.0.0-darwin-arm64.tar.gz\n" +
"cccc lark-cli-1.0.0-windows-amd64.zip\n"
);
const hash = getExpectedChecksum(
"lark-cli-1.0.0-darwin-arm64.tar.gz",
dir
);
assert.equal(hash, "bbbb");
});

it("throws Error when archiveName is not found", () => {
const dir = makeTmpChecksums(
"aaaa lark-cli-1.0.0-linux-amd64.tar.gz\n"
);
assert.throws(
() => getExpectedChecksum("nonexistent.tar.gz", dir),
{ message: /Checksum entry not found for nonexistent\.tar\.gz/ }
);
});

it("returns null when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
});
});

describe("verifyChecksum", () => {
function makeTmpFile(content) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
const filePath = path.join(dir, "archive.tar.gz");
fs.writeFileSync(filePath, content);
return filePath;
}

function sha256(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}

it("returns normally when hash matches", () => {
const content = "binary content here";
const filePath = makeTmpFile(content);
const hash = sha256(content);
// Should not throw
verifyChecksum(filePath, hash);
});

it("matches case-insensitively", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
// Should not throw
verifyChecksum(filePath, hash);
});

it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "0000000000000000000000000000000000000000000000000000000000000000"),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /Checksum mismatch/);
return true;
}
);
});
});
Loading