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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ OpenCLI acts as a universal hub for your existing command-line tools — unified
opencli register mycli
```

**Versioned isolated installs** — keep external CLIs under `~/.opencli/opt` without mutating the global environment:

```bash
opencli install vercel --isolated
opencli install vercel --isolated --version 43.1.0
opencli switch vercel 43.1.0
opencli uninstall vercel --version 43.1.0
```

### Desktop App Adapters

Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
Expand Down
9 changes: 9 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,15 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
opencli register mycli
```

**隔离安装与版本切换**:把外部 CLI 安装到 `~/.opencli/opt`,不污染全局环境:

```bash
opencli install vercel --isolated
opencli install vercel --isolated --version 43.1.0
opencli switch vercel 43.1.0
opencli uninstall vercel --version 43.1.0
```

### 桌面应用适配器

每个桌面适配器都有自己详细的文档说明,包括命令参考、启动配置与使用示例:
Expand Down
38 changes: 32 additions & 6 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, listExternalClis, isBinaryInstalled } from './external.js';
import { registerAllCommands } from './commanderAdapter.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';

Expand Down Expand Up @@ -85,12 +85,15 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log();
}

const externalClis = loadExternalClis();
const externalClis = listExternalClis(loadExternalClis());
if (externalClis.length > 0) {
console.log(chalk.bold.cyan(' external CLIs'));
for (const ext of externalClis) {
const isInstalled = isBinaryInstalled(ext.binary);
const tag = isInstalled ? chalk.green('[installed]') : chalk.yellow('[auto-install]');
const tag = ext.installed
? ext.installType === 'isolated'
? chalk.green(`[isolated${ext.version ? ` @${ext.version}` : ''}]`)
: chalk.green('[installed]')
: chalk.yellow('[auto-install]');
console.log(` ${ext.name} ${tag}${ext.description ? chalk.dim(` — ${ext.description}`) : ''}`);
}
console.log();
Expand Down Expand Up @@ -450,14 +453,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
60 changes: 60 additions & 0 deletions src/external-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { mockHomedir } = vi.hoisted(() => ({
mockHomedir: vi.fn(() => '/tmp'),
}));

vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os');
return {
...actual,
homedir: mockHomedir,
};
});

import { getInstalledInfo, removeVersionEntry, upsertInstallEntry } from './external-store.js';
import type { InstalledExternalCli } from './external.js';

describe('external-store', () => {
beforeEach(() => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-external-store-test-'));
mockHomedir.mockReturnValue(tempHome);
});

afterEach(() => {
fs.rmSync(mockHomedir(), { recursive: true, force: true });
});

it('promotes the most recently installed version when removing the current version', () => {
const info: InstalledExternalCli = {
name: 'vercel',
binaryName: 'vercel',
installType: 'isolated',
versions: [
{
version: '42.0.0',
installPath: '/tmp/vercel/42.0.0',
installedAt: '2026-03-01T00:00:00.000Z',
current: false,
},
{
version: '43.1.0',
installPath: '/tmp/vercel/43.1.0',
installedAt: '2026-03-10T00:00:00.000Z',
current: true,
},
],
};

expect(upsertInstallEntry(info)).toBe(true);
expect(removeVersionEntry('vercel', '43.1.0')).toBe(true);

const saved = getInstalledInfo('vercel');
expect(saved?.versions).toHaveLength(1);
expect(saved?.versions[0].version).toBe('42.0.0');
expect(saved?.versions[0].current).toBe(true);
});
});
184 changes: 184 additions & 0 deletions src/external-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* 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 removedEntry = info.versions.find(v => v.version === version);
const originalLength = info.versions.length;
info.versions = info.versions.filter(v => v.version !== version);

if (info.versions.length === 0) {
delete lock[name];
} else if (removedEntry?.current && !info.versions.some(v => v.current)) {
const nextCurrent = [...info.versions].sort((a, b) =>
new Date(b.installedAt).getTime() - new Date(a.installedAt).getTime()
)[0];
nextCurrent.current = true;
}

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