gitlink-cli/npm/scripts/install.js

444 lines
13 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const os = require("os");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const https = require("https");
const http = require("http");
const { execSync } = require("child_process");
const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli";
const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli";
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
const DOWNLOAD_TIMEOUT_MS = 120000;
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap = {
x64: "amd64",
arm64: "arm64",
};
const goPlatform = platformMap[platform];
const goArch = archMap[arch];
if (!goPlatform || !goArch) {
throw new Error(
`Unsupported platform: ${platform}-${arch}. ` +
`Supported: darwin-x64, darwin-arm64, linux-x64, linux-arm64, win32-x64, win32-arm64`
);
}
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
}
function getBinaryName(platform) {
return platform === "windows" ? BINARY_NAME + ".exe" : BINARY_NAME;
}
function getArchiveName(platform, arch) {
const ext = platform === "windows" ? ".zip" : ".tar.gz";
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function fetch(url, options = {}) {
return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5;
let redirectCount = 0;
// Proxy support: respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY
function getProxy(targetUrl) {
try {
const parsed = new URL(targetUrl);
const noProxy = (process.env.NO_PROXY || process.env.no_proxy || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const np of noProxy) {
if (parsed.hostname.endsWith(np) || parsed.hostname === np) {
return null;
}
}
if (parsed.protocol === "https:") {
return (
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy ||
null
);
}
return (
process.env.HTTP_PROXY ||
process.env.http_proxy ||
null
);
} catch {
return null;
}
}
function doRequest(currentUrl) {
const proxyUrl = getProxy(currentUrl);
const mod = currentUrl.startsWith("https") ? https : http;
const parsedUrl = new URL(currentUrl);
const reqOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === "https:" ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: "GET",
timeout: options.timeout || DOWNLOAD_TIMEOUT_MS,
};
const req = mod.get(currentUrl, reqOptions, (res) => {
if (
(res.statusCode === 301 ||
res.statusCode === 302 ||
res.statusCode === 307 ||
res.statusCode === 308) &&
res.headers.location
) {
redirectCount++;
if (redirectCount > maxRedirects) {
reject(new Error(`Too many redirects (max ${maxRedirects})`));
return;
}
let redirectUrl = res.headers.location;
if (redirectUrl.startsWith("/")) {
const parsed = new URL(currentUrl);
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
}
doRequest(redirectUrl);
return;
}
if (res.statusCode !== 200) {
reject(
new Error(`HTTP ${res.statusCode} when downloading ${currentUrl}`)
);
return;
}
if (options.json) {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error(`Failed to parse JSON: ${e.message}`));
}
});
} else {
const chunks = [];
const totalSize = parseInt(res.headers["content-length"], 10) || 0;
let downloaded = 0;
let lastProgress = 0;
res.on("data", (chunk) => {
chunks.push(chunk);
downloaded += chunk.length;
// Progress indicator (every 10% or every 512KB)
if (totalSize > 0) {
const pct = Math.floor((downloaded / totalSize) * 100);
if (pct >= lastProgress + 10) {
lastProgress = pct;
process.stdout.write(`\r Downloading: ${pct}%`);
}
} else if (downloaded - lastProgress > 512 * 1024) {
lastProgress = downloaded;
process.stdout.write(
`\r Downloaded: ${(downloaded / 1024 / 1024).toFixed(1)} MB`
);
}
});
res.on("end", () => {
if (totalSize > 0 || downloaded > 512 * 1024) {
process.stdout.write("\r");
}
resolve(Buffer.concat(chunks));
});
}
});
req.on("error", reject);
req.setTimeout(options.timeout || DOWNLOAD_TIMEOUT_MS, () => {
req.destroy();
reject(new Error("Request timed out"));
});
}
doRequest(url);
});
}
async function findReleaseAsset(platform, arch) {
const archiveName = getArchiveName(platform, arch);
const tagName = `v${VERSION}`;
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
console.log(`Fetching release info from ${apiUrl}`);
try {
const releases = await fetch(apiUrl, { json: true });
let release = null;
if (Array.isArray(releases)) {
release = releases.find(
(r) => r.tag_name === tagName || r.tag_name === VERSION
);
if (!release && releases.length > 0) {
release = releases[0];
}
} else if (releases && releases.releases) {
const list = releases.releases;
release = list.find(
(r) => r.tag_name === tagName || r.tag_name === VERSION
);
if (!release && list.length > 0) {
release = list[0];
}
}
if (release && release.attachments) {
let asset = release.attachments.find(
(a) => a.title === archiveName || a.filename === archiveName
);
if (!asset) {
const ext = platform === "windows" ? ".zip" : ".tar.gz";
const pattern = `_${platform}_${arch}${ext}`;
asset = release.attachments.find(
(a) => (a.title || a.filename || "").endsWith(pattern)
);
}
if (asset) {
let downloadUrl =
asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
if (downloadUrl.startsWith("/")) {
downloadUrl = RELEASE_BASE + downloadUrl;
}
return downloadUrl;
}
}
} catch (e) {
console.log(`Warning: Could not fetch release info: ${e.message}`);
}
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
}
function extractArchive(archivePath, destDir, platform) {
const isWindows = platform === "windows";
if (isWindows) {
// Use PowerShell with properly escaped paths for Windows
const escapedArchive = archivePath.replace(/'/g, "''");
const escapedDest = destDir.replace(/'/g, "''");
try {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${escapedArchive}' -DestinationPath '${escapedDest}'"`,
{ stdio: "pipe" }
);
} catch {
// Fallback: try tar on modern Windows (10+)
try {
execSync(`tar -xf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
} catch (e2) {
throw new Error(
`Failed to extract archive. Tried PowerShell Expand-Archive and tar.\n${e2.message}`
);
}
}
} else {
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
}
}
function classifyError(err) {
const code = err.code || (err.cause && err.cause.code) || "";
const messages = {
ENOTFOUND: "DNS 解析失败,请检查网络连接",
ECONNREFUSED: "连接被拒绝,请检查代理设置 (HTTP_PROXY/HTTPS_PROXY)",
ETIMEDOUT: "连接超时,请尝试设置 HTTP_PROXY 环境变量",
EACCES: "权限不足,请使用管理员权限运行或检查安装目录",
EPERM: "权限不足,请使用管理员权限运行或检查安装目录",
};
return messages[code] || null;
}
function verifyChecksum(filePath, expectedHash) {
if (!expectedHash) return true;
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
if (hash !== expectedHash) {
throw new Error(
`Checksum verification failed for ${path.basename(filePath)}\n` +
` Expected: ${expectedHash}\n` +
` Actual: ${hash}\n` +
`The downloaded file may be corrupted or tampered with.`
);
}
console.log(` Checksum verified: SHA256 OK`);
return true;
}
async function downloadAndExtract(url, destDir, platform, expectedChecksum) {
console.log(`Downloading ${BINARY_NAME} from ${url}...`);
const data = await fetch(url);
const isWindows = platform === "windows";
const archiveExt = isWindows ? "download.zip" : "download.tar.gz";
const archivePath = path.join(destDir, archiveExt);
fs.writeFileSync(archivePath, data);
console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
// Verify checksum if provided
verifyChecksum(archivePath, expectedChecksum);
extractArchive(archivePath, destDir, platform);
fs.unlinkSync(archivePath);
// Find the binary in extracted files
const binaryName = getBinaryName(platform);
const binaryPath = path.join(destDir, binaryName);
if (!fs.existsSync(binaryPath)) {
const files = fs.readdirSync(destDir);
for (const file of files) {
const subPath = path.join(destDir, file, binaryName);
if (fs.existsSync(subPath)) {
fs.renameSync(subPath, binaryPath);
break;
}
}
}
if (!fs.existsSync(binaryPath)) {
throw new Error(`Binary "${binaryName}" not found after extraction`);
}
if (!isWindows) {
fs.chmodSync(binaryPath, 0o755);
}
console.log(`Installed ${BINARY_NAME} to ${binaryPath}`);
}
async function main() {
let platformInfo = null;
let archiveName = null;
try {
platformInfo = getPlatformInfo();
const { platform, arch } = platformInfo;
archiveName = getArchiveName(platform, arch);
console.log(`Platform: ${platform}-${arch}`);
console.log(`Expected release asset: ${archiveName}`);
const binDir = path.join(__dirname, "..", "bin");
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
}
const binaryPath = path.join(binDir, getBinaryName(platform));
// If binary already exists, check version matches
if (fs.existsSync(binaryPath)) {
try {
const output = execSync(`"${binaryPath}" version`, {
encoding: "utf-8",
stdio: "pipe",
timeout: 5000,
});
if (output.includes(VERSION)) {
console.log(
`${BINARY_NAME} v${VERSION} already installed, skipping download.`
);
return;
}
console.log(
`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`
);
} catch {
console.log(
`${BINARY_NAME} binary exists but is not compatible, re-downloading...`
);
}
fs.unlinkSync(binaryPath);
}
const downloadUrl = await findReleaseAsset(platform, arch);
// Download with retry
let lastError = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await downloadAndExtract(downloadUrl, binDir, platform, null);
lastError = null;
break;
} catch (err) {
lastError = err;
if (attempt < MAX_RETRIES) {
console.log(
`Download attempt ${attempt}/${MAX_RETRIES} failed: ${err.message}`
);
console.log(`Retrying in ${RETRY_DELAY_MS / 1000}s...`);
await sleep(RETRY_DELAY_MS);
}
}
}
if (lastError) {
throw lastError;
}
} catch (err) {
const classifiedMsg = classifyError(err);
console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`);
if (classifiedMsg) {
console.error(`\n诊断信息: ${classifiedMsg}`);
}
if (platformInfo) {
console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`);
}
if (archiveName) {
console.error(`Expected release asset: ${archiveName}`);
}
console.error(
`\nYou can install manually:\n` +
` 1. Download from https://www.gitlink.org.cn/${REPO_OWNER}/${REPO_NAME}/releases\n` +
` 2. Extract and place the binary in your PATH\n` +
` 3. Or build from source: git clone && make build\n`
);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
getPlatformInfo,
getBinaryName,
getArchiveName,
findReleaseAsset,
classifyError,
verifyChecksum,
};