Skip to content

Commit e7e4367

Browse files
ByteYuejackwener
andauthored
review: scope plugin lock tracking cleanly (#362)
Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent b7ada0e commit e7e4367

7 files changed

Lines changed: 210 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ opencli plugin update --all # Update all install
294294
opencli plugin uninstall my-tool # Remove
295295
```
296296

297+
`opencli plugin list` also shows the tracked short commit hash when a plugin version is recorded in `~/.opencli/plugins.lock.json`.
298+
297299
| Plugin | Type | Description |
298300
|--------|------|-------------|
299301
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |

README.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ opencli plugin update --all # 更新全部已安
296296
opencli plugin uninstall my-tool # 卸载
297297
```
298298

299+
当 plugin 的版本被记录到 `~/.opencli/plugins.lock.json` 后,`opencli plugin list` 也会显示对应的短 commit hash。
300+
299301
| 插件 | 类型 | 描述 |
300302
|------|------|------|
301303
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending 仓库 |

docs/guide/plugins.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ opencli plugin install https://github.com/user/repo
3737

3838
The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`.
3939

40+
## Version Tracking
41+
42+
OpenCLI records installed plugin versions in `~/.opencli/plugins.lock.json`. Each entry stores the plugin source, current git commit hash, install time, and last update time. `opencli plugin list` shows the short commit hash when version metadata is available.
43+
4044
## Creating a Plugin
4145

4246
### Option 1: YAML Plugin (Simplest)

docs/zh/guide/plugins.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ opencli plugin install https://github.com/user/repo
3737

3838
如果仓库名带 `opencli-plugin-` 前缀,本地目录会自动去掉这个前缀。例如 `opencli-plugin-hot-digest` 会变成 `hot-digest`
3939

40+
## 版本追踪
41+
42+
OpenCLI 会把已安装 plugin 的版本记录到 `~/.opencli/plugins.lock.json`。每条记录会保存 plugin source、当前 git commit hash、安装时间,以及最近一次更新时间。只要有这份元数据,`opencli plugin list` 就会显示对应的短 commit hash。
43+
4044
## YAML plugin 示例
4145

4246
```text

src/cli.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,10 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
368368
console.log(chalk.bold(' Installed plugins'));
369369
console.log();
370370
for (const p of plugins) {
371+
const version = p.version ? chalk.green(` @${p.version}`) : '';
371372
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
372373
const src = p.source ? chalk.dim(` ← ${p.source}`) : '';
373-
console.log(` ${chalk.cyan(p.name)}${cmds}${src}`);
374+
console.log(` ${chalk.cyan(p.name)}${version}${cmds}${src}`);
374375
}
375376
console.log();
376377
console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));

src/plugin.test.ts

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
/**
2-
* Tests for plugin management: install, uninstall, list.
2+
* Tests for plugin management: install, uninstall, list, and lock file support.
33
*/
44

55
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
66
import * as fs from 'node:fs';
77
import * as path from 'node:path';
88
import { PLUGINS_DIR } from './discovery.js';
9+
import type { LockEntry } from './plugin.js';
910
import * as pluginModule from './plugin.js';
1011

1112
const {
13+
LOCK_FILE,
14+
_getCommitHash,
1215
listPlugins,
16+
_readLockFile,
1317
uninstallPlugin,
1418
updatePlugin,
1519
_parseSource,
1620
_updateAllPlugins,
1721
_validatePluginStructure,
22+
_writeLockFile,
1823
} = pluginModule;
1924

2025
describe('parseSource', () => {
@@ -111,6 +116,70 @@ describe('validatePluginStructure', () => {
111116
});
112117
});
113118

119+
describe('lock file', () => {
120+
const backupPath = `${LOCK_FILE}.test-backup`;
121+
let hadOriginal = false;
122+
123+
beforeEach(() => {
124+
hadOriginal = fs.existsSync(LOCK_FILE);
125+
if (hadOriginal) {
126+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
127+
fs.copyFileSync(LOCK_FILE, backupPath);
128+
}
129+
});
130+
131+
afterEach(() => {
132+
if (hadOriginal) {
133+
fs.copyFileSync(backupPath, LOCK_FILE);
134+
fs.unlinkSync(backupPath);
135+
return;
136+
}
137+
try { fs.unlinkSync(LOCK_FILE); } catch {}
138+
});
139+
140+
it('reads empty lock when file does not exist', () => {
141+
try { fs.unlinkSync(LOCK_FILE); } catch {}
142+
expect(_readLockFile()).toEqual({});
143+
});
144+
145+
it('round-trips lock entries', () => {
146+
const entries: Record<string, LockEntry> = {
147+
'test-plugin': {
148+
source: 'https://github.com/user/repo.git',
149+
commitHash: 'abc1234567890def',
150+
installedAt: '2025-01-01T00:00:00.000Z',
151+
},
152+
'another-plugin': {
153+
source: 'https://github.com/user/another.git',
154+
commitHash: 'def4567890123abc',
155+
installedAt: '2025-02-01T00:00:00.000Z',
156+
updatedAt: '2025-03-01T00:00:00.000Z',
157+
},
158+
};
159+
160+
_writeLockFile(entries);
161+
expect(_readLockFile()).toEqual(entries);
162+
});
163+
164+
it('handles malformed lock file gracefully', () => {
165+
fs.mkdirSync(path.dirname(LOCK_FILE), { recursive: true });
166+
fs.writeFileSync(LOCK_FILE, 'not valid json');
167+
expect(_readLockFile()).toEqual({});
168+
});
169+
});
170+
171+
describe('getCommitHash', () => {
172+
it('returns a hash for a git repo', () => {
173+
const hash = _getCommitHash(process.cwd());
174+
expect(hash).toBeDefined();
175+
expect(hash).toMatch(/^[0-9a-f]{40}$/);
176+
});
177+
178+
it('returns undefined for non-git directory', () => {
179+
expect(_getCommitHash('/tmp')).toBeUndefined();
180+
});
181+
});
182+
114183
describe('listPlugins', () => {
115184
const testDir = path.join(PLUGINS_DIR, '__test-list-plugin__');
116185

@@ -128,6 +197,28 @@ describe('listPlugins', () => {
128197
expect(found!.commands).toContain('hello');
129198
});
130199

200+
it('includes version metadata from the lock file', () => {
201+
fs.mkdirSync(testDir, { recursive: true });
202+
fs.writeFileSync(path.join(testDir, 'hello.yaml'), 'site: test\nname: hello\n');
203+
204+
const lock = _readLockFile();
205+
lock['__test-list-plugin__'] = {
206+
source: 'https://github.com/user/repo.git',
207+
commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
208+
installedAt: '2025-01-01T00:00:00.000Z',
209+
};
210+
_writeLockFile(lock);
211+
212+
const plugins = listPlugins();
213+
const found = plugins.find(p => p.name === '__test-list-plugin__');
214+
expect(found).toBeDefined();
215+
expect(found!.version).toBe('abcdef1');
216+
expect(found!.installedAt).toBe('2025-01-01T00:00:00.000Z');
217+
218+
delete lock['__test-list-plugin__'];
219+
_writeLockFile(lock);
220+
});
221+
131222
it('returns empty array when no plugins dir', () => {
132223
// listPlugins should handle missing dir gracefully
133224
const plugins = listPlugins();
@@ -150,6 +241,22 @@ describe('uninstallPlugin', () => {
150241
expect(fs.existsSync(testDir)).toBe(false);
151242
});
152243

244+
it('removes lock entry on uninstall', () => {
245+
fs.mkdirSync(testDir, { recursive: true });
246+
fs.writeFileSync(path.join(testDir, 'test.yaml'), 'site: test');
247+
248+
const lock = _readLockFile();
249+
lock['__test-uninstall__'] = {
250+
source: 'https://github.com/user/repo.git',
251+
commitHash: 'abc123',
252+
installedAt: '2025-01-01T00:00:00.000Z',
253+
};
254+
_writeLockFile(lock);
255+
256+
uninstallPlugin('__test-uninstall__');
257+
expect(_readLockFile()['__test-uninstall__']).toBeUndefined();
258+
});
259+
153260
it('throws for non-existent plugin', () => {
154261
expect(() => uninstallPlugin('__nonexistent__')).toThrow('not installed');
155262
});
@@ -163,7 +270,13 @@ describe('updatePlugin', () => {
163270

164271
vi.mock('node:child_process', () => {
165272
return {
166-
execFileSync: vi.fn((_cmd, _args, opts) => {
273+
execFileSync: vi.fn((_cmd, args, opts) => {
274+
if (Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
275+
if (opts?.cwd === '/tmp') {
276+
throw new Error('not a git repository');
277+
}
278+
return '1234567890abcdef1234567890abcdef12345678\n';
279+
}
167280
if (opts && opts.cwd && String(opts.cwd).endsWith('plugin-b')) {
168281
throw new Error('Network error');
169282
}

src/plugin.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,30 @@
66
*/
77

88
import * as fs from 'node:fs';
9+
import * as os from 'node:os';
910
import * as path from 'node:path';
10-
import { execSync, execFileSync } from 'node:child_process';
11+
import { execFileSync } from 'node:child_process';
1112
import { PLUGINS_DIR } from './discovery.js';
1213
import { getErrorMessage } from './errors.js';
1314
import { log } from './logger.js';
1415

16+
/** Path to the lock file that tracks installed plugin versions. */
17+
export const LOCK_FILE = path.join(os.homedir(), '.opencli', 'plugins.lock.json');
18+
19+
export interface LockEntry {
20+
source: string;
21+
commitHash: string;
22+
installedAt: string;
23+
updatedAt?: string;
24+
}
25+
1526
export interface PluginInfo {
1627
name: string;
1728
path: string;
1829
commands: string[];
1930
source?: string;
31+
version?: string;
32+
installedAt?: string;
2033
}
2134

2235
// ── Validation helpers ──────────────────────────────────────────────────────
@@ -26,6 +39,35 @@ export interface ValidationResult {
2639
errors: string[];
2740
}
2841

42+
// ── Lock file helpers ───────────────────────────────────────────────────────
43+
44+
export function readLockFile(): Record<string, LockEntry> {
45+
try {
46+
const raw = fs.readFileSync(LOCK_FILE, 'utf-8');
47+
return JSON.parse(raw) as Record<string, LockEntry>;
48+
} catch {
49+
return {};
50+
}
51+
}
52+
53+
export function writeLockFile(lock: Record<string, LockEntry>): void {
54+
fs.mkdirSync(path.dirname(LOCK_FILE), { recursive: true });
55+
fs.writeFileSync(LOCK_FILE, JSON.stringify(lock, null, 2) + '\n');
56+
}
57+
58+
/** Get the HEAD commit hash of a git repo directory. */
59+
export function getCommitHash(dir: string): string | undefined {
60+
try {
61+
return execFileSync('git', ['rev-parse', 'HEAD'], {
62+
cwd: dir,
63+
encoding: 'utf-8',
64+
stdio: ['pipe', 'pipe', 'pipe'],
65+
}).trim();
66+
} catch {
67+
return undefined;
68+
}
69+
}
70+
2971
/**
3072
* Validate that a downloaded plugin directory is a structurally valid plugin.
3173
* Checks for at least one command file (.yaml, .yml, .ts, .js) and a valid
@@ -134,6 +176,18 @@ export function installPlugin(source: string): string {
134176
}
135177

136178
postInstallLifecycle(targetDir);
179+
180+
const commitHash = getCommitHash(targetDir);
181+
if (commitHash) {
182+
const lock = readLockFile();
183+
lock[name] = {
184+
source: cloneUrl,
185+
commitHash,
186+
installedAt: new Date().toISOString(),
187+
};
188+
writeLockFile(lock);
189+
}
190+
137191
return name;
138192
}
139193

@@ -146,6 +200,12 @@ export function uninstallPlugin(name: string): void {
146200
throw new Error(`Plugin "${name}" is not installed.`);
147201
}
148202
fs.rmSync(targetDir, { recursive: true, force: true });
203+
204+
const lock = readLockFile();
205+
if (lock[name]) {
206+
delete lock[name];
207+
writeLockFile(lock);
208+
}
149209
}
150210

151211
/**
@@ -173,6 +233,19 @@ export function updatePlugin(name: string): void {
173233
}
174234

175235
postInstallLifecycle(targetDir);
236+
237+
const commitHash = getCommitHash(targetDir);
238+
if (commitHash) {
239+
const lock = readLockFile();
240+
const existing = lock[name];
241+
lock[name] = {
242+
source: existing?.source ?? getPluginSource(targetDir) ?? '',
243+
commitHash,
244+
installedAt: existing?.installedAt ?? new Date().toISOString(),
245+
updatedAt: new Date().toISOString(),
246+
};
247+
writeLockFile(lock);
248+
}
176249
}
177250

178251
export interface UpdateResult {
@@ -207,19 +280,23 @@ export function listPlugins(): PluginInfo[] {
207280
if (!fs.existsSync(PLUGINS_DIR)) return [];
208281

209282
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
283+
const lock = readLockFile();
210284
const plugins: PluginInfo[] = [];
211285

212286
for (const entry of entries) {
213287
if (!entry.isDirectory()) continue;
214288
const pluginDir = path.join(PLUGINS_DIR, entry.name);
215289
const commands = scanPluginCommands(pluginDir);
216290
const source = getPluginSource(pluginDir);
291+
const lockEntry = lock[entry.name];
217292

218293
plugins.push({
219294
name: entry.name,
220295
path: pluginDir,
221296
commands,
222297
source,
298+
version: lockEntry?.commitHash?.slice(0, 7),
299+
installedAt: lockEntry?.installedAt,
223300
});
224301
}
225302

@@ -361,7 +438,10 @@ function transpilePluginTs(pluginDir: string): void {
361438
}
362439

363440
export {
441+
getCommitHash as _getCommitHash,
364442
parseSource as _parseSource,
443+
readLockFile as _readLockFile,
365444
updateAllPlugins as _updateAllPlugins,
366445
validatePluginStructure as _validatePluginStructure,
446+
writeLockFile as _writeLockFile,
367447
};

0 commit comments

Comments
 (0)