mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
refactor(cliproxy): modularize binary-manager into binary/ directory
- extract types, version-checker, lifecycle, updater, installer - extract downloader (verifier), extractors (tar, zip), version-cache - slim binary-manager.ts from 1080 to 137 lines (87% reduction) - add barrel export at binary/index.ts
This commit is contained in:
+58
-990
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Archive Extractor
|
||||
* Facade for tar.gz and zip archive extraction.
|
||||
*/
|
||||
|
||||
import { ArchiveExtension } from '../types';
|
||||
import { extractTarGz } from './tar-extractor';
|
||||
import { extractZip } from './zip-extractor';
|
||||
|
||||
// Re-export for convenience
|
||||
export { extractTarGz } from './tar-extractor';
|
||||
export { extractZip } from './zip-extractor';
|
||||
|
||||
/**
|
||||
* Extract archive based on extension
|
||||
*/
|
||||
export async function extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
extension: ArchiveExtension,
|
||||
verbose = false
|
||||
): Promise<void> {
|
||||
if (extension === 'tar.gz') {
|
||||
await extractTarGz(archivePath, destDir, verbose);
|
||||
} else {
|
||||
await extractZip(archivePath, destDir, verbose);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Binary Module - Barrel Export
|
||||
* Re-exports all binary management functionality.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type { VersionCache, UpdateCheckResult } from './types';
|
||||
export { VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE, GITHUB_API_LATEST_RELEASE } from './types';
|
||||
|
||||
// Downloader
|
||||
export { downloadFile, downloadWithRetry, fetchText, fetchJson } from './downloader';
|
||||
|
||||
// Verifier
|
||||
export { computeChecksum, parseChecksum, verifyChecksum } from './verifier';
|
||||
|
||||
// Version Cache
|
||||
export {
|
||||
getVersionCachePath,
|
||||
getVersionPinPath,
|
||||
readVersionCache,
|
||||
writeVersionCache,
|
||||
readInstalledVersion,
|
||||
writeInstalledVersion,
|
||||
getPinnedVersion,
|
||||
savePinnedVersion,
|
||||
clearPinnedVersion,
|
||||
isVersionPinned,
|
||||
} from './version-cache';
|
||||
|
||||
// Version Checker
|
||||
export { isNewerVersion, fetchLatestVersion, checkForUpdates } from './version-checker';
|
||||
|
||||
// Extractor
|
||||
export { extractTarGz, extractZip, extractArchive } from './extractor';
|
||||
|
||||
// Updater
|
||||
export {
|
||||
downloadAndInstall,
|
||||
deleteBinary,
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
getBinaryInfo,
|
||||
ensureBinary,
|
||||
} from './updater';
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Binary Installer
|
||||
* Handles downloading, verifying, and extracting binary.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { BinaryManagerConfig } from '../types';
|
||||
import {
|
||||
detectPlatform,
|
||||
getDownloadUrl,
|
||||
getChecksumsUrl,
|
||||
getExecutableName,
|
||||
} from '../platform-detector';
|
||||
import { downloadWithRetry } from './downloader';
|
||||
import { verifyChecksum, computeChecksum } from './verifier';
|
||||
import { extractArchive } from './extractor';
|
||||
import { writeInstalledVersion } from './version-cache';
|
||||
import { ProgressIndicator } from '../../utils/progress-indicator';
|
||||
import { ok } from '../../utils/ui';
|
||||
|
||||
/**
|
||||
* Download and install the binary
|
||||
*/
|
||||
export async function downloadAndInstall(
|
||||
config: BinaryManagerConfig,
|
||||
verbose = false
|
||||
): Promise<void> {
|
||||
const platform = detectPlatform(config.version);
|
||||
const downloadUrl = getDownloadUrl(config.version);
|
||||
const checksumsUrl = getChecksumsUrl(config.version);
|
||||
|
||||
fs.mkdirSync(config.binPath, { recursive: true });
|
||||
const archivePath = path.join(config.binPath, `cliproxy-archive.${platform.extension}`);
|
||||
const spinner = new ProgressIndicator(`Downloading CLIProxyAPI v${config.version}`);
|
||||
spinner.start();
|
||||
|
||||
try {
|
||||
const result = await downloadWithRetry(downloadUrl, archivePath, {
|
||||
maxRetries: config.maxRetries,
|
||||
verbose,
|
||||
});
|
||||
if (!result.success) {
|
||||
spinner.fail('Download failed');
|
||||
throw new Error(result.error || 'Download failed after retries');
|
||||
}
|
||||
|
||||
spinner.update('Verifying checksum');
|
||||
const checksumResult = await verifyChecksum(
|
||||
archivePath,
|
||||
platform.binaryName,
|
||||
checksumsUrl,
|
||||
verbose
|
||||
);
|
||||
|
||||
if (!checksumResult.valid) {
|
||||
spinner.fail('Checksum mismatch');
|
||||
fs.unlinkSync(archivePath);
|
||||
throw new Error(
|
||||
`Checksum mismatch for ${platform.binaryName}\nExpected: ${checksumResult.expected}\n` +
|
||||
`Actual: ${checksumResult.actual}\n\nManual download: ${downloadUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
spinner.update('Extracting binary');
|
||||
await extractArchive(archivePath, config.binPath, platform.extension, verbose);
|
||||
spinner.succeed('CLIProxyAPI ready');
|
||||
fs.unlinkSync(archivePath);
|
||||
|
||||
const binaryPath = path.join(config.binPath, getExecutableName());
|
||||
if (platform.os !== 'windows' && fs.existsSync(binaryPath)) {
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
if (verbose) console.error(`[cliproxy] Set executable permissions: ${binaryPath}`);
|
||||
}
|
||||
|
||||
writeInstalledVersion(config.binPath, config.version);
|
||||
console.log(ok(`CLIProxyAPI v${config.version} installed successfully`));
|
||||
} catch (error) {
|
||||
spinner.fail('Installation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete binary (for cleanup or reinstall) */
|
||||
export function deleteBinary(binPath: string, verbose = false): void {
|
||||
const binaryPath = path.join(binPath, getExecutableName());
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
fs.unlinkSync(binaryPath);
|
||||
if (verbose) console.error(`[cliproxy] Deleted: ${binaryPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get binary path */
|
||||
export function getBinaryPath(binPath: string): string {
|
||||
return path.join(binPath, getExecutableName());
|
||||
}
|
||||
|
||||
/** Check if binary exists */
|
||||
export function isBinaryInstalled(binPath: string): boolean {
|
||||
return fs.existsSync(getBinaryPath(binPath));
|
||||
}
|
||||
|
||||
/** Get binary info if installed */
|
||||
export async function getBinaryInfo(
|
||||
binPath: string,
|
||||
version: string
|
||||
): Promise<{
|
||||
path: string;
|
||||
version: string;
|
||||
platform: ReturnType<typeof detectPlatform>;
|
||||
checksum: string;
|
||||
} | null> {
|
||||
const binaryPath = getBinaryPath(binPath);
|
||||
if (!fs.existsSync(binaryPath)) return null;
|
||||
|
||||
const platform = detectPlatform();
|
||||
const checksum = await computeChecksum(binaryPath);
|
||||
return { path: binaryPath, version, platform, checksum };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Binary Lifecycle Manager
|
||||
* Handles ensuring binary availability and auto-updates.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { BinaryManagerConfig } from '../types';
|
||||
import { checkForUpdates, fetchLatestVersion, isNewerVersion } from './version-checker';
|
||||
import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer';
|
||||
import { info } from '../../utils/ui';
|
||||
import { isCliproxyRunning } from '../stats-fetcher';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
||||
|
||||
/** Log helper */
|
||||
function log(message: string, verbose: boolean): void {
|
||||
if (verbose) console.error(`[cliproxy] ${message}`);
|
||||
}
|
||||
|
||||
/** Handle auto-update when binary exists */
|
||||
async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise<void> {
|
||||
const updateResult = await checkForUpdates(config.binPath, config.version, verbose);
|
||||
if (!updateResult.hasUpdate) return;
|
||||
|
||||
const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
|
||||
const updateMsg = `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`;
|
||||
|
||||
if (proxyRunning) {
|
||||
console.log(info(updateMsg));
|
||||
console.log(info('Run "ccs cliproxy stop" then restart to apply update'));
|
||||
log('Skipping update: CLIProxyAPI is currently running', verbose);
|
||||
} else {
|
||||
console.log(info(updateMsg));
|
||||
console.log(info('Updating CLIProxyAPI...'));
|
||||
deleteBinary(config.binPath, verbose);
|
||||
config.version = updateResult.latestVersion;
|
||||
await downloadAndInstall(config, verbose);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure binary is available (download if missing, update if outdated)
|
||||
* @returns Path to executable binary
|
||||
*/
|
||||
export async function ensureBinary(config: BinaryManagerConfig): Promise<string> {
|
||||
const verbose = config.verbose;
|
||||
const binaryPath = getBinaryPath(config.binPath);
|
||||
|
||||
// Binary exists - check for updates unless forceVersion
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
log(`Binary exists: ${binaryPath}`, verbose);
|
||||
|
||||
if (config.forceVersion) {
|
||||
log('Force version mode: skipping auto-update', verbose);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleAutoUpdate(config, verbose);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
log(`Update check failed (non-blocking): ${err.message}`, verbose);
|
||||
}
|
||||
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
// Binary missing - download
|
||||
log('Binary not found, downloading...', verbose);
|
||||
|
||||
if (!config.forceVersion) {
|
||||
try {
|
||||
const latestVersion = await fetchLatestVersion(verbose);
|
||||
if (latestVersion && isNewerVersion(latestVersion, config.version)) {
|
||||
log(`Using latest version: ${latestVersion} (instead of ${config.version})`, verbose);
|
||||
config.version = latestVersion;
|
||||
}
|
||||
} catch {
|
||||
log(`Using pinned version: ${config.version}`, verbose);
|
||||
}
|
||||
} else {
|
||||
log(`Force version mode: using specified version ${config.version}`, verbose);
|
||||
}
|
||||
|
||||
await downloadAndInstall(config, verbose);
|
||||
return binaryPath;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Tar.gz Archive Extractor
|
||||
* Handles extraction of tar.gz archives using Node.js built-in modules.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as zlib from 'zlib';
|
||||
import { getExecutableName, getArchiveBinaryName } from '../platform-detector';
|
||||
|
||||
/**
|
||||
* Extract tar.gz archive using Node.js built-in modules
|
||||
*/
|
||||
export function extractTarGz(archivePath: string, destDir: string, verbose = false): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const execName = getExecutableName();
|
||||
const archiveBinaryName = getArchiveBinaryName();
|
||||
const gunzip = zlib.createGunzip();
|
||||
const input = fs.createReadStream(archivePath);
|
||||
|
||||
let headerBuffer = Buffer.alloc(0);
|
||||
let currentFile: { name: string; size: number } | null = null;
|
||||
let bytesRead = 0;
|
||||
let fileBuffer = Buffer.alloc(0);
|
||||
|
||||
const processData = (data: Buffer) => {
|
||||
headerBuffer = Buffer.concat([headerBuffer, data]);
|
||||
|
||||
while (headerBuffer.length >= 512) {
|
||||
if (!currentFile) {
|
||||
const header = headerBuffer.subarray(0, 512);
|
||||
headerBuffer = headerBuffer.subarray(512);
|
||||
|
||||
if (header.every((b) => b === 0)) return;
|
||||
|
||||
let name = '';
|
||||
for (let i = 0; i < 100 && header[i] !== 0; i++) {
|
||||
name += String.fromCharCode(header[i]);
|
||||
}
|
||||
|
||||
let sizeStr = '';
|
||||
for (let i = 124; i < 136 && header[i] !== 0; i++) {
|
||||
sizeStr += String.fromCharCode(header[i]);
|
||||
}
|
||||
const size = parseInt(sizeStr.trim(), 8) || 0;
|
||||
|
||||
if (name && size > 0) {
|
||||
const baseName = path.basename(name);
|
||||
if (
|
||||
baseName === execName ||
|
||||
baseName === archiveBinaryName ||
|
||||
baseName === 'cli-proxy-api'
|
||||
) {
|
||||
currentFile = { name: baseName, size };
|
||||
fileBuffer = Buffer.alloc(0);
|
||||
bytesRead = 0;
|
||||
} else {
|
||||
const paddedSize = Math.ceil(size / 512) * 512;
|
||||
if (headerBuffer.length >= paddedSize) {
|
||||
headerBuffer = headerBuffer.subarray(paddedSize);
|
||||
} else {
|
||||
currentFile = { name: '', size: paddedSize };
|
||||
bytesRead = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const remaining = currentFile.size - bytesRead;
|
||||
const chunk = headerBuffer.subarray(0, Math.min(remaining, headerBuffer.length));
|
||||
headerBuffer = headerBuffer.subarray(chunk.length);
|
||||
|
||||
if (currentFile.name) fileBuffer = Buffer.concat([fileBuffer, chunk]);
|
||||
bytesRead += chunk.length;
|
||||
|
||||
if (bytesRead >= currentFile.size) {
|
||||
if (currentFile.name) {
|
||||
const destPath = path.join(destDir, execName);
|
||||
fs.writeFileSync(destPath, fileBuffer);
|
||||
if (verbose)
|
||||
console.error(`[cliproxy] Extracted: ${currentFile.name} -> ${destPath}`);
|
||||
}
|
||||
const paddedSize = Math.ceil(currentFile.size / 512) * 512;
|
||||
const padding = paddedSize - currentFile.size;
|
||||
if (headerBuffer.length >= padding) headerBuffer = headerBuffer.subarray(padding);
|
||||
currentFile = null;
|
||||
fileBuffer = Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
input.pipe(gunzip);
|
||||
gunzip.on('data', processData);
|
||||
gunzip.on('end', resolve);
|
||||
gunzip.on('error', reject);
|
||||
input.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Binary Module Type Definitions
|
||||
* Types specific to binary management operations.
|
||||
*/
|
||||
|
||||
/** Version cache file structure */
|
||||
export interface VersionCache {
|
||||
latestVersion: string;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
/** Update check result */
|
||||
export interface UpdateCheckResult {
|
||||
hasUpdate: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
fromCache: boolean;
|
||||
checkedAt: number; // Unix timestamp of last check
|
||||
}
|
||||
|
||||
/** Cache duration for version check (1 hour in milliseconds) */
|
||||
export const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Version pin file name - stores user's explicit version choice */
|
||||
export const VERSION_PIN_FILE = '.version-pin';
|
||||
|
||||
/** GitHub API URL for latest release */
|
||||
export const GITHUB_API_LATEST_RELEASE =
|
||||
'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Binary Updater
|
||||
* Re-exports installer and lifecycle functionality.
|
||||
*/
|
||||
|
||||
// Re-export installer functions
|
||||
export {
|
||||
downloadAndInstall,
|
||||
deleteBinary,
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
getBinaryInfo,
|
||||
} from './installer';
|
||||
|
||||
// Re-export lifecycle functions
|
||||
export { ensureBinary } from './lifecycle';
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Version Cache Manager
|
||||
* Handles reading/writing version cache to avoid excessive GitHub API calls.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCliproxyDir, getBinDir } from '../config-generator';
|
||||
import { VersionCache, VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE } from './types';
|
||||
|
||||
/**
|
||||
* Get path to version cache file
|
||||
*/
|
||||
export function getVersionCachePath(): string {
|
||||
return path.join(getCliproxyDir(), '.version-cache.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to version pin file
|
||||
*/
|
||||
export function getVersionPinPath(): string {
|
||||
return path.join(getBinDir(), VERSION_PIN_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read version cache if still valid
|
||||
*/
|
||||
export function readVersionCache(): VersionCache | null {
|
||||
const cachePath = getVersionCachePath();
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(cachePath, 'utf8');
|
||||
const cache: VersionCache = JSON.parse(content);
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Cache expired
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write version to cache
|
||||
*/
|
||||
export function writeVersionCache(version: string): void {
|
||||
const cachePath = getVersionCachePath();
|
||||
const cache: VersionCache = {
|
||||
latestVersion: version,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
|
||||
} catch {
|
||||
// Silent fail - caching is optional
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read installed version from .version file
|
||||
*/
|
||||
export function readInstalledVersion(binPath: string, fallbackVersion: string): string {
|
||||
const versionFile = path.join(binPath, '.version');
|
||||
if (fs.existsSync(versionFile)) {
|
||||
try {
|
||||
return fs.readFileSync(versionFile, 'utf8').trim();
|
||||
} catch {
|
||||
return fallbackVersion;
|
||||
}
|
||||
}
|
||||
return fallbackVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write installed version to .version file
|
||||
*/
|
||||
export function writeInstalledVersion(binPath: string, version: string): void {
|
||||
const versionFile = path.join(binPath, '.version');
|
||||
try {
|
||||
fs.writeFileSync(versionFile, version, 'utf8');
|
||||
} catch {
|
||||
// Silent fail - not critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pinned version if one exists
|
||||
*/
|
||||
export function getPinnedVersion(): string | null {
|
||||
const pinPath = getVersionPinPath();
|
||||
if (!fs.existsSync(pinPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.readFileSync(pinPath, 'utf8').trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save pinned version to persist user's explicit choice
|
||||
*/
|
||||
export function savePinnedVersion(version: string): void {
|
||||
const pinPath = getVersionPinPath();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(pinPath), { recursive: true });
|
||||
fs.writeFileSync(pinPath, version, 'utf8');
|
||||
} catch {
|
||||
// Silent fail - not critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pinned version (unpin)
|
||||
*/
|
||||
export function clearPinnedVersion(): void {
|
||||
const pinPath = getVersionPinPath();
|
||||
if (fs.existsSync(pinPath)) {
|
||||
try {
|
||||
fs.unlinkSync(pinPath);
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a version is currently pinned
|
||||
*/
|
||||
export function isVersionPinned(): boolean {
|
||||
return getPinnedVersion() !== null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Version Checker
|
||||
* Handles checking GitHub API for latest version and comparing semver.
|
||||
*/
|
||||
|
||||
import { fetchJson } from './downloader';
|
||||
import { readVersionCache, writeVersionCache, readInstalledVersion } from './version-cache';
|
||||
import { UpdateCheckResult, GITHUB_API_LATEST_RELEASE } from './types';
|
||||
|
||||
/**
|
||||
* Compare semver versions (true if latest > current)
|
||||
*/
|
||||
export function isNewerVersion(latest: string, current: string): boolean {
|
||||
const latestParts = latest.split('.').map((p) => parseInt(p, 10) || 0);
|
||||
const currentParts = current.split('.').map((p) => parseInt(p, 10) || 0);
|
||||
|
||||
// Pad arrays to same length
|
||||
while (latestParts.length < 3) latestParts.push(0);
|
||||
while (currentParts.length < 3) currentParts.push(0);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (latestParts[i] > currentParts[i]) return true;
|
||||
if (latestParts[i] < currentParts[i]) return false;
|
||||
}
|
||||
|
||||
return false; // Equal versions
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest version from GitHub API
|
||||
*/
|
||||
export async function fetchLatestVersion(verbose = false): Promise<string> {
|
||||
const response = await fetchJson(GITHUB_API_LATEST_RELEASE, verbose);
|
||||
|
||||
// Extract version from tag_name (format: "v6.5.27" or "6.5.27")
|
||||
const tagName = response.tag_name as string;
|
||||
if (!tagName) {
|
||||
throw new Error('No tag_name in GitHub API response');
|
||||
}
|
||||
|
||||
return tagName.replace(/^v/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates by comparing installed version with latest release
|
||||
* Uses cache to avoid hitting GitHub API on every run
|
||||
*/
|
||||
export async function checkForUpdates(
|
||||
binPath: string,
|
||||
configVersion: string,
|
||||
verbose = false
|
||||
): Promise<UpdateCheckResult> {
|
||||
const currentVersion = readInstalledVersion(binPath, configVersion);
|
||||
|
||||
// Try cache first
|
||||
const cache = readVersionCache();
|
||||
if (cache) {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] Using cached version: ${cache.latestVersion}`);
|
||||
}
|
||||
return {
|
||||
hasUpdate: isNewerVersion(cache.latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion: cache.latestVersion,
|
||||
fromCache: true,
|
||||
checkedAt: cache.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch from GitHub API
|
||||
const latestVersion = await fetchLatestVersion(verbose);
|
||||
const now = Date.now();
|
||||
writeVersionCache(latestVersion);
|
||||
|
||||
return {
|
||||
hasUpdate: isNewerVersion(latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
fromCache: false,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Zip Archive Extractor
|
||||
* Handles extraction of zip archives using Node.js built-in modules.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as zlib from 'zlib';
|
||||
import { getExecutableName, getArchiveBinaryName } from '../platform-detector';
|
||||
|
||||
/**
|
||||
* Extract zip archive using Node.js (simple implementation)
|
||||
*/
|
||||
export function extractZip(archivePath: string, destDir: string, verbose = false): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const execName = getExecutableName();
|
||||
const archiveBinaryName = getArchiveBinaryName();
|
||||
const buffer = fs.readFileSync(archivePath);
|
||||
|
||||
// Find End of Central Directory record (EOCD)
|
||||
let eocdOffset = buffer.length - 22;
|
||||
while (eocdOffset >= 0) {
|
||||
if (buffer.readUInt32LE(eocdOffset) === 0x06054b50) break;
|
||||
eocdOffset--;
|
||||
}
|
||||
|
||||
if (eocdOffset < 0) {
|
||||
reject(new Error('Invalid ZIP file: EOCD not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const centralDirOffset = buffer.readUInt32LE(eocdOffset + 16);
|
||||
let offset = centralDirOffset;
|
||||
|
||||
while (offset < eocdOffset) {
|
||||
const sig = buffer.readUInt32LE(offset);
|
||||
if (sig !== 0x02014b50) break;
|
||||
|
||||
const compressionMethod = buffer.readUInt16LE(offset + 10);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
||||
const fileNameLength = buffer.readUInt16LE(offset + 28);
|
||||
const extraFieldLength = buffer.readUInt16LE(offset + 30);
|
||||
const commentLength = buffer.readUInt16LE(offset + 32);
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
||||
|
||||
const fileName = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength);
|
||||
const baseName = path.basename(fileName);
|
||||
|
||||
if (
|
||||
baseName === execName ||
|
||||
baseName === archiveBinaryName ||
|
||||
baseName === 'cli-proxy-api.exe'
|
||||
) {
|
||||
const localOffset = localHeaderOffset;
|
||||
const localSig = buffer.readUInt32LE(localOffset);
|
||||
|
||||
if (localSig !== 0x04034b50) {
|
||||
reject(new Error('Invalid local file header'));
|
||||
return;
|
||||
}
|
||||
|
||||
const localFileNameLength = buffer.readUInt16LE(localOffset + 26);
|
||||
const localExtraLength = buffer.readUInt16LE(localOffset + 28);
|
||||
const dataOffset = localOffset + 30 + localFileNameLength + localExtraLength;
|
||||
|
||||
let fileData: Buffer;
|
||||
if (compressionMethod === 0) {
|
||||
fileData = buffer.subarray(dataOffset, dataOffset + compressedSize);
|
||||
} else if (compressionMethod === 8) {
|
||||
const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize);
|
||||
fileData = zlib.inflateRawSync(compressed);
|
||||
} else {
|
||||
reject(new Error(`Unsupported compression method: ${compressionMethod}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileData.length !== uncompressedSize) {
|
||||
reject(new Error('Decompression size mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
const destPath = path.join(destDir, execName);
|
||||
fs.writeFileSync(destPath, fileData);
|
||||
if (verbose) console.error(`[cliproxy] Extracted: ${fileName} -> ${destPath}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
offset += 46 + fileNameLength + extraFieldLength + commentLength;
|
||||
}
|
||||
|
||||
reject(new Error(`Executable not found in archive: ${execName}`));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user