feat(copilot): add self-managed package manager for copilot-api

- install copilot-api locally to ~/.ccs/copilot/

- auto-update with version caching (1hr TTL)

- version pinning support via .version-pin file

- mirrors CLIProxy binary-manager pattern
This commit is contained in:
kaitranntt
2025-12-18 02:26:11 -05:00
parent 63bdc3ae39
commit ecdad1d6d0
+515
View File
@@ -0,0 +1,515 @@
/**
* Copilot Package Manager
*
* Self-managed npm package installation for copilot-api:
* - Installs copilot-api locally to ~/.ccs/copilot/
* - Auto-updates to latest version (like CLIProxy binary manager)
* - Version caching to avoid hitting npm registry on every run
* - Retry logic with exponential backoff
*
* Pattern: Mirrors CLIProxy BinaryManager but for npm packages
*/
import * as fs from 'fs';
import * as path from 'path';
import { spawn, spawnSync } from 'child_process';
import { ProgressIndicator } from '../utils/progress-indicator';
import { ok, info } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
/** Cache duration for version check (1 hour in milliseconds) */
const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000;
/** Version pin file name */
const VERSION_PIN_FILE = '.version-pin';
/** Package name to install */
const COPILOT_API_PACKAGE = 'copilot-api';
/** Version cache file structure */
interface VersionCache {
latestVersion: string;
checkedAt: number;
}
/** Update check result */
interface UpdateCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
fromCache: boolean;
}
/** Install result */
interface InstallResult {
success: boolean;
version?: string;
error?: string;
}
/**
* Get copilot base directory
* All copilot-related files are stored under ~/.ccs/copilot/
*/
export function getCopilotDir(): string {
return path.join(getCcsDir(), 'copilot');
}
/**
* Get path to copilot-api binary
*/
export function getCopilotApiBinPath(): string {
const binName = process.platform === 'win32' ? 'copilot-api.cmd' : 'copilot-api';
return path.join(getCopilotDir(), 'node_modules', '.bin', binName);
}
/**
* Get path to package.json
*/
function getPackageJsonPath(): string {
return path.join(getCopilotDir(), 'package.json');
}
/**
* Get path to version file
*/
function getVersionFilePath(): string {
return path.join(getCopilotDir(), '.version');
}
/**
* Get path to version cache file
*/
function getVersionCachePath(): string {
return path.join(getCopilotDir(), '.version-cache.json');
}
/**
* Get path to version pin file
*/
export function getVersionPinPath(): string {
return path.join(getCopilotDir(), VERSION_PIN_FILE);
}
/**
* Check if copilot-api is installed locally
*/
export function isCopilotApiInstalled(): boolean {
return fs.existsSync(getCopilotApiBinPath());
}
/**
* Get installed version from .version file
*/
export function getInstalledVersion(): string | null {
const versionFile = getVersionFilePath();
if (fs.existsSync(versionFile)) {
try {
return fs.readFileSync(versionFile, 'utf8').trim();
} catch {
return null;
}
}
return null;
}
/**
* Save installed version to file
*/
function saveInstalledVersion(version: string): void {
const versionFile = getVersionFilePath();
try {
fs.writeFileSync(versionFile, version, 'utf8');
} catch {
// Silent fail - not critical
}
}
/**
* Get cached latest version if still valid
*/
function getCachedLatestVersion(): string | 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.latestVersion;
}
// Cache expired
return null;
} catch {
return null;
}
}
/**
* Cache latest version for future checks
*/
function cacheLatestVersion(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
}
}
/**
* Fetch latest version from npm registry
*/
async function fetchLatestVersion(): Promise<string> {
return new Promise((resolve, reject) => {
const result = spawnSync('npm', ['view', COPILOT_API_PACKAGE, 'version'], {
encoding: 'utf8',
timeout: 15000,
shell: process.platform === 'win32',
});
if (result.status === 0 && result.stdout) {
resolve(result.stdout.trim());
} else {
reject(new Error(result.stderr || 'Failed to fetch version from npm'));
}
});
}
/**
* Compare semver versions (true if latest > current)
*/
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
}
/**
* 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
*/
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
}
}
/**
* Clear pinned version
*/
export function clearPinnedVersion(): void {
const pinPath = getVersionPinPath();
if (fs.existsSync(pinPath)) {
try {
fs.unlinkSync(pinPath);
} catch {
// Silent fail
}
}
}
/**
* Check for updates
*/
export async function checkForUpdates(): Promise<UpdateCheckResult> {
const currentVersion = getInstalledVersion() || '0.0.0';
// Try cache first
const cachedVersion = getCachedLatestVersion();
if (cachedVersion) {
return {
hasUpdate: isNewerVersion(cachedVersion, currentVersion),
currentVersion,
latestVersion: cachedVersion,
fromCache: true,
};
}
// Fetch from npm registry
try {
const latestVersion = await fetchLatestVersion();
cacheLatestVersion(latestVersion);
return {
hasUpdate: isNewerVersion(latestVersion, currentVersion),
currentVersion,
latestVersion,
fromCache: false,
};
} catch {
// Return no update if fetch fails
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
fromCache: false,
};
}
}
/**
* Run npm install in copilot directory
*/
async function runNpmInstall(version?: string): Promise<InstallResult> {
const copilotDir = getCopilotDir();
// Ensure directory exists
fs.mkdirSync(copilotDir, { recursive: true });
// Create package.json with versioned or latest dependency
const packageJson = {
name: 'ccs-copilot-local',
private: true,
dependencies: {
[COPILOT_API_PACKAGE]: version || 'latest',
},
};
fs.writeFileSync(getPackageJsonPath(), JSON.stringify(packageJson, null, 2), 'utf8');
return new Promise((resolve) => {
const proc = spawn('npm', ['install', '--prefer-offline', '--no-audit', '--no-fund'], {
cwd: copilotDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
// Get installed version from package-lock.json or package.json
try {
const lockPath = path.join(copilotDir, 'package-lock.json');
if (fs.existsSync(lockPath)) {
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const installedVersion =
lock.packages?.['node_modules/copilot-api']?.version ||
lock.dependencies?.[COPILOT_API_PACKAGE]?.version;
if (installedVersion) {
saveInstalledVersion(installedVersion);
resolve({ success: true, version: installedVersion });
return;
}
}
} catch {
// Fallback
}
resolve({ success: true, version: version || 'latest' });
} else {
resolve({ success: false, error: stderr || `npm install failed with code ${code}` });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
}
/**
* Ensure copilot-api is available (install if missing, update if outdated)
* @returns Path to copilot-api binary
*/
export async function ensureCopilotApi(verbose = false): Promise<string> {
const binPath = getCopilotApiBinPath();
const pinnedVersion = getPinnedVersion();
// Check if already installed
if (isCopilotApiInstalled()) {
if (verbose) {
console.error(`[copilot] Binary exists: ${binPath}`);
}
// Skip auto-update if version is pinned
if (pinnedVersion) {
if (verbose) {
console.error(`[copilot] Pinned version mode: skipping auto-update`);
}
return binPath;
}
// Check for updates (non-blocking for UX)
try {
const updateResult = await checkForUpdates();
if (updateResult.hasUpdate) {
console.log(
info(
`copilot-api update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
)
);
console.log(info('Updating copilot-api...'));
const spinner = new ProgressIndicator('Updating copilot-api');
spinner.start();
const result = await runNpmInstall(updateResult.latestVersion);
if (result.success) {
spinner.succeed('copilot-api updated');
console.log(ok(`copilot-api v${result.version} installed successfully`));
} else {
spinner.fail('Update failed (non-blocking)');
if (verbose) {
console.error(`[copilot] Update failed: ${result.error}`);
}
}
}
} catch (error) {
// Silent fail - don't block startup if update check fails
if (verbose) {
console.error(`[copilot] Update check failed: ${(error as Error).message}`);
}
}
return binPath;
}
// Not installed - install now
console.log(info('copilot-api not found, installing...'));
const spinner = new ProgressIndicator('Installing copilot-api');
spinner.start();
try {
// Get target version
let targetVersion: string | undefined;
if (pinnedVersion) {
targetVersion = pinnedVersion;
if (verbose) {
console.error(`[copilot] Using pinned version: ${pinnedVersion}`);
}
} else {
// Fetch latest version
try {
targetVersion = await fetchLatestVersion();
cacheLatestVersion(targetVersion);
} catch {
// Use 'latest' if fetch fails
targetVersion = undefined;
}
}
const result = await runNpmInstall(targetVersion);
if (result.success) {
spinner.succeed('copilot-api installed');
console.log(ok(`copilot-api v${result.version} installed successfully`));
return binPath;
} else {
spinner.fail('Installation failed');
throw new Error(result.error || 'npm install failed');
}
} catch (error) {
spinner.fail('Installation failed');
throw error;
}
}
/**
* Install a specific version of copilot-api
*/
export async function installCopilotApiVersion(version: string, _verbose = false): Promise<void> {
const spinner = new ProgressIndicator(`Installing copilot-api v${version}`);
spinner.start();
try {
const result = await runNpmInstall(version);
if (result.success) {
spinner.succeed('copilot-api installed');
console.log(ok(`copilot-api v${result.version} installed successfully`));
} else {
spinner.fail('Installation failed');
throw new Error(result.error || 'npm install failed');
}
} catch (error) {
spinner.fail('Installation failed');
throw error;
}
}
/**
* Uninstall copilot-api (remove ~/.ccs/copilot directory)
*/
export function uninstallCopilotApi(): void {
const copilotDir = getCopilotDir();
if (fs.existsSync(copilotDir)) {
fs.rmSync(copilotDir, { recursive: true, force: true });
}
}
/**
* Get copilot-api info
*/
export function getCopilotApiInfo(): {
installed: boolean;
version: string | null;
path: string;
pinnedVersion: string | null;
} {
return {
installed: isCopilotApiInstalled(),
version: getInstalledVersion(),
path: getCopilotApiBinPath(),
pinnedVersion: getPinnedVersion(),
};
}
export default {
getCopilotDir,
getCopilotApiBinPath,
isCopilotApiInstalled,
getInstalledVersion,
getPinnedVersion,
savePinnedVersion,
clearPinnedVersion,
checkForUpdates,
ensureCopilotApi,
installCopilotApiVersion,
uninstallCopilotApi,
getCopilotApiInfo,
};