diff --git a/src/cli.ts b/src/cli.ts index fe358144..3d716181 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,7 @@ import { render as renderOutput } from './output.js'; import { getBrowserFactory, browserSession } from './runtime.js'; import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; -import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js'; +import { loadExternalClis, executeExternalCli, installExternalCli, uninstallExternalCli, switchExternalCliVersion, registerExternalCli, isBinaryInstalled } from './external.js'; import { registerAllCommands } from './commanderAdapter.js'; import { EXIT_CODES, getErrorMessage } from './errors.js'; @@ -450,14 +450,37 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .command('install') .description('Install an external CLI') .argument('', 'Name of the external CLI') - .action((name: string) => { + .option('--version ', 'Install specific version (isolated mode only)') + .option('--isolated', 'Install in isolated directory (does not affect global)') + .action((name: string, opts: { version?: string; isolated?: boolean }) => { const ext = externalClis.find(e => e.name === name); if (!ext) { console.error(chalk.red(`External CLI '${name}' not found in registry.`)); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } - installExternalCli(ext); + const success = installExternalCli(ext, { version: opts.version, isolated: opts.isolated }); + if (!success) process.exitCode = 1; + }); + + program + .command('uninstall') + .description('Uninstall an isolated external CLI') + .argument('', 'Name of the external CLI') + .option('--version ', 'Uninstall only the specified version') + .action((name: string, opts: { version?: string }) => { + const success = uninstallExternalCli(name, opts.version); + if (!success) process.exitCode = 1; + }); + + program + .command('switch') + .description('Switch active version of an isolated external CLI') + .argument('', 'Name of the external CLI') + .argument('', 'Version to activate') + .action((name: string, version: string) => { + const success = switchExternalCliVersion(name, version); + if (!success) process.exitCode = 1; }); program diff --git a/src/external-store.ts b/src/external-store.ts new file mode 100644 index 00000000..f739d2c0 --- /dev/null +++ b/src/external-store.ts @@ -0,0 +1,178 @@ +/** + * External CLI store - manages isolated installation lock file. + * + * Stores version information and installation metadata for + * isolated-installed external CLIs. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { log } from './logger.js'; +import { getErrorMessage } from './errors.js'; +import type { ExternalLockFile, InstalledExternalCli } from './external.js'; + +/** + * Get the root directory for isolated installations: ~/.opencli/opt/ + */ +export function getOptRoot(): string { + const home = os.homedir(); + return path.join(home, '.opencli', 'opt'); +} + +/** + * Get the path to the lock file: ~/.opencli/external.lock.json + */ +export function getExternalLockPath(): string { + const home = os.homedir(); + return path.join(home, '.opencli', 'external.lock.json'); +} + +/** + * Read the lock file from disk. + * Returns empty object if file doesn't exist or is corrupted. + */ +export function readLockFile(): ExternalLockFile { + const lockPath = getExternalLockPath(); + if (!fs.existsSync(lockPath)) { + return {}; + } + try { + const raw = fs.readFileSync(lockPath, 'utf8'); + return JSON.parse(raw) as ExternalLockFile; + } catch (err) { + log.warn(`Failed to parse external lock file: ${getErrorMessage(err)}`); + log.warn('Starting with empty lock file.'); + return {}; + } +} + +/** + * Write the lock file atomically. + * Writes to a temp file then renames to avoid corruption. + */ +export function writeLockFile(lock: ExternalLockFile): boolean { + const lockPath = getExternalLockPath(); + const tempPath = `${lockPath}.tmp`; + const dir = path.dirname(lockPath); + + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const json = JSON.stringify(lock, null, 2); + fs.writeFileSync(tempPath, json, 'utf8'); + // Atomically rename (works on POSIX systems, Windows has caveats but OK here) + fs.renameSync(tempPath, lockPath); + return true; + } catch (err) { + log.error(`Failed to write external lock file: ${getErrorMessage(err)}`); + try { fs.unlinkSync(tempPath); } catch {} + return false; + } +} + +/** + * Get installed info for a specific CLI. + */ +export function getInstalledInfo(name: string): InstalledExternalCli | null { + const lock = readLockFile(); + return lock[name] ?? null; +} + +/** + * Update or insert an installed CLI entry. + */ +export function upsertInstallEntry(info: InstalledExternalCli): boolean { + const lock = readLockFile(); + lock[info.name] = info; + return writeLockFile(lock); +} + +/** + * Remove an installed CLI entry completely. + */ +export function removeInstallEntry(name: string): boolean { + const lock = readLockFile(); + if (!lock[name]) return false; + delete lock[name]; + return writeLockFile(lock); +} + +/** + * Remove a specific version of an installed CLI. + * Returns true if the version was removed. + */ +export function removeVersionEntry(name: string, version: string): boolean { + const lock = readLockFile(); + const info = lock[name]; + if (!info) return false; + + const originalLength = info.versions.length; + info.versions = info.versions.filter(v => v.version !== version); + + if (info.versions.length === 0) { + delete lock[name]; + } + + return writeLockFile(lock) && originalLength !== info.versions.length; +} + +/** + * Mark a specific version as current. + */ +export function setCurrentVersion(name: string, version: string): boolean { + const lock = readLockFile(); + const info = lock[name]; + if (!info) return false; + + for (const v of info.versions) { + v.current = v.version === version; + } + + return writeLockFile(lock); +} + +/** + * Get the currently active version for an installed CLI. + */ +export function getCurrentVersion(info: InstalledExternalCli): string | null { + const current = info.versions.find(v => v.current); + if (current) return current.version; + // If none marked current, return the most recently installed + if (info.versions.length > 0) { + // Sort by installedAt descending + const sorted = [...info.versions].sort((a, b) => + new Date(b.installedAt).getTime() - new Date(a.installedAt).getTime() + ); + return sorted[0].version; + } + return null; +} + +/** + * Get the full binary path for the currently active version. + */ +export function getCurrentBinaryPath(info: InstalledExternalCli): string | null { + const version = getCurrentVersion(info); + if (!version) return null; + const entry = info.versions.find(v => v.version === version); + if (!entry) return null; + + // For npm packages installed with --prefix, binary is in node_modules/.bin + // Try common locations + const locations = [ + path.join(entry.installPath, 'node_modules', '.bin', info.binaryName), + path.join(entry.installPath, 'bin', info.binaryName), + path.join(entry.installPath, info.binaryName), + ]; + + for (const loc of locations) { + if (fs.existsSync(loc) || fs.existsSync(`${loc}.cmd`)) { + return loc; + } + } + + // Fallback to the expected location + return path.join(entry.installPath, info.binaryName); +} diff --git a/src/external.ts b/src/external.ts index 72939e8c..6dfa0c1e 100644 --- a/src/external.ts +++ b/src/external.ts @@ -7,9 +7,12 @@ import yaml from 'js-yaml'; import chalk from 'chalk'; import { log } from './logger.js'; import { EXIT_CODES, getErrorMessage } from './errors.js'; +import * as externalStore from './external-store.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export type InstallationType = 'global' | 'isolated'; + export interface ExternalCliInstall { mac?: string; linux?: string; @@ -17,6 +20,35 @@ export interface ExternalCliInstall { default?: string; } +export interface IsolatedInstallEntry { + version: string; + installPath: string; + installedAt: string; + current: boolean; +} + +export interface InstalledExternalCli { + name: string; + binaryName: string; + installType: InstallationType; + versions: IsolatedInstallEntry[]; + cachedVersion?: string; + cachedAt?: string; +} + +export interface ExternalLockFile { + [name: string]: InstalledExternalCli; +} + +export interface ListExternalCliEntry { + name: string; + description?: string; + binary: string; + installed: boolean; + version?: string; + installType?: InstallationType; +} + export interface ExternalCliConfig { name: string; binary: string; @@ -142,7 +174,12 @@ function runInstallCommand(cmd: string): void { } } -export function installExternalCli(cli: ExternalCliConfig): boolean { +export interface InstallOptions { + version?: string; + isolated?: boolean; +} + +export function installExternalCli(cli: ExternalCliConfig, opts: InstallOptions = {}): boolean { if (!cli.install) { console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`)); console.error(`Please install '${cli.binary}' manually.`); @@ -156,16 +193,92 @@ export function installExternalCli(cli: ExternalCliConfig): boolean { return false; } - console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`)); - console.log(chalk.dim(`$ ${cmd}`)); + if (!opts.isolated) { + // Global installation - original behavior + console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Installing globally...`)); + console.log(chalk.dim(`$ ${cmd}`)); + try { + runInstallCommand(cmd); + console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`)); + return true; + } catch (err) { + console.error(chalk.red(`❌ Failed to install '${cli.name}': ${getErrorMessage(err)}`)); + return false; + } + } + + // Isolated installation + const version = opts.version || 'latest'; + console.log(chalk.cyan(`🔹 '${cli.name}'@${version} - installing in isolated mode...`)); + + // Check if already installed + const existing = externalStore.getInstalledInfo(cli.name); + if (existing) { + const hasVersion = existing.versions.some(v => v.version === version); + if (hasVersion) { + console.log(chalk.yellow(`⚠️ '${cli.name}'@${version} is already installed. Switching to it...`)); + externalStore.setCurrentVersion(cli.name, version); + console.log(chalk.green(`✅ Switched to '${cli.name}'@${version}\n`)); + return true; + } + } + + // Create isolated directory + const optRoot = externalStore.getOptRoot(); + const installPath = path.join(optRoot, cli.name, version); + if (!fs.existsSync(installPath)) { + fs.mkdirSync(installPath, { recursive: true }); + } + + // Modify install command for isolated installation + let installCmd = cmd; + if (cmd.startsWith('npm install ')) { + // For npm packages: npm install @ --prefix + const pkgSpec = version === 'latest' ? cli.name : `${cli.name}@${version}`; + installCmd = `npm install ${pkgSpec} --prefix "${installPath}"`; + } else if (cmd.startsWith('yarn add ')) { + const pkgSpec = version === 'latest' ? cli.name : `${cli.name}@${version}`; + installCmd = `yarn add ${pkgSpec} --cwd "${installPath}"`; + } + + console.log(chalk.dim(`$ ${installCmd}`)); try { - runInstallCommand(cmd); - console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`)); - return true; + runInstallCommand(installCmd); } catch (err) { console.error(chalk.red(`❌ Failed to install '${cli.name}': ${getErrorMessage(err)}`)); + // Clean up partial installation + try { fs.rmSync(installPath, { recursive: true, force: true }); } catch {} return false; } + + // Update lock file + const installedAt = new Date().toISOString(); + const entry: IsolatedInstallEntry = { + version, + installPath, + installedAt, + current: true, + }; + + if (existing) { + // Unmark current on existing + existing.versions.forEach(v => v.current = false); + existing.versions.push(entry); + externalStore.upsertInstallEntry({ + ...existing, + versions: existing.versions, + }); + } else { + externalStore.upsertInstallEntry({ + name: cli.name, + binaryName: cli.binary, + installType: 'isolated', + versions: [entry], + }); + } + + console.log(chalk.green(`✅ Installed '${cli.name}'@${version} in isolated mode.\n`)); + return true; } export function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void { @@ -175,29 +288,131 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext throw new Error(`External CLI '${name}' not found in registry.`); } + // Check for isolated installation first + const installedInfo = externalStore.getInstalledInfo(name); + let binaryPath = cli.binary; + + if (installedInfo && installedInfo.installType === 'isolated') { + const currentPath = externalStore.getCurrentBinaryPath(installedInfo); + if (currentPath) { + if (fs.existsSync(currentPath) || fs.existsSync(`${currentPath}.cmd`)) { + binaryPath = currentPath; + } else { + console.log(chalk.yellow(`⚠️ Isolated installation not found at ${currentPath}. Falling back to global.`)); + } + } + } + // 1. Check if installed - if (!isBinaryInstalled(cli.binary)) { + const isInstalled = installedInfo?.installType === 'isolated' + ? fs.existsSync(binaryPath) || fs.existsSync(`${binaryPath}.cmd`) + : isBinaryInstalled(cli.binary); + + if (!isInstalled) { // 2. Try to auto install const success = installExternalCli(cli); if (!success) { process.exitCode = EXIT_CODES.SERVICE_UNAVAIL; return; } + // After install, check again for isolated + const newInfo = externalStore.getInstalledInfo(name); + if (newInfo?.installType === 'isolated') { + const newPath = externalStore.getCurrentBinaryPath(newInfo); + if (newPath) { + binaryPath = newPath; + } + } } // 3. Passthrough execution with stdio inherited - const result = spawnSync(cli.binary, args, { stdio: 'inherit' }); + const result = spawnSync(binaryPath, args, { stdio: 'inherit' }); if (result.error) { - console.error(chalk.red(`Failed to execute '${cli.binary}': ${result.error.message}`)); + console.error(chalk.red(`Failed to execute '${binaryPath}': ${result.error.message}`)); process.exitCode = EXIT_CODES.GENERIC_ERROR; return; } - + if (result.status !== null) { process.exitCode = result.status; } } +/** + * Uninstall an external CLI. + * If version is specified, only uninstall that version. + * Otherwise, uninstall all versions. + */ +export function uninstallExternalCli(name: string, version?: string): boolean { + const installedInfo = externalStore.getInstalledInfo(name); + if (!installedInfo) { + console.error(chalk.red(`External CLI '${name}' is not installed in isolated mode.`)); + console.error(chalk.dim(`For globally installed CLI, please uninstall it manually.`)); + return false; + } + + const optRoot = externalStore.getOptRoot(); + const cliDir = path.join(optRoot, name); + + if (version) { + // Uninstall only the specified version + const versionDir = path.join(cliDir, version); + if (!fs.existsSync(versionDir)) { + console.error(chalk.red(`Version '${version}' of '${name}' is not installed.`)); + return false; + } + + try { + fs.rmSync(versionDir, { recursive: true, force: true }); + } catch (err) { + console.error(chalk.red(`Failed to delete version directory: ${getErrorMessage(err)}`)); + return false; + } + + const removed = externalStore.removeVersionEntry(name, version); + console.log(chalk.green(`✅ Uninstalled '${name}'@${version} successfully.`)); + return removed; + } + + // Uninstall all versions + if (fs.existsSync(cliDir)) { + try { + fs.rmSync(cliDir, { recursive: true, force: true }); + } catch (err) { + console.error(chalk.red(`Failed to delete installation directory: ${getErrorMessage(err)}`)); + return false; + } + } + + const removed = externalStore.removeInstallEntry(name); + console.log(chalk.green(`✅ Uninstalled '${name}' completely.`)); + return removed; +} + +/** + * Switch the currently active version. + */ +export function switchExternalCliVersion(name: string, version: string): boolean { + const installedInfo = externalStore.getInstalledInfo(name); + if (!installedInfo) { + console.error(chalk.red(`External CLI '${name}' is not installed in isolated mode.`)); + return false; + } + + const hasVersion = installedInfo.versions.some(v => v.version === version); + if (!hasVersion) { + console.error(chalk.red(`Version '${version}' is not installed for '${name}'.`)); + console.error(chalk.dim(`Installed versions: ${installedInfo.versions.map(v => v.version).join(', ')}`)); + return false; + } + + const success = externalStore.setCurrentVersion(name, version); + if (success) { + console.log(chalk.green(`✅ Switched '${name}' to version ${version}.`)); + } + return success; +} + export interface RegisterOptions { binary?: string; install?: string; diff --git a/tests/e2e/external-cli-management.test.ts b/tests/e2e/external-cli-management.test.ts new file mode 100644 index 00000000..f0ee64ac --- /dev/null +++ b/tests/e2e/external-cli-management.test.ts @@ -0,0 +1,184 @@ +/** + * E2E tests for external CLI isolated installation, version management, + * uninstall, and switch commands. + * + * Uses a temp HOME directory to isolate from the real ~/.opencli. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { runCli } from './helpers.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +const TEMP_HOME = path.join(os.tmpdir(), `opencli-e2e-ext-${Date.now()}`); + +function envWithHome(): Record { + return { HOME: TEMP_HOME, USERPROFILE: TEMP_HOME }; +} + +function readLockFile(): Record { + const lockPath = path.join(TEMP_HOME, '.opencli', 'external.lock.json'); + if (!fs.existsSync(lockPath)) return {}; + return JSON.parse(fs.readFileSync(lockPath, 'utf8')); +} + +describe('external CLI management E2E', () => { + beforeAll(() => { + fs.mkdirSync(TEMP_HOME, { recursive: true }); + }); + + afterAll(() => { + // Clean up temp home + try { + fs.rmSync(TEMP_HOME, { recursive: true, force: true }); + } catch {} + }); + + // ── register ── + it('register adds a CLI to user registry', async () => { + const { stdout, code } = await runCli( + ['register', 'cowsay', '--binary', 'cowsay', '--install', 'npm install -g cowsay', '--desc', 'ASCII art cow'], + { env: envWithHome(), timeout: 15_000 }, + ); + expect(code).toBe(0); + expect(stdout).toContain('Registered'); + + // Verify user registry file was created + const registryPath = path.join(TEMP_HOME, '.opencli', 'external-clis.yaml'); + expect(fs.existsSync(registryPath)).toBe(true); + const content = fs.readFileSync(registryPath, 'utf8'); + expect(content).toContain('cowsay'); + }); + + // ── install --isolated ── + it('install --isolated installs a CLI to isolated directory', async () => { + const { stdout, code } = await runCli( + ['install', 'cowsay', '--isolated'], + { env: envWithHome(), timeout: 60_000 }, + ); + expect(code).toBe(0); + expect(stdout).toContain('isolated'); + + // Verify lock file was created + const lock = readLockFile(); + expect(lock['cowsay']).toBeDefined(); + expect(lock['cowsay'].installType).toBe('isolated'); + expect(lock['cowsay'].versions.length).toBeGreaterThanOrEqual(1); + + // Verify install directory exists + const optRoot = path.join(TEMP_HOME, '.opencli', 'opt'); + expect(fs.existsSync(optRoot)).toBe(true); + const cowsayDir = path.join(optRoot, 'cowsay'); + expect(fs.existsSync(cowsayDir)).toBe(true); + }, 90_000); + + // ── install --isolated --version ── + it('install --isolated --version installs a specific version', async () => { + const { stdout, code } = await runCli( + ['install', 'cowsay', '--isolated', '--version', '1.5.0'], + { env: envWithHome(), timeout: 60_000 }, + ); + expect(code).toBe(0); + + // Verify version directory + const versionDir = path.join(TEMP_HOME, '.opencli', 'opt', 'cowsay', '1.5.0'); + expect(fs.existsSync(versionDir)).toBe(true); + + // Verify lock file has the version + const lock = readLockFile(); + const versions = lock['cowsay'].versions.map((v: any) => v.version); + expect(versions).toContain('1.5.0'); + }, 90_000); + + // ── switch ── + it('switch changes the active version', async () => { + // First get the current versions from lock file + const lockBefore = readLockFile(); + const versions = lockBefore['cowsay'].versions; + expect(versions.length).toBeGreaterThanOrEqual(2); + + // Find a non-current version to switch to + const currentVer = versions.find((v: any) => v.current)?.version; + const otherVer = versions.find((v: any) => !v.current)?.version; + expect(otherVer).toBeDefined(); + + const { stdout, code } = await runCli( + ['switch', 'cowsay', otherVer!], + { env: envWithHome(), timeout: 15_000 }, + ); + expect(code).toBe(0); + expect(stdout).toContain('Switched'); + + // Verify lock file was updated + const lockAfter = readLockFile(); + const currentAfter = lockAfter['cowsay'].versions.find((v: any) => v.current); + expect(currentAfter.version).toBe(otherVer); + }); + + // ── switch error: non-existent version ── + it('switch fails for non-installed version', async () => { + const { stderr, code } = await runCli( + ['switch', 'cowsay', '99.99.99'], + { env: envWithHome(), timeout: 15_000 }, + ); + expect(code).toBe(1); + }); + + // ── uninstall --version ── + it('uninstall --version removes a specific version', async () => { + const { stdout, code } = await runCli( + ['uninstall', 'cowsay', '--version', '1.5.0'], + { env: envWithHome(), timeout: 15_000 }, + ); + expect(code).toBe(0); + expect(stdout).toContain('Uninstalled'); + + // Verify version directory was removed + const versionDir = path.join(TEMP_HOME, '.opencli', 'opt', 'cowsay', '1.5.0'); + expect(fs.existsSync(versionDir)).toBe(false); + + // Verify lock file was updated + const lock = readLockFile(); + if (lock['cowsay']) { + const versions = lock['cowsay'].versions.map((v: any) => v.version); + expect(versions).not.toContain('1.5.0'); + } + }); + + // ── uninstall all ── + it('uninstall removes all versions of a CLI', async () => { + const { stdout, code } = await runCli( + ['uninstall', 'cowsay'], + { env: envWithHome(), timeout: 15_000 }, + ); + expect(code).toBe(0); + expect(stdout).toContain('Uninstalled'); + + // Verify entire CLI directory was removed + const cliDir = path.join(TEMP_HOME, '.opencli', 'opt', 'cowsay'); + expect(fs.existsSync(cliDir)).toBe(false); + + // Verify lock file was cleaned + const lock = readLockFile(); + expect(lock['cowsay']).toBeUndefined(); + }); + + // ── uninstall error: not installed ── + it('uninstall fails for non-installed CLI', async () => { + const { stderr, code } = await runCli( + ['uninstall', 'nonexistent-cli-xyz'], + { env: envWithHome(), timeout: 15_000 }, + ); + expect(code).toBe(1); + }); + + // ── switch error: not installed ── + it('switch fails for non-installed CLI', async () => { + const { stderr, code } = await runCli( + ['switch', 'nonexistent-cli-xyz', '1.0.0'], + { env: envWithHome(), timeout: 15_000 }, + ); + expect(code).toBe(1); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 48154835..cd63ce77 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ 'tests/e2e/management.test.ts', 'tests/e2e/output-formats.test.ts', 'tests/e2e/plugin-management.test.ts', + 'tests/e2e/external-cli-management.test.ts', // Extended browser tests (20+ sites) — opt-in only: // OPENCLI_E2E=1 npx vitest run ...(includeExtendedE2e ? ['tests/e2e/browser-public-extended.test.ts', 'tests/e2e/browser-auth.test.ts'] : []),