@jackwener/opencli 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/page.d.ts +3 -0
  3. package/dist/browser/page.js +24 -1
  4. package/dist/cli-manifest.json +465 -5
  5. package/dist/cli.js +34 -3
  6. package/dist/clis/bluesky/feeds.yaml +29 -0
  7. package/dist/clis/bluesky/followers.yaml +33 -0
  8. package/dist/clis/bluesky/following.yaml +33 -0
  9. package/dist/clis/bluesky/profile.yaml +27 -0
  10. package/dist/clis/bluesky/search.yaml +34 -0
  11. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  12. package/dist/clis/bluesky/thread.yaml +32 -0
  13. package/dist/clis/bluesky/trending.yaml +27 -0
  14. package/dist/clis/bluesky/user.yaml +34 -0
  15. package/dist/clis/twitter/trending.js +29 -61
  16. package/dist/clis/v2ex/hot.yaml +17 -3
  17. package/dist/clis/xiaohongshu/publish.js +78 -42
  18. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  19. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  20. package/dist/clis/xiaohongshu/search.js +20 -1
  21. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  22. package/dist/clis/xiaohongshu/search.test.js +32 -1
  23. package/dist/discovery.js +40 -28
  24. package/dist/doctor.d.ts +1 -2
  25. package/dist/doctor.js +2 -2
  26. package/dist/engine.test.js +42 -0
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +2 -2
  29. package/dist/execution.js +45 -7
  30. package/dist/execution.test.d.ts +1 -0
  31. package/dist/execution.test.js +40 -0
  32. package/dist/external.js +6 -1
  33. package/dist/main.js +1 -0
  34. package/dist/plugin-scaffold.d.ts +28 -0
  35. package/dist/plugin-scaffold.js +142 -0
  36. package/dist/plugin-scaffold.test.d.ts +4 -0
  37. package/dist/plugin-scaffold.test.js +83 -0
  38. package/dist/plugin.d.ts +55 -17
  39. package/dist/plugin.js +706 -154
  40. package/dist/plugin.test.js +836 -38
  41. package/dist/runtime.d.ts +1 -0
  42. package/dist/runtime.js +1 -1
  43. package/dist/types.d.ts +2 -0
  44. package/docs/adapters/browser/bluesky.md +53 -0
  45. package/docs/guide/plugins.md +10 -0
  46. package/package.json +1 -1
  47. package/src/browser/cdp.ts +6 -0
  48. package/src/browser/page.ts +24 -1
  49. package/src/cli.ts +34 -3
  50. package/src/clis/bluesky/feeds.yaml +29 -0
  51. package/src/clis/bluesky/followers.yaml +33 -0
  52. package/src/clis/bluesky/following.yaml +33 -0
  53. package/src/clis/bluesky/profile.yaml +27 -0
  54. package/src/clis/bluesky/search.yaml +34 -0
  55. package/src/clis/bluesky/starter-packs.yaml +34 -0
  56. package/src/clis/bluesky/thread.yaml +32 -0
  57. package/src/clis/bluesky/trending.yaml +27 -0
  58. package/src/clis/bluesky/user.yaml +34 -0
  59. package/src/clis/twitter/trending.ts +29 -77
  60. package/src/clis/v2ex/hot.yaml +17 -3
  61. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  62. package/src/clis/xiaohongshu/publish.ts +93 -52
  63. package/src/clis/xiaohongshu/search.test.ts +39 -1
  64. package/src/clis/xiaohongshu/search.ts +19 -1
  65. package/src/discovery.ts +41 -33
  66. package/src/doctor.ts +2 -3
  67. package/src/engine.test.ts +38 -0
  68. package/src/errors.ts +6 -2
  69. package/src/execution.test.ts +47 -0
  70. package/src/execution.ts +39 -6
  71. package/src/external.ts +6 -1
  72. package/src/main.ts +1 -0
  73. package/src/plugin-scaffold.test.ts +98 -0
  74. package/src/plugin-scaffold.ts +170 -0
  75. package/src/plugin.test.ts +881 -38
  76. package/src/plugin.ts +871 -158
  77. package/src/runtime.ts +2 -2
  78. package/src/types.ts +2 -0
  79. package/tests/e2e/browser-public.test.ts +1 -1
@@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6
6
  import * as fs from 'node:fs';
7
7
  import * as os from 'node:os';
8
8
  import * as path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
9
10
  import { PLUGINS_DIR } from './discovery.js';
10
11
  import type { LockEntry } from './plugin.js';
11
12
  import * as pluginModule from './plugin.js';
@@ -16,12 +17,15 @@ const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({
16
17
  }));
17
18
 
18
19
  const {
19
- LOCK_FILE,
20
20
  _getCommitHash,
21
21
  _installDependencies,
22
22
  _postInstallMonorepoLifecycle,
23
+ _promoteDir,
24
+ _replaceDir,
25
+ installPlugin,
23
26
  listPlugins,
24
27
  _readLockFile,
28
+ _readLockFileWithWriter,
25
29
  _resolveEsbuildBin,
26
30
  uninstallPlugin,
27
31
  updatePlugin,
@@ -29,14 +33,24 @@ const {
29
33
  _updateAllPlugins,
30
34
  _validatePluginStructure,
31
35
  _writeLockFile,
36
+ _writeLockFileWithFs,
32
37
  _isSymlinkSync,
33
38
  _getMonoreposDir,
39
+ getLockFilePath,
40
+ _installLocalPlugin,
41
+ _isLocalPluginSource,
42
+ _moveDir,
43
+ _resolvePluginSource,
44
+ _resolveStoredPluginSource,
45
+ _toStoredPluginSource,
46
+ _toLocalPluginSource,
34
47
  } = pluginModule;
35
48
 
36
49
  describe('parseSource', () => {
37
50
  it('parses github:user/repo format', () => {
38
51
  const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
39
52
  expect(result).toEqual({
53
+ type: 'git',
40
54
  cloneUrl: 'https://github.com/ByteYue/opencli-plugin-github-trending.git',
41
55
  name: 'github-trending',
42
56
  });
@@ -45,6 +59,7 @@ describe('parseSource', () => {
45
59
  it('parses https URL format', () => {
46
60
  const result = _parseSource('https://github.com/ByteYue/opencli-plugin-hot-digest');
47
61
  expect(result).toEqual({
62
+ type: 'git',
48
63
  cloneUrl: 'https://github.com/ByteYue/opencli-plugin-hot-digest.git',
49
64
  name: 'hot-digest',
50
65
  });
@@ -64,6 +79,98 @@ describe('parseSource', () => {
64
79
  expect(_parseSource('invalid')).toBeNull();
65
80
  expect(_parseSource('npm:some-package')).toBeNull();
66
81
  });
82
+
83
+ it('parses file:// local plugin directories', () => {
84
+ const localDir = path.join(os.tmpdir(), 'opencli-plugin-test');
85
+ const fileUrl = pathToFileURL(localDir).href;
86
+ const result = _parseSource(fileUrl);
87
+ expect(result).toEqual({
88
+ type: 'local',
89
+ localPath: localDir,
90
+ name: 'test',
91
+ });
92
+ });
93
+
94
+ it('parses plain absolute local plugin directories', () => {
95
+ const localDir = path.join(os.tmpdir(), 'my-plugin');
96
+ const result = _parseSource(localDir);
97
+ expect(result).toEqual({
98
+ type: 'local',
99
+ localPath: localDir,
100
+ name: 'my-plugin',
101
+ });
102
+ });
103
+
104
+ it('strips opencli-plugin- prefix for local paths', () => {
105
+ const localDir = path.join(os.tmpdir(), 'opencli-plugin-foo');
106
+ const result = _parseSource(localDir);
107
+ expect(result!.name).toBe('foo');
108
+ });
109
+
110
+ // ── Generic git URL support ──
111
+ it('parses ssh:// URLs', () => {
112
+ const result = _parseSource('ssh://git@gitlab.com/team/opencli-plugin-tools.git');
113
+ expect(result).toEqual({
114
+ type: 'git',
115
+ cloneUrl: 'ssh://git@gitlab.com/team/opencli-plugin-tools.git',
116
+ name: 'tools',
117
+ });
118
+ });
119
+
120
+ it('parses ssh:// URLs without .git suffix', () => {
121
+ const result = _parseSource('ssh://git@gitlab.com/team/my-plugin');
122
+ expect(result).toEqual({
123
+ type: 'git',
124
+ cloneUrl: 'ssh://git@gitlab.com/team/my-plugin',
125
+ name: 'my-plugin',
126
+ });
127
+ });
128
+
129
+ it('parses git@ SCP-style URLs', () => {
130
+ const result = _parseSource('git@gitlab.com:team/my-plugin.git');
131
+ expect(result).toEqual({
132
+ type: 'git',
133
+ cloneUrl: 'git@gitlab.com:team/my-plugin.git',
134
+ name: 'my-plugin',
135
+ });
136
+ });
137
+
138
+ it('parses git@ SCP-style URLs and strips opencli-plugin- prefix', () => {
139
+ const result = _parseSource('git@github.com:user/opencli-plugin-awesome.git');
140
+ expect(result).toEqual({
141
+ type: 'git',
142
+ cloneUrl: 'git@github.com:user/opencli-plugin-awesome.git',
143
+ name: 'awesome',
144
+ });
145
+ });
146
+
147
+ it('parses generic HTTPS git URLs (non-GitHub)', () => {
148
+ const result = _parseSource('https://codehub.example.com/Team/App/opencli-plugins-app.git');
149
+ expect(result).toEqual({
150
+ type: 'git',
151
+ cloneUrl: 'https://codehub.example.com/Team/App/opencli-plugins-app.git',
152
+ name: 'opencli-plugins-app',
153
+ });
154
+ });
155
+
156
+ it('parses generic HTTPS git URLs without .git suffix', () => {
157
+ const result = _parseSource('https://gitlab.example.com/org/my-plugin');
158
+ expect(result).toEqual({
159
+ type: 'git',
160
+ cloneUrl: 'https://gitlab.example.com/org/my-plugin.git',
161
+ name: 'my-plugin',
162
+ });
163
+ });
164
+
165
+ it('still prefers GitHub shorthand over generic HTTPS for github.com', () => {
166
+ const result = _parseSource('https://github.com/user/repo');
167
+ // Should be handled by the GitHub-specific matcher (normalizes URL)
168
+ expect(result).toEqual({
169
+ type: 'git',
170
+ cloneUrl: 'https://github.com/user/repo.git',
171
+ name: 'repo',
172
+ });
173
+ });
67
174
  });
68
175
 
69
176
  describe('validatePluginStructure', () => {
@@ -128,40 +235,40 @@ describe('validatePluginStructure', () => {
128
235
  });
129
236
 
130
237
  describe('lock file', () => {
131
- const backupPath = `${LOCK_FILE}.test-backup`;
238
+ const backupPath = `${getLockFilePath()}.test-backup`;
132
239
  let hadOriginal = false;
133
240
 
134
241
  beforeEach(() => {
135
- hadOriginal = fs.existsSync(LOCK_FILE);
242
+ hadOriginal = fs.existsSync(getLockFilePath());
136
243
  if (hadOriginal) {
137
244
  fs.mkdirSync(path.dirname(backupPath), { recursive: true });
138
- fs.copyFileSync(LOCK_FILE, backupPath);
245
+ fs.copyFileSync(getLockFilePath(), backupPath);
139
246
  }
140
247
  });
141
248
 
142
249
  afterEach(() => {
143
250
  if (hadOriginal) {
144
- fs.copyFileSync(backupPath, LOCK_FILE);
251
+ fs.copyFileSync(backupPath, getLockFilePath());
145
252
  fs.unlinkSync(backupPath);
146
253
  return;
147
254
  }
148
- try { fs.unlinkSync(LOCK_FILE); } catch {}
255
+ try { fs.unlinkSync(getLockFilePath()); } catch {}
149
256
  });
150
257
 
151
258
  it('reads empty lock when file does not exist', () => {
152
- try { fs.unlinkSync(LOCK_FILE); } catch {}
259
+ try { fs.unlinkSync(getLockFilePath()); } catch {}
153
260
  expect(_readLockFile()).toEqual({});
154
261
  });
155
262
 
156
263
  it('round-trips lock entries', () => {
157
264
  const entries: Record<string, LockEntry> = {
158
265
  'test-plugin': {
159
- source: 'https://github.com/user/repo.git',
266
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
160
267
  commitHash: 'abc1234567890def',
161
268
  installedAt: '2025-01-01T00:00:00.000Z',
162
269
  },
163
270
  'another-plugin': {
164
- source: 'https://github.com/user/another.git',
271
+ source: { kind: 'git', url: 'https://github.com/user/another.git' },
165
272
  commitHash: 'def4567890123abc',
166
273
  installedAt: '2025-02-01T00:00:00.000Z',
167
274
  updatedAt: '2025-03-01T00:00:00.000Z',
@@ -173,10 +280,105 @@ describe('lock file', () => {
173
280
  });
174
281
 
175
282
  it('handles malformed lock file gracefully', () => {
176
- fs.mkdirSync(path.dirname(LOCK_FILE), { recursive: true });
177
- fs.writeFileSync(LOCK_FILE, 'not valid json');
283
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
284
+ fs.writeFileSync(getLockFilePath(), 'not valid json');
178
285
  expect(_readLockFile()).toEqual({});
179
286
  });
287
+
288
+ it('keeps the previous lockfile contents when atomic rewrite fails', () => {
289
+ const existing: Record<string, LockEntry> = {
290
+ stable: {
291
+ source: { kind: 'git', url: 'https://github.com/user/stable.git' },
292
+ commitHash: 'stable1234567890',
293
+ installedAt: '2025-01-01T00:00:00.000Z',
294
+ },
295
+ };
296
+ _writeLockFile(existing);
297
+
298
+ const renameSync = vi.fn(() => {
299
+ throw new Error('rename failed');
300
+ });
301
+ const rmSync = vi.fn(() => undefined);
302
+
303
+ expect(() => _writeLockFileWithFs({
304
+ broken: {
305
+ source: { kind: 'git', url: 'https://github.com/user/broken.git' },
306
+ commitHash: 'broken1234567890',
307
+ installedAt: '2025-02-01T00:00:00.000Z',
308
+ },
309
+ }, {
310
+ mkdirSync: fs.mkdirSync,
311
+ writeFileSync: fs.writeFileSync,
312
+ renameSync,
313
+ rmSync,
314
+ })).toThrow('rename failed');
315
+
316
+ expect(_readLockFile()).toEqual(existing);
317
+ expect(rmSync).toHaveBeenCalledTimes(1);
318
+ });
319
+
320
+ it('migrates legacy string sources to structured sources on read', () => {
321
+ const legacyLocalPath = path.resolve(path.join(os.tmpdir(), 'opencli-legacy-local-plugin'));
322
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
323
+ fs.writeFileSync(getLockFilePath(), JSON.stringify({
324
+ alpha: {
325
+ source: 'https://github.com/user/opencli-plugins.git',
326
+ commitHash: 'abc1234567890def',
327
+ installedAt: '2025-01-01T00:00:00.000Z',
328
+ monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
329
+ },
330
+ beta: {
331
+ source: `local:${legacyLocalPath}`,
332
+ commitHash: 'local',
333
+ installedAt: '2025-01-01T00:00:00.000Z',
334
+ },
335
+ }, null, 2));
336
+
337
+ expect(_readLockFile()).toEqual({
338
+ alpha: {
339
+ source: {
340
+ kind: 'monorepo',
341
+ url: 'https://github.com/user/opencli-plugins.git',
342
+ repoName: 'opencli-plugins',
343
+ subPath: 'packages/alpha',
344
+ },
345
+ commitHash: 'abc1234567890def',
346
+ installedAt: '2025-01-01T00:00:00.000Z',
347
+ },
348
+ beta: {
349
+ source: { kind: 'local', path: legacyLocalPath },
350
+ commitHash: 'local',
351
+ installedAt: '2025-01-01T00:00:00.000Z',
352
+ },
353
+ });
354
+ });
355
+
356
+ it('returns normalized entries even when migration rewrite fails', () => {
357
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
358
+ fs.writeFileSync(getLockFilePath(), JSON.stringify({
359
+ alpha: {
360
+ source: 'https://github.com/user/opencli-plugins.git',
361
+ commitHash: 'abc1234567890def',
362
+ installedAt: '2025-01-01T00:00:00.000Z',
363
+ monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
364
+ },
365
+ }, null, 2));
366
+
367
+ expect(_readLockFileWithWriter(() => {
368
+ throw new Error('disk full');
369
+ })).toEqual({
370
+ alpha: {
371
+ source: {
372
+ kind: 'monorepo',
373
+ url: 'https://github.com/user/opencli-plugins.git',
374
+ repoName: 'opencli-plugins',
375
+ subPath: 'packages/alpha',
376
+ },
377
+ commitHash: 'abc1234567890def',
378
+ installedAt: '2025-01-01T00:00:00.000Z',
379
+ },
380
+ });
381
+ });
180
382
  });
181
383
 
182
384
  describe('getCommitHash', () => {
@@ -197,7 +399,6 @@ describe('resolveEsbuildBin', () => {
197
399
  expect(binPath).not.toBeNull();
198
400
  expect(typeof binPath).toBe('string');
199
401
  expect(fs.existsSync(binPath!)).toBe(true);
200
- // On Windows the resolved path ends with 'esbuild.cmd', on Unix 'esbuild'
201
402
  expect(binPath).toMatch(/esbuild(\.cmd)?$/);
202
403
  });
203
404
  });
@@ -225,7 +426,7 @@ describe('listPlugins', () => {
225
426
 
226
427
  const lock = _readLockFile();
227
428
  lock['__test-list-plugin__'] = {
228
- source: 'https://github.com/user/repo.git',
429
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
229
430
  commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
230
431
  installedAt: '2025-01-01T00:00:00.000Z',
231
432
  };
@@ -242,10 +443,37 @@ describe('listPlugins', () => {
242
443
  });
243
444
 
244
445
  it('returns empty array when no plugins dir', () => {
245
- // listPlugins should handle missing dir gracefully
246
446
  const plugins = listPlugins();
247
447
  expect(Array.isArray(plugins)).toBe(true);
248
448
  });
449
+
450
+ it('prefers lockfile source for local symlink plugins', () => {
451
+ const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-list-'));
452
+ const linkPath = path.join(PLUGINS_DIR, '__test-list-plugin__');
453
+
454
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
455
+ fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
456
+ try { fs.unlinkSync(linkPath); } catch {}
457
+ try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch {}
458
+ fs.symlinkSync(localTarget, linkPath, 'dir');
459
+
460
+ const lock = _readLockFile();
461
+ lock['__test-list-plugin__'] = {
462
+ source: { kind: 'local', path: localTarget },
463
+ commitHash: 'local',
464
+ installedAt: '2025-01-01T00:00:00.000Z',
465
+ };
466
+ _writeLockFile(lock);
467
+
468
+ const plugins = listPlugins();
469
+ const found = plugins.find(p => p.name === '__test-list-plugin__');
470
+ expect(found?.source).toBe(`local:${localTarget}`);
471
+
472
+ try { fs.unlinkSync(linkPath); } catch {}
473
+ try { fs.rmSync(localTarget, { recursive: true, force: true }); } catch {}
474
+ delete lock['__test-list-plugin__'];
475
+ _writeLockFile(lock);
476
+ });
249
477
  });
250
478
 
251
479
  describe('uninstallPlugin', () => {
@@ -269,7 +497,7 @@ describe('uninstallPlugin', () => {
269
497
 
270
498
  const lock = _readLockFile();
271
499
  lock['__test-uninstall__'] = {
272
- source: 'https://github.com/user/repo.git',
500
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
273
501
  commitHash: 'abc123',
274
502
  installedAt: '2025-01-01T00:00:00.000Z',
275
503
  };
@@ -288,6 +516,45 @@ describe('updatePlugin', () => {
288
516
  it('throws for non-existent plugin', () => {
289
517
  expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
290
518
  });
519
+
520
+ it('refreshes local plugins without running git pull', () => {
521
+ const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-update-'));
522
+ const linkPath = path.join(PLUGINS_DIR, '__test-local-update__');
523
+
524
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
525
+ fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
526
+ fs.symlinkSync(localTarget, linkPath, 'dir');
527
+
528
+ const lock = _readLockFile();
529
+ lock['__test-local-update__'] = {
530
+ source: { kind: 'local', path: localTarget },
531
+ commitHash: 'local',
532
+ installedAt: '2025-01-01T00:00:00.000Z',
533
+ };
534
+ _writeLockFile(lock);
535
+
536
+ mockExecFileSync.mockClear();
537
+ updatePlugin('__test-local-update__');
538
+
539
+ expect(
540
+ mockExecFileSync.mock.calls.some(
541
+ ([cmd, args, opts]) => cmd === 'git'
542
+ && Array.isArray(args)
543
+ && args[0] === 'pull'
544
+ && opts?.cwd === linkPath,
545
+ ),
546
+ ).toBe(false);
547
+
548
+ const updated = _readLockFile()['__test-local-update__'];
549
+ expect(updated?.source).toEqual({ kind: 'local', path: path.resolve(localTarget) });
550
+ expect(updated?.updatedAt).toBeDefined();
551
+
552
+ try { fs.unlinkSync(linkPath); } catch {}
553
+ try { fs.rmSync(localTarget, { recursive: true, force: true }); } catch {}
554
+ const finalLock = _readLockFile();
555
+ delete finalLock['__test-local-update__'];
556
+ _writeLockFile(finalLock);
557
+ });
291
558
  });
292
559
 
293
560
  vi.mock('node:child_process', () => {
@@ -373,16 +640,59 @@ describe('updateAllPlugins', () => {
373
640
  fs.writeFileSync(path.join(testDirA, 'cmd.yaml'), 'site: a');
374
641
  fs.writeFileSync(path.join(testDirB, 'cmd.yaml'), 'site: b');
375
642
  fs.writeFileSync(path.join(testDirC, 'cmd.yaml'), 'site: c');
643
+
644
+ const lock = _readLockFile();
645
+ lock['plugin-a'] = {
646
+ source: { kind: 'git', url: 'https://github.com/user/plugin-a.git' },
647
+ commitHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
648
+ installedAt: '2025-01-01T00:00:00.000Z',
649
+ };
650
+ lock['plugin-b'] = {
651
+ source: { kind: 'git', url: 'https://github.com/user/plugin-b.git' },
652
+ commitHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
653
+ installedAt: '2025-01-01T00:00:00.000Z',
654
+ };
655
+ lock['plugin-c'] = {
656
+ source: { kind: 'git', url: 'https://github.com/user/plugin-c.git' },
657
+ commitHash: 'cccccccccccccccccccccccccccccccccccccccc',
658
+ installedAt: '2025-01-01T00:00:00.000Z',
659
+ };
660
+ _writeLockFile(lock);
376
661
  });
377
662
 
378
663
  afterEach(() => {
379
664
  try { fs.rmSync(testDirA, { recursive: true }); } catch {}
380
665
  try { fs.rmSync(testDirB, { recursive: true }); } catch {}
381
666
  try { fs.rmSync(testDirC, { recursive: true }); } catch {}
667
+ const lock = _readLockFile();
668
+ delete lock['plugin-a'];
669
+ delete lock['plugin-b'];
670
+ delete lock['plugin-c'];
671
+ _writeLockFile(lock);
382
672
  vi.clearAllMocks();
383
673
  });
384
674
 
385
675
  it('collects successes and failures without throwing', () => {
676
+ mockExecFileSync.mockImplementation((cmd, args) => {
677
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
678
+ const cloneUrl = String(args[3]);
679
+ const cloneDir = String(args[4]);
680
+ fs.mkdirSync(cloneDir, { recursive: true });
681
+ fs.writeFileSync(path.join(cloneDir, 'cmd.yaml'), 'site: test\nname: hello\n');
682
+ if (cloneUrl.includes('plugin-b')) {
683
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: 'plugin-b' }));
684
+ }
685
+ return '';
686
+ }
687
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
688
+ throw new Error('Network error');
689
+ }
690
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
691
+ return '1234567890abcdef1234567890abcdef12345678\n';
692
+ }
693
+ return '';
694
+ });
695
+
386
696
  const results = _updateAllPlugins();
387
697
 
388
698
  const resA = results.find(r => r.name === 'plugin-a');
@@ -407,6 +717,7 @@ describe('parseSource with monorepo subplugin', () => {
407
717
  it('parses github:user/repo/subplugin format', () => {
408
718
  const result = _parseSource('github:ByteYue/opencli-plugins/polymarket');
409
719
  expect(result).toEqual({
720
+ type: 'git',
410
721
  cloneUrl: 'https://github.com/ByteYue/opencli-plugins.git',
411
722
  name: 'opencli-plugins',
412
723
  subPlugin: 'polymarket',
@@ -422,6 +733,7 @@ describe('parseSource with monorepo subplugin', () => {
422
733
  it('still parses github:user/repo without subplugin', () => {
423
734
  const result = _parseSource('github:user/my-repo');
424
735
  expect(result).toEqual({
736
+ type: 'git',
425
737
  cloneUrl: 'https://github.com/user/my-repo.git',
426
738
  name: 'my-repo',
427
739
  });
@@ -466,26 +778,26 @@ describe('monorepo uninstall with symlink', () => {
466
778
 
467
779
  beforeEach(() => {
468
780
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-mono-uninstall-'));
469
- // We need to use the real PLUGINS_DIR for uninstallPlugin() to work
470
781
  pluginDir = path.join(PLUGINS_DIR, '__test-mono-sub__');
471
782
  monoDir = path.join(_getMonoreposDir(), '__test-mono__');
472
783
 
473
- // Set up monorepo structure
474
784
  const subDir = path.join(monoDir, 'packages', 'sub');
475
785
  fs.mkdirSync(subDir, { recursive: true });
476
786
  fs.writeFileSync(path.join(subDir, 'cmd.yaml'), 'site: test');
477
787
 
478
- // Create symlink in plugins dir
479
788
  fs.mkdirSync(PLUGINS_DIR, { recursive: true });
480
789
  fs.symlinkSync(subDir, pluginDir, 'dir');
481
790
 
482
- // Set up lock file with monorepo entry
483
791
  const lock = _readLockFile();
484
792
  lock['__test-mono-sub__'] = {
485
- source: 'https://github.com/user/test.git',
793
+ source: {
794
+ kind: 'monorepo',
795
+ url: 'https://github.com/user/test.git',
796
+ repoName: '__test-mono__',
797
+ subPath: 'packages/sub',
798
+ },
486
799
  commitHash: 'abc123',
487
800
  installedAt: '2025-01-01T00:00:00.000Z',
488
- monorepo: { name: '__test-mono__', subPath: 'packages/sub' },
489
801
  };
490
802
  _writeLockFile(lock);
491
803
  });
@@ -494,35 +806,32 @@ describe('monorepo uninstall with symlink', () => {
494
806
  try { fs.unlinkSync(pluginDir); } catch {}
495
807
  try { fs.rmSync(pluginDir, { recursive: true, force: true }); } catch {}
496
808
  try { fs.rmSync(monoDir, { recursive: true, force: true }); } catch {}
497
- // Clean up lock entry
498
809
  const lock = _readLockFile();
499
810
  delete lock['__test-mono-sub__'];
500
811
  _writeLockFile(lock);
501
812
  });
502
813
 
503
814
  it('removes symlink but keeps monorepo if other sub-plugins reference it', () => {
504
- // Add another sub-plugin referencing the same monorepo
505
815
  const lock = _readLockFile();
506
816
  lock['__test-mono-other__'] = {
507
- source: 'https://github.com/user/test.git',
817
+ source: {
818
+ kind: 'monorepo',
819
+ url: 'https://github.com/user/test.git',
820
+ repoName: '__test-mono__',
821
+ subPath: 'packages/other',
822
+ },
508
823
  commitHash: 'abc123',
509
824
  installedAt: '2025-01-01T00:00:00.000Z',
510
- monorepo: { name: '__test-mono__', subPath: 'packages/other' },
511
825
  };
512
826
  _writeLockFile(lock);
513
827
 
514
828
  uninstallPlugin('__test-mono-sub__');
515
829
 
516
- // Symlink removed
517
830
  expect(fs.existsSync(pluginDir)).toBe(false);
518
- // Monorepo dir still exists (other sub-plugin references it)
519
831
  expect(fs.existsSync(monoDir)).toBe(true);
520
- // Lock entry removed
521
832
  expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
522
- // Other lock entry still present
523
833
  expect(_readLockFile()['__test-mono-other__']).toBeDefined();
524
834
 
525
- // Clean up the other entry
526
835
  const finalLock = _readLockFile();
527
836
  delete finalLock['__test-mono-other__'];
528
837
  _writeLockFile(finalLock);
@@ -531,11 +840,8 @@ describe('monorepo uninstall with symlink', () => {
531
840
  it('removes symlink AND monorepo dir when last sub-plugin is uninstalled', () => {
532
841
  uninstallPlugin('__test-mono-sub__');
533
842
 
534
- // Symlink removed
535
843
  expect(fs.existsSync(pluginDir)).toBe(false);
536
- // Monorepo dir also removed (no more references)
537
844
  expect(fs.existsSync(monoDir)).toBe(false);
538
- // Lock entry removed
539
845
  expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
540
846
  });
541
847
  });
@@ -545,22 +851,23 @@ describe('listPlugins with monorepo metadata', () => {
545
851
  const testLink = path.join(PLUGINS_DIR, '__test-mono-list__');
546
852
 
547
853
  beforeEach(() => {
548
- // Create a target dir with a command file
549
854
  fs.mkdirSync(testSymlinkTarget, { recursive: true });
550
855
  fs.writeFileSync(path.join(testSymlinkTarget, 'hello.yaml'), 'site: test\nname: hello\n');
551
856
 
552
- // Create symlink
553
857
  fs.mkdirSync(PLUGINS_DIR, { recursive: true });
554
858
  try { fs.unlinkSync(testLink); } catch {}
555
859
  fs.symlinkSync(testSymlinkTarget, testLink, 'dir');
556
860
 
557
- // Set up lock file with monorepo entry
558
861
  const lock = _readLockFile();
559
862
  lock['__test-mono-list__'] = {
560
- source: 'https://github.com/user/test-mono.git',
863
+ source: {
864
+ kind: 'monorepo',
865
+ url: 'https://github.com/user/test-mono.git',
866
+ repoName: 'test-mono',
867
+ subPath: 'packages/list',
868
+ },
561
869
  commitHash: 'def456def456def456def456def456def456def4',
562
870
  installedAt: '2025-01-01T00:00:00.000Z',
563
- monorepo: { name: 'test-mono', subPath: 'packages/list' },
564
871
  };
565
872
  _writeLockFile(lock);
566
873
  });
@@ -582,3 +889,539 @@ describe('listPlugins with monorepo metadata', () => {
582
889
  expect(found!.source).toBe('https://github.com/user/test-mono.git');
583
890
  });
584
891
  });
892
+
893
+ describe('installLocalPlugin', () => {
894
+ let tmpDir: string;
895
+ const pluginName = '__test-local-plugin__';
896
+
897
+ beforeEach(() => {
898
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-install-'));
899
+ fs.writeFileSync(path.join(tmpDir, 'hello.yaml'), 'site: test\nname: hello\n');
900
+ });
901
+
902
+ afterEach(() => {
903
+ const linkPath = path.join(PLUGINS_DIR, pluginName);
904
+ try { fs.unlinkSync(linkPath); } catch {}
905
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
906
+ const lock = _readLockFile();
907
+ delete lock[pluginName];
908
+ _writeLockFile(lock);
909
+ });
910
+
911
+ it('creates a symlink to the local directory', () => {
912
+ const result = _installLocalPlugin(tmpDir, pluginName);
913
+ expect(result).toBe(pluginName);
914
+ const linkPath = path.join(PLUGINS_DIR, pluginName);
915
+ expect(fs.existsSync(linkPath)).toBe(true);
916
+ expect(_isSymlinkSync(linkPath)).toBe(true);
917
+ });
918
+
919
+ it('records local: source in lockfile', () => {
920
+ _installLocalPlugin(tmpDir, pluginName);
921
+ const lock = _readLockFile();
922
+ expect(lock[pluginName]).toBeDefined();
923
+ expect(lock[pluginName].source).toEqual({ kind: 'local', path: path.resolve(tmpDir) });
924
+ });
925
+
926
+ it('lists the recorded local source', () => {
927
+ _installLocalPlugin(tmpDir, pluginName);
928
+ const plugins = listPlugins();
929
+ const found = plugins.find(p => p.name === pluginName);
930
+ expect(found).toBeDefined();
931
+ expect(found!.source).toBe(`local:${path.resolve(tmpDir)}`);
932
+ });
933
+
934
+ it('throws for non-existent path', () => {
935
+ expect(() => _installLocalPlugin('/does/not/exist', 'x')).toThrow('does not exist');
936
+ });
937
+ });
938
+
939
+ describe('isLocalPluginSource', () => {
940
+ it('detects lockfile local sources', () => {
941
+ expect(_isLocalPluginSource('local:/tmp/plugin')).toBe(true);
942
+ expect(_isLocalPluginSource('https://github.com/user/repo.git')).toBe(false);
943
+ expect(_isLocalPluginSource(undefined)).toBe(false);
944
+ });
945
+ });
946
+
947
+ describe('plugin source helpers', () => {
948
+ it('formats local plugin sources consistently', () => {
949
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
950
+ expect(_toLocalPluginSource(dir)).toBe(`local:${path.resolve(dir)}`);
951
+ });
952
+
953
+ it('serializes structured local sources consistently', () => {
954
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
955
+ expect(_toStoredPluginSource({ kind: 'local', path: dir })).toBe(`local:${path.resolve(dir)}`);
956
+ });
957
+
958
+ it('prefers lockfile source over git remote lookup', () => {
959
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
960
+ const localPath = path.resolve(path.join(os.tmpdir(), 'opencli-plugin-source-local'));
961
+ const source = _resolveStoredPluginSource({
962
+ source: { kind: 'local', path: localPath },
963
+ commitHash: 'local',
964
+ installedAt: '2025-01-01T00:00:00.000Z',
965
+ }, dir);
966
+ expect(source).toBe(`local:${localPath}`);
967
+ });
968
+
969
+ it('returns structured monorepo sources unchanged', () => {
970
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
971
+ const source = _resolvePluginSource({
972
+ source: {
973
+ kind: 'monorepo',
974
+ url: 'https://github.com/user/opencli-plugins.git',
975
+ repoName: 'opencli-plugins',
976
+ subPath: 'packages/alpha',
977
+ },
978
+ commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
979
+ installedAt: '2025-01-01T00:00:00.000Z',
980
+ }, dir);
981
+ expect(source).toEqual({
982
+ kind: 'monorepo',
983
+ url: 'https://github.com/user/opencli-plugins.git',
984
+ repoName: 'opencli-plugins',
985
+ subPath: 'packages/alpha',
986
+ });
987
+ });
988
+ });
989
+
990
+ describe('moveDir', () => {
991
+ it('cleans up destination when EXDEV fallback copy fails', () => {
992
+ const src = path.join(os.tmpdir(), 'opencli-move-src');
993
+ const dest = path.join(os.tmpdir(), 'opencli-move-dest');
994
+ const renameErr = Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' });
995
+ const copyErr = new Error('copy failed');
996
+ const renameSync = vi.fn(() => { throw renameErr; });
997
+ const cpSync = vi.fn(() => { throw copyErr; });
998
+ const rmSync = vi.fn(() => undefined);
999
+
1000
+ expect(() => _moveDir(src, dest, { renameSync, cpSync, rmSync })).toThrow(copyErr);
1001
+ expect(renameSync).toHaveBeenCalledWith(src, dest);
1002
+ expect(cpSync).toHaveBeenCalledWith(src, dest, { recursive: true });
1003
+ expect(rmSync).toHaveBeenCalledWith(dest, { recursive: true, force: true });
1004
+ });
1005
+ });
1006
+
1007
+ describe('promoteDir', () => {
1008
+ it('cleans up temporary publish dir when final rename fails', () => {
1009
+ const staging = path.join(os.tmpdir(), 'opencli-promote-stage');
1010
+ const dest = path.join(os.tmpdir(), 'opencli-promote-dest');
1011
+ const publishErr = new Error('publish failed');
1012
+ const existsSync = vi.fn(() => false);
1013
+ const mkdirSync = vi.fn(() => undefined);
1014
+ const cpSync = vi.fn(() => undefined);
1015
+ const rmSync = vi.fn(() => undefined);
1016
+ const renameSync = vi.fn((src, _target) => {
1017
+ if (String(src) === staging) return;
1018
+ throw publishErr;
1019
+ });
1020
+
1021
+ expect(() => _promoteDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
1022
+
1023
+ const tempDest = renameSync.mock.calls[0][1];
1024
+ expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
1025
+ expect(renameSync).toHaveBeenNthCalledWith(2, tempDest, dest);
1026
+ expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
1027
+ });
1028
+ });
1029
+
1030
+ describe('replaceDir', () => {
1031
+ it('rolls back the original destination when swap fails', () => {
1032
+ const staging = path.join(os.tmpdir(), 'opencli-replace-stage');
1033
+ const dest = path.join(os.tmpdir(), 'opencli-replace-dest');
1034
+ const publishErr = new Error('swap failed');
1035
+ const existingPaths = new Set([dest]);
1036
+ const existsSync = vi.fn((p) => existingPaths.has(String(p)));
1037
+ const mkdirSync = vi.fn(() => undefined);
1038
+ const cpSync = vi.fn(() => undefined);
1039
+ const rmSync = vi.fn(() => undefined);
1040
+ const renameSync = vi.fn((src, target) => {
1041
+ if (String(src) === staging) {
1042
+ existingPaths.add(String(target));
1043
+ return;
1044
+ }
1045
+ if (String(src) === dest) {
1046
+ existingPaths.delete(dest);
1047
+ existingPaths.add(String(target));
1048
+ return;
1049
+ }
1050
+ if (String(target) === dest) throw publishErr;
1051
+ if (existingPaths.has(String(src))) {
1052
+ existingPaths.delete(String(src));
1053
+ existingPaths.add(String(target));
1054
+ }
1055
+ });
1056
+
1057
+ expect(() => _replaceDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
1058
+
1059
+ const tempDest = renameSync.mock.calls[0][1];
1060
+ const backupDest = renameSync.mock.calls[1][1];
1061
+ expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
1062
+ expect(renameSync).toHaveBeenNthCalledWith(2, dest, backupDest);
1063
+ expect(renameSync).toHaveBeenNthCalledWith(3, tempDest, dest);
1064
+ expect(renameSync).toHaveBeenNthCalledWith(4, backupDest, dest);
1065
+ expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
1066
+ });
1067
+ });
1068
+
1069
+ describe('installPlugin transactional staging', () => {
1070
+ const standaloneSource = 'github:user/opencli-plugin-__test-transactional-standalone__';
1071
+ const standaloneName = '__test-transactional-standalone__';
1072
+ const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
1073
+ const monorepoSource = 'github:user/opencli-plugins-__test-transactional__';
1074
+ const monorepoRepoDir = path.join(_getMonoreposDir(), 'opencli-plugins-__test-transactional__');
1075
+ const monorepoLink = path.join(PLUGINS_DIR, 'alpha');
1076
+
1077
+ beforeEach(() => {
1078
+ mockExecFileSync.mockClear();
1079
+ mockExecSync.mockClear();
1080
+ });
1081
+
1082
+ afterEach(() => {
1083
+ try { fs.unlinkSync(monorepoLink); } catch {}
1084
+ try { fs.rmSync(monorepoLink, { recursive: true, force: true }); } catch {}
1085
+ try { fs.rmSync(monorepoRepoDir, { recursive: true, force: true }); } catch {}
1086
+ try { fs.rmSync(standaloneDir, { recursive: true, force: true }); } catch {}
1087
+ const lock = _readLockFile();
1088
+ delete lock[standaloneName];
1089
+ delete lock.alpha;
1090
+ _writeLockFile(lock);
1091
+ vi.clearAllMocks();
1092
+ });
1093
+
1094
+ it('does not expose the final standalone plugin dir when lifecycle fails in staging', () => {
1095
+ mockExecFileSync.mockImplementation((cmd, args) => {
1096
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1097
+ const cloneDir = String(args[args.length - 1]);
1098
+ fs.mkdirSync(cloneDir, { recursive: true });
1099
+ fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
1100
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
1101
+ return '';
1102
+ }
1103
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1104
+ throw new Error('boom');
1105
+ }
1106
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1107
+ return '1234567890abcdef1234567890abcdef12345678\n';
1108
+ }
1109
+ return '';
1110
+ });
1111
+
1112
+ expect(() => installPlugin(standaloneSource)).toThrow(`npm install failed`);
1113
+ expect(fs.existsSync(standaloneDir)).toBe(false);
1114
+ expect(_readLockFile()[standaloneName]).toBeUndefined();
1115
+ });
1116
+
1117
+ it('does not expose monorepo links or repo dir when lifecycle fails in staging', () => {
1118
+ mockExecFileSync.mockImplementation((cmd, args) => {
1119
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1120
+ const cloneDir = String(args[args.length - 1]);
1121
+ const alphaDir = path.join(cloneDir, 'packages', 'alpha');
1122
+ fs.mkdirSync(alphaDir, { recursive: true });
1123
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
1124
+ name: 'opencli-plugins-__test-transactional__',
1125
+ private: true,
1126
+ }));
1127
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1128
+ plugins: {
1129
+ alpha: { path: 'packages/alpha' },
1130
+ },
1131
+ }));
1132
+ fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
1133
+ return '';
1134
+ }
1135
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1136
+ throw new Error('boom');
1137
+ }
1138
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1139
+ return '1234567890abcdef1234567890abcdef12345678\n';
1140
+ }
1141
+ return '';
1142
+ });
1143
+
1144
+ expect(() => installPlugin(monorepoSource)).toThrow(`npm install failed`);
1145
+ expect(fs.existsSync(monorepoRepoDir)).toBe(false);
1146
+ expect(fs.existsSync(monorepoLink)).toBe(false);
1147
+ expect(_readLockFile().alpha).toBeUndefined();
1148
+ });
1149
+ });
1150
+
1151
+ describe('installPlugin with existing monorepo', () => {
1152
+ const repoName = '__test-existing-monorepo__';
1153
+ const repoDir = path.join(_getMonoreposDir(), repoName);
1154
+ const pluginName = 'beta';
1155
+ const pluginLink = path.join(PLUGINS_DIR, pluginName);
1156
+
1157
+ beforeEach(() => {
1158
+ mockExecFileSync.mockClear();
1159
+ mockExecSync.mockClear();
1160
+ });
1161
+
1162
+ afterEach(() => {
1163
+ try { fs.unlinkSync(pluginLink); } catch {}
1164
+ try { fs.rmSync(pluginLink, { recursive: true, force: true }); } catch {}
1165
+ try { fs.rmSync(repoDir, { recursive: true, force: true }); } catch {}
1166
+ const lock = _readLockFile();
1167
+ delete lock[pluginName];
1168
+ _writeLockFile(lock);
1169
+ vi.clearAllMocks();
1170
+ });
1171
+
1172
+ it('reinstalls root dependencies when adding a sub-plugin from an existing monorepo', () => {
1173
+ const subDir = path.join(repoDir, 'packages', pluginName);
1174
+ fs.mkdirSync(subDir, { recursive: true });
1175
+ fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({
1176
+ name: repoName,
1177
+ private: true,
1178
+ workspaces: ['packages/*'],
1179
+ }));
1180
+ fs.writeFileSync(path.join(repoDir, 'opencli-plugin.json'), JSON.stringify({
1181
+ plugins: {
1182
+ [pluginName]: { path: `packages/${pluginName}` },
1183
+ },
1184
+ }));
1185
+ fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n');
1186
+
1187
+ mockExecFileSync.mockImplementation((cmd, args) => {
1188
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1189
+ const cloneDir = String(args[4]);
1190
+ fs.mkdirSync(cloneDir, { recursive: true });
1191
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1192
+ plugins: {
1193
+ [pluginName]: { path: `packages/${pluginName}` },
1194
+ },
1195
+ }));
1196
+ return '';
1197
+ }
1198
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1199
+ return '1234567890abcdef1234567890abcdef12345678\n';
1200
+ }
1201
+ return '';
1202
+ });
1203
+
1204
+ installPlugin(`github:user/${repoName}/${pluginName}`);
1205
+
1206
+ const npmCalls = mockExecFileSync.mock.calls.filter(
1207
+ ([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install',
1208
+ );
1209
+ expect(npmCalls.some(([, , opts]) => opts?.cwd === repoDir)).toBe(true);
1210
+ expect(fs.realpathSync(pluginLink)).toBe(fs.realpathSync(subDir));
1211
+ });
1212
+ });
1213
+
1214
+ describe('updatePlugin transactional staging', () => {
1215
+ const standaloneName = '__test-transactional-update__';
1216
+ const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
1217
+ const monorepoName = '__test-transactional-mono-update__';
1218
+ const monorepoRepoDir = path.join(_getMonoreposDir(), monorepoName);
1219
+ const monorepoPluginName = 'alpha-update';
1220
+ const monorepoLink = path.join(PLUGINS_DIR, monorepoPluginName);
1221
+
1222
+ beforeEach(() => {
1223
+ mockExecFileSync.mockClear();
1224
+ mockExecSync.mockClear();
1225
+ });
1226
+
1227
+ afterEach(() => {
1228
+ try { fs.unlinkSync(monorepoLink); } catch {}
1229
+ try { fs.rmSync(monorepoLink, { recursive: true, force: true }); } catch {}
1230
+ try { fs.rmSync(monorepoRepoDir, { recursive: true, force: true }); } catch {}
1231
+ try { fs.rmSync(standaloneDir, { recursive: true, force: true }); } catch {}
1232
+ const lock = _readLockFile();
1233
+ delete lock[standaloneName];
1234
+ delete lock[monorepoPluginName];
1235
+ _writeLockFile(lock);
1236
+ vi.clearAllMocks();
1237
+ });
1238
+
1239
+ it('keeps the existing standalone plugin when staged update preparation fails', () => {
1240
+ fs.mkdirSync(standaloneDir, { recursive: true });
1241
+ fs.writeFileSync(path.join(standaloneDir, 'old.yaml'), 'site: old\nname: old\n');
1242
+
1243
+ const lock = _readLockFile();
1244
+ lock[standaloneName] = {
1245
+ source: {
1246
+ kind: 'git',
1247
+ url: 'https://github.com/user/opencli-plugin-__test-transactional-update__.git',
1248
+ },
1249
+ commitHash: 'oldhasholdhasholdhasholdhasholdhasholdh',
1250
+ installedAt: '2025-01-01T00:00:00.000Z',
1251
+ };
1252
+ _writeLockFile(lock);
1253
+
1254
+ mockExecFileSync.mockImplementation((cmd, args) => {
1255
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1256
+ const cloneDir = String(args[4]);
1257
+ fs.mkdirSync(cloneDir, { recursive: true });
1258
+ fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
1259
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
1260
+ return '';
1261
+ }
1262
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1263
+ throw new Error('boom');
1264
+ }
1265
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1266
+ return '1234567890abcdef1234567890abcdef12345678\n';
1267
+ }
1268
+ return '';
1269
+ });
1270
+
1271
+ expect(() => updatePlugin(standaloneName)).toThrow('npm install failed');
1272
+ expect(fs.existsSync(standaloneDir)).toBe(true);
1273
+ expect(fs.readFileSync(path.join(standaloneDir, 'old.yaml'), 'utf-8')).toContain('site: old');
1274
+ expect(_readLockFile()[standaloneName]?.commitHash).toBe('oldhasholdhasholdhasholdhasholdhasholdh');
1275
+ });
1276
+
1277
+ it('keeps the existing monorepo repo and link when staged update preparation fails', () => {
1278
+ const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName);
1279
+ fs.mkdirSync(subDir, { recursive: true });
1280
+ fs.writeFileSync(path.join(subDir, 'old.yaml'), 'site: old\nname: old\n');
1281
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
1282
+ fs.symlinkSync(subDir, monorepoLink, 'dir');
1283
+
1284
+ const lock = _readLockFile();
1285
+ lock[monorepoPluginName] = {
1286
+ source: {
1287
+ kind: 'monorepo',
1288
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1289
+ repoName: monorepoName,
1290
+ subPath: `packages/${monorepoPluginName}`,
1291
+ },
1292
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1293
+ installedAt: '2025-01-01T00:00:00.000Z',
1294
+ };
1295
+ _writeLockFile(lock);
1296
+
1297
+ mockExecFileSync.mockImplementation((cmd, args) => {
1298
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1299
+ const cloneDir = String(args[4]);
1300
+ const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName);
1301
+ fs.mkdirSync(alphaDir, { recursive: true });
1302
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
1303
+ name: 'opencli-plugins-__test-transactional-mono-update__',
1304
+ private: true,
1305
+ }));
1306
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1307
+ plugins: {
1308
+ [monorepoPluginName]: { path: `packages/${monorepoPluginName}` },
1309
+ },
1310
+ }));
1311
+ fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
1312
+ return '';
1313
+ }
1314
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1315
+ throw new Error('boom');
1316
+ }
1317
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1318
+ return '1234567890abcdef1234567890abcdef12345678\n';
1319
+ }
1320
+ return '';
1321
+ });
1322
+
1323
+ expect(() => updatePlugin(monorepoPluginName)).toThrow('npm install failed');
1324
+ expect(fs.existsSync(monorepoRepoDir)).toBe(true);
1325
+ expect(fs.existsSync(monorepoLink)).toBe(true);
1326
+ expect(fs.readFileSync(path.join(subDir, 'old.yaml'), 'utf-8')).toContain('site: old');
1327
+ expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe('oldmonooldmonooldmonooldmonooldmonoold');
1328
+ });
1329
+
1330
+ it('relinks monorepo plugins when the updated manifest moves their subPath', () => {
1331
+ const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
1332
+ fs.mkdirSync(oldSubDir, { recursive: true });
1333
+ fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
1334
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
1335
+ fs.symlinkSync(oldSubDir, monorepoLink, 'dir');
1336
+
1337
+ const lock = _readLockFile();
1338
+ lock[monorepoPluginName] = {
1339
+ source: {
1340
+ kind: 'monorepo',
1341
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1342
+ repoName: monorepoName,
1343
+ subPath: 'packages/old-alpha',
1344
+ },
1345
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1346
+ installedAt: '2025-01-01T00:00:00.000Z',
1347
+ };
1348
+ _writeLockFile(lock);
1349
+
1350
+ mockExecFileSync.mockImplementation((cmd, args) => {
1351
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1352
+ const cloneDir = String(args[4]);
1353
+ const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
1354
+ fs.mkdirSync(movedDir, { recursive: true });
1355
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1356
+ plugins: {
1357
+ [monorepoPluginName]: { path: 'packages/moved-alpha' },
1358
+ },
1359
+ }));
1360
+ fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
1361
+ return '';
1362
+ }
1363
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1364
+ return '1234567890abcdef1234567890abcdef12345678\n';
1365
+ }
1366
+ return '';
1367
+ });
1368
+
1369
+ updatePlugin(monorepoPluginName);
1370
+
1371
+ const expectedTarget = path.join(monorepoRepoDir, 'packages', 'moved-alpha');
1372
+ expect(fs.realpathSync(monorepoLink)).toBe(fs.realpathSync(expectedTarget));
1373
+ expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
1374
+ kind: 'monorepo',
1375
+ subPath: 'packages/moved-alpha',
1376
+ });
1377
+ });
1378
+
1379
+ it('rolls back the monorepo repo swap when relinking fails', () => {
1380
+ const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
1381
+ fs.mkdirSync(oldSubDir, { recursive: true });
1382
+ fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
1383
+ fs.mkdirSync(monorepoLink, { recursive: true });
1384
+ fs.writeFileSync(path.join(monorepoLink, 'blocker.txt'), 'not a symlink');
1385
+
1386
+ const lock = _readLockFile();
1387
+ lock[monorepoPluginName] = {
1388
+ source: {
1389
+ kind: 'monorepo',
1390
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1391
+ repoName: monorepoName,
1392
+ subPath: 'packages/old-alpha',
1393
+ },
1394
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1395
+ installedAt: '2025-01-01T00:00:00.000Z',
1396
+ };
1397
+ _writeLockFile(lock);
1398
+
1399
+ mockExecFileSync.mockImplementation((cmd, args) => {
1400
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1401
+ const cloneDir = String(args[4]);
1402
+ const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
1403
+ fs.mkdirSync(movedDir, { recursive: true });
1404
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1405
+ plugins: {
1406
+ [monorepoPluginName]: { path: 'packages/moved-alpha' },
1407
+ },
1408
+ }));
1409
+ fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
1410
+ return '';
1411
+ }
1412
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1413
+ return '1234567890abcdef1234567890abcdef12345678\n';
1414
+ }
1415
+ return '';
1416
+ });
1417
+
1418
+ expect(() => updatePlugin(monorepoPluginName)).toThrow('to be a symlink');
1419
+ expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'old-alpha', 'old.yaml'))).toBe(true);
1420
+ expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'moved-alpha'))).toBe(false);
1421
+ expect(fs.readFileSync(path.join(monorepoLink, 'blocker.txt'), 'utf-8')).toBe('not a symlink');
1422
+ expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
1423
+ kind: 'monorepo',
1424
+ subPath: 'packages/old-alpha',
1425
+ });
1426
+ });
1427
+ });