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:
kaitranntt
2025-12-19 12:34:48 -05:00
parent b49b7d17b2
commit d3c94fe6a2
11 changed files with 797 additions and 990 deletions
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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);
}
}
+44
View File
@@ -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';
+119
View File
@@ -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 };
}
+86
View File
@@ -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;
}
+98
View File
@@ -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);
});
}
+29
View File
@@ -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';
+16
View File
@@ -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';
+142
View File
@@ -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;
}
+82
View File
@@ -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,
};
}
+95
View File
@@ -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}`));
});
}