Skip to content

Commit b363851

Browse files
ByteYueclaude
andcommitted
feat: add isolated installation and version management for external CLIs
This change adds: - Isolated installation mode: install external CLIs to ~/.opencli/opt/ without polluting global - Version management: support multiple versions, switch between versions - Uninstall: cleanly uninstall isolated installations - New CLI commands: * opencli install <name> [--version <ver>] [--isolated] * opencli uninstall <name> [--version <ver>] * opencli switch <name> <version> - Backward compatible: global installation remains default Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5c655ee commit b363851

3 files changed

Lines changed: 427 additions & 13 deletions

File tree

src/cli.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { render as renderOutput } from './output.js';
1313
import { getBrowserFactory, browserSession } from './runtime.js';
1414
import { PKG_VERSION } from './version.js';
1515
import { printCompletionScript } from './completion.js';
16-
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
16+
import { loadExternalClis, executeExternalCli, installExternalCli, uninstallExternalCli, switchExternalCliVersion, registerExternalCli, isBinaryInstalled } from './external.js';
1717
import { registerAllCommands } from './commanderAdapter.js';
1818
import { getErrorMessage } from './errors.js';
1919

@@ -450,14 +450,35 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
450450
.command('install')
451451
.description('Install an external CLI')
452452
.argument('<name>', 'Name of the external CLI')
453-
.action((name: string) => {
453+
.option('--version <ver>', 'Install specific version (isolated mode only)')
454+
.option('--isolated', 'Install in isolated directory (does not affect global)')
455+
.action((name: string, opts: { version?: string; isolated?: boolean }) => {
454456
const ext = externalClis.find(e => e.name === name);
455457
if (!ext) {
456458
console.error(chalk.red(`External CLI '${name}' not found in registry.`));
457459
process.exitCode = 1;
458460
return;
459461
}
460-
installExternalCli(ext);
462+
installExternalCli(ext, { version: opts.version, isolated: opts.isolated });
463+
});
464+
465+
program
466+
.command('uninstall')
467+
.description('Uninstall an isolated external CLI')
468+
.argument('<name>', 'Name of the external CLI')
469+
.option('--version <ver>', 'Uninstall only the specified version')
470+
.action((name: string, opts: { version?: string }) => {
471+
uninstallExternalCli(name, opts.version);
472+
});
473+
474+
program
475+
.command('switch')
476+
.description('Switch active version of an isolated external CLI')
477+
.argument('<name>', 'Name of the external CLI')
478+
.argument('<version>', 'Version to activate')
479+
.action((name: string, version: string) => {
480+
const success = switchExternalCliVersion(name, version);
481+
if (!success) process.exitCode = 1;
461482
});
462483

463484
program

src/external-store.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* External CLI store - manages isolated installation lock file.
3+
*
4+
* Stores version information and installation metadata for
5+
* isolated-installed external CLIs.
6+
*/
7+
8+
import * as fs from 'node:fs';
9+
import * as path from 'node:path';
10+
import * as os from 'node:os';
11+
import { log } from './logger.js';
12+
import { getErrorMessage } from './errors.js';
13+
import type { ExternalLockFile, InstalledExternalCli } from './external.js';
14+
15+
/**
16+
* Get the root directory for isolated installations: ~/.opencli/opt/
17+
*/
18+
export function getOptRoot(): string {
19+
const home = os.homedir();
20+
return path.join(home, '.opencli', 'opt');
21+
}
22+
23+
/**
24+
* Get the path to the lock file: ~/.opencli/external.lock.json
25+
*/
26+
export function getExternalLockPath(): string {
27+
const home = os.homedir();
28+
return path.join(home, '.opencli', 'external.lock.json');
29+
}
30+
31+
/**
32+
* Read the lock file from disk.
33+
* Returns empty object if file doesn't exist or is corrupted.
34+
*/
35+
export function readLockFile(): ExternalLockFile {
36+
const lockPath = getExternalLockPath();
37+
if (!fs.existsSync(lockPath)) {
38+
return {};
39+
}
40+
try {
41+
const raw = fs.readFileSync(lockPath, 'utf8');
42+
return JSON.parse(raw) as ExternalLockFile;
43+
} catch (err) {
44+
log.warn(`Failed to parse external lock file: ${getErrorMessage(err)}`);
45+
log.warn('Starting with empty lock file.');
46+
return {};
47+
}
48+
}
49+
50+
/**
51+
* Write the lock file atomically.
52+
* Writes to a temp file then renames to avoid corruption.
53+
*/
54+
export function writeLockFile(lock: ExternalLockFile): boolean {
55+
const lockPath = getExternalLockPath();
56+
const tempPath = `${lockPath}.tmp`;
57+
const dir = path.dirname(lockPath);
58+
59+
try {
60+
if (!fs.existsSync(dir)) {
61+
fs.mkdirSync(dir, { recursive: true });
62+
}
63+
const json = JSON.stringify(lock, null, 2);
64+
fs.writeFileSync(tempPath, json, 'utf8');
65+
// Atomically rename (works on POSIX systems, Windows has caveats but OK here)
66+
fs.renameSync(tempPath, lockPath);
67+
return true;
68+
} catch (err) {
69+
log.error(`Failed to write external lock file: ${getErrorMessage(err)}`);
70+
try { fs.unlinkSync(tempPath); } catch {}
71+
return false;
72+
}
73+
}
74+
75+
/**
76+
* Get installed info for a specific CLI.
77+
*/
78+
export function getInstalledInfo(name: string): InstalledExternalCli | null {
79+
const lock = readLockFile();
80+
return lock[name] ?? null;
81+
}
82+
83+
/**
84+
* Update or insert an installed CLI entry.
85+
*/
86+
export function upsertInstallEntry(info: InstalledExternalCli): boolean {
87+
const lock = readLockFile();
88+
lock[info.name] = info;
89+
return writeLockFile(lock);
90+
}
91+
92+
/**
93+
* Remove an installed CLI entry completely.
94+
*/
95+
export function removeInstallEntry(name: string): boolean {
96+
const lock = readLockFile();
97+
if (!lock[name]) return false;
98+
delete lock[name];
99+
return writeLockFile(lock);
100+
}
101+
102+
/**
103+
* Remove a specific version of an installed CLI.
104+
* Returns true if the version was removed.
105+
*/
106+
export function removeVersionEntry(name: string, version: string): boolean {
107+
const lock = readLockFile();
108+
const info = lock[name];
109+
if (!info) return false;
110+
111+
const originalLength = info.versions.length;
112+
info.versions = info.versions.filter(v => v.version !== version);
113+
114+
if (info.versions.length === 0) {
115+
delete lock[name];
116+
}
117+
118+
return writeLockFile(lock) && originalLength !== info.versions.length;
119+
}
120+
121+
/**
122+
* Mark a specific version as current.
123+
*/
124+
export function setCurrentVersion(name: string, version: string): boolean {
125+
const lock = readLockFile();
126+
const info = lock[name];
127+
if (!info) return false;
128+
129+
for (const v of info.versions) {
130+
v.current = v.version === version;
131+
}
132+
133+
return writeLockFile(lock);
134+
}
135+
136+
/**
137+
* Get the currently active version for an installed CLI.
138+
*/
139+
export function getCurrentVersion(info: InstalledExternalCli): string | null {
140+
const current = info.versions.find(v => v.current);
141+
if (current) return current.version;
142+
// If none marked current, return the most recently installed
143+
if (info.versions.length > 0) {
144+
// Sort by installedAt descending
145+
const sorted = [...info.versions].sort((a, b) =>
146+
new Date(b.installedAt).getTime() - new Date(a.installedAt).getTime()
147+
);
148+
return sorted[0].version;
149+
}
150+
return null;
151+
}
152+
153+
/**
154+
* Get the full binary path for the currently active version.
155+
*/
156+
export function getCurrentBinaryPath(info: InstalledExternalCli): string | null {
157+
const version = getCurrentVersion(info);
158+
if (!version) return null;
159+
const entry = info.versions.find(v => v.version === version);
160+
if (!entry) return null;
161+
162+
// For npm packages installed with --prefix, binary is in node_modules/.bin
163+
// Try common locations
164+
const locations = [
165+
path.join(entry.installPath, 'node_modules', '.bin', info.binaryName),
166+
path.join(entry.installPath, 'bin', info.binaryName),
167+
path.join(entry.installPath, info.binaryName),
168+
];
169+
170+
for (const loc of locations) {
171+
if (fs.existsSync(loc) || fs.existsSync(`${loc}.cmd`)) {
172+
return loc;
173+
}
174+
}
175+
176+
// Fallback to the expected location
177+
return path.join(entry.installPath, info.binaryName);
178+
}

0 commit comments

Comments
 (0)