Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -450,14 +450,37 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
.command('install')
.description('Install an external CLI')
.argument('<name>', 'Name of the external CLI')
.action((name: string) => {
.option('--version <ver>', '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>', 'Name of the external CLI')
.option('--version <ver>', '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>', 'Name of the external CLI')
.argument('<version>', 'Version to activate')
.action((name: string, version: string) => {
const success = switchExternalCliVersion(name, version);
if (!success) process.exitCode = 1;
});

program
Expand Down
178 changes: 178 additions & 0 deletions src/external-store.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading