mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user