mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +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