@jackwener/opencli 1.5.0 → 1.5.2

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 (108) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/discover.js +11 -7
  3. package/dist/browser/index.d.ts +2 -0
  4. package/dist/browser/index.js +2 -0
  5. package/dist/browser/page.d.ts +4 -0
  6. package/dist/browser/page.js +52 -3
  7. package/dist/browser.test.js +5 -0
  8. package/dist/cli-manifest.json +460 -1
  9. package/dist/cli.js +34 -3
  10. package/dist/clis/apple-podcasts/commands.test.js +26 -3
  11. package/dist/clis/apple-podcasts/top.js +4 -1
  12. package/dist/clis/bluesky/feeds.yaml +29 -0
  13. package/dist/clis/bluesky/followers.yaml +33 -0
  14. package/dist/clis/bluesky/following.yaml +33 -0
  15. package/dist/clis/bluesky/profile.yaml +27 -0
  16. package/dist/clis/bluesky/search.yaml +34 -0
  17. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  18. package/dist/clis/bluesky/thread.yaml +32 -0
  19. package/dist/clis/bluesky/trending.yaml +27 -0
  20. package/dist/clis/bluesky/user.yaml +34 -0
  21. package/dist/clis/twitter/trending.js +29 -61
  22. package/dist/clis/weread/shelf.js +132 -9
  23. package/dist/clis/weread/utils.js +5 -1
  24. package/dist/clis/xiaohongshu/publish.js +78 -42
  25. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  26. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  27. package/dist/clis/xiaohongshu/search.js +20 -1
  28. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  29. package/dist/clis/xiaohongshu/search.test.js +32 -1
  30. package/dist/daemon.js +1 -0
  31. package/dist/discovery.js +40 -28
  32. package/dist/doctor.d.ts +1 -2
  33. package/dist/doctor.js +9 -5
  34. package/dist/engine.test.js +42 -0
  35. package/dist/errors.d.ts +1 -1
  36. package/dist/errors.js +2 -2
  37. package/dist/execution.js +45 -13
  38. package/dist/execution.test.d.ts +1 -0
  39. package/dist/execution.test.js +40 -0
  40. package/dist/extension-manifest-regression.test.d.ts +1 -0
  41. package/dist/extension-manifest-regression.test.js +12 -0
  42. package/dist/external.js +6 -1
  43. package/dist/main.js +1 -0
  44. package/dist/plugin-scaffold.d.ts +28 -0
  45. package/dist/plugin-scaffold.js +142 -0
  46. package/dist/plugin-scaffold.test.d.ts +4 -0
  47. package/dist/plugin-scaffold.test.js +83 -0
  48. package/dist/plugin.d.ts +55 -17
  49. package/dist/plugin.js +706 -154
  50. package/dist/plugin.test.js +836 -38
  51. package/dist/runtime.d.ts +1 -0
  52. package/dist/runtime.js +1 -1
  53. package/dist/types.d.ts +2 -0
  54. package/dist/weread-private-api-regression.test.js +185 -0
  55. package/docs/adapters/browser/bluesky.md +53 -0
  56. package/docs/guide/plugins.md +10 -0
  57. package/extension/dist/background.js +4 -2
  58. package/extension/manifest.json +4 -1
  59. package/extension/package-lock.json +2 -2
  60. package/extension/package.json +1 -1
  61. package/extension/src/background.ts +2 -1
  62. package/package.json +1 -1
  63. package/src/browser/cdp.ts +6 -0
  64. package/src/browser/discover.ts +10 -7
  65. package/src/browser/index.ts +2 -0
  66. package/src/browser/page.ts +49 -3
  67. package/src/browser.test.ts +6 -0
  68. package/src/cli.ts +34 -3
  69. package/src/clis/apple-podcasts/commands.test.ts +30 -2
  70. package/src/clis/apple-podcasts/top.ts +4 -1
  71. package/src/clis/bluesky/feeds.yaml +29 -0
  72. package/src/clis/bluesky/followers.yaml +33 -0
  73. package/src/clis/bluesky/following.yaml +33 -0
  74. package/src/clis/bluesky/profile.yaml +27 -0
  75. package/src/clis/bluesky/search.yaml +34 -0
  76. package/src/clis/bluesky/starter-packs.yaml +34 -0
  77. package/src/clis/bluesky/thread.yaml +32 -0
  78. package/src/clis/bluesky/trending.yaml +27 -0
  79. package/src/clis/bluesky/user.yaml +34 -0
  80. package/src/clis/twitter/trending.ts +29 -77
  81. package/src/clis/weread/shelf.ts +169 -9
  82. package/src/clis/weread/utils.ts +6 -1
  83. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  84. package/src/clis/xiaohongshu/publish.ts +93 -52
  85. package/src/clis/xiaohongshu/search.test.ts +39 -1
  86. package/src/clis/xiaohongshu/search.ts +19 -1
  87. package/src/daemon.ts +1 -0
  88. package/src/discovery.ts +41 -33
  89. package/src/doctor.ts +11 -8
  90. package/src/engine.test.ts +38 -0
  91. package/src/errors.ts +6 -2
  92. package/src/execution.test.ts +47 -0
  93. package/src/execution.ts +39 -15
  94. package/src/extension-manifest-regression.test.ts +17 -0
  95. package/src/external.ts +6 -1
  96. package/src/main.ts +1 -0
  97. package/src/plugin-scaffold.test.ts +98 -0
  98. package/src/plugin-scaffold.ts +170 -0
  99. package/src/plugin.test.ts +881 -38
  100. package/src/plugin.ts +871 -158
  101. package/src/runtime.ts +2 -2
  102. package/src/types.ts +2 -0
  103. package/src/weread-private-api-regression.test.ts +207 -0
  104. package/tests/e2e/browser-public.test.ts +1 -1
  105. package/tests/e2e/output-formats.test.ts +10 -14
  106. package/tests/e2e/plugin-management.test.ts +4 -1
  107. package/tests/e2e/public-commands.test.ts +12 -1
  108. package/vitest.config.ts +1 -15
@@ -5,17 +5,19 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
5
  import * as fs from 'node:fs';
6
6
  import * as os from 'node:os';
7
7
  import * as path from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
8
9
  import { PLUGINS_DIR } from './discovery.js';
9
10
  import * as pluginModule from './plugin.js';
10
11
  const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({
11
12
  mockExecFileSync: vi.fn(),
12
13
  mockExecSync: vi.fn(),
13
14
  }));
14
- const { LOCK_FILE, _getCommitHash, _installDependencies, _postInstallMonorepoLifecycle, listPlugins, _readLockFile, _resolveEsbuildBin, uninstallPlugin, updatePlugin, _parseSource, _updateAllPlugins, _validatePluginStructure, _writeLockFile, _isSymlinkSync, _getMonoreposDir, } = pluginModule;
15
+ const { _getCommitHash, _installDependencies, _postInstallMonorepoLifecycle, _promoteDir, _replaceDir, installPlugin, listPlugins, _readLockFile, _readLockFileWithWriter, _resolveEsbuildBin, uninstallPlugin, updatePlugin, _parseSource, _updateAllPlugins, _validatePluginStructure, _writeLockFile, _writeLockFileWithFs, _isSymlinkSync, _getMonoreposDir, getLockFilePath, _installLocalPlugin, _isLocalPluginSource, _moveDir, _resolvePluginSource, _resolveStoredPluginSource, _toStoredPluginSource, _toLocalPluginSource, } = pluginModule;
15
16
  describe('parseSource', () => {
16
17
  it('parses github:user/repo format', () => {
17
18
  const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
18
19
  expect(result).toEqual({
20
+ type: 'git',
19
21
  cloneUrl: 'https://github.com/ByteYue/opencli-plugin-github-trending.git',
20
22
  name: 'github-trending',
21
23
  });
@@ -23,6 +25,7 @@ describe('parseSource', () => {
23
25
  it('parses https URL format', () => {
24
26
  const result = _parseSource('https://github.com/ByteYue/opencli-plugin-hot-digest');
25
27
  expect(result).toEqual({
28
+ type: 'git',
26
29
  cloneUrl: 'https://github.com/ByteYue/opencli-plugin-hot-digest.git',
27
30
  name: 'hot-digest',
28
31
  });
@@ -39,6 +42,88 @@ describe('parseSource', () => {
39
42
  expect(_parseSource('invalid')).toBeNull();
40
43
  expect(_parseSource('npm:some-package')).toBeNull();
41
44
  });
45
+ it('parses file:// local plugin directories', () => {
46
+ const localDir = path.join(os.tmpdir(), 'opencli-plugin-test');
47
+ const fileUrl = pathToFileURL(localDir).href;
48
+ const result = _parseSource(fileUrl);
49
+ expect(result).toEqual({
50
+ type: 'local',
51
+ localPath: localDir,
52
+ name: 'test',
53
+ });
54
+ });
55
+ it('parses plain absolute local plugin directories', () => {
56
+ const localDir = path.join(os.tmpdir(), 'my-plugin');
57
+ const result = _parseSource(localDir);
58
+ expect(result).toEqual({
59
+ type: 'local',
60
+ localPath: localDir,
61
+ name: 'my-plugin',
62
+ });
63
+ });
64
+ it('strips opencli-plugin- prefix for local paths', () => {
65
+ const localDir = path.join(os.tmpdir(), 'opencli-plugin-foo');
66
+ const result = _parseSource(localDir);
67
+ expect(result.name).toBe('foo');
68
+ });
69
+ // ── Generic git URL support ──
70
+ it('parses ssh:// URLs', () => {
71
+ const result = _parseSource('ssh://git@gitlab.com/team/opencli-plugin-tools.git');
72
+ expect(result).toEqual({
73
+ type: 'git',
74
+ cloneUrl: 'ssh://git@gitlab.com/team/opencli-plugin-tools.git',
75
+ name: 'tools',
76
+ });
77
+ });
78
+ it('parses ssh:// URLs without .git suffix', () => {
79
+ const result = _parseSource('ssh://git@gitlab.com/team/my-plugin');
80
+ expect(result).toEqual({
81
+ type: 'git',
82
+ cloneUrl: 'ssh://git@gitlab.com/team/my-plugin',
83
+ name: 'my-plugin',
84
+ });
85
+ });
86
+ it('parses git@ SCP-style URLs', () => {
87
+ const result = _parseSource('git@gitlab.com:team/my-plugin.git');
88
+ expect(result).toEqual({
89
+ type: 'git',
90
+ cloneUrl: 'git@gitlab.com:team/my-plugin.git',
91
+ name: 'my-plugin',
92
+ });
93
+ });
94
+ it('parses git@ SCP-style URLs and strips opencli-plugin- prefix', () => {
95
+ const result = _parseSource('git@github.com:user/opencli-plugin-awesome.git');
96
+ expect(result).toEqual({
97
+ type: 'git',
98
+ cloneUrl: 'git@github.com:user/opencli-plugin-awesome.git',
99
+ name: 'awesome',
100
+ });
101
+ });
102
+ it('parses generic HTTPS git URLs (non-GitHub)', () => {
103
+ const result = _parseSource('https://codehub.example.com/Team/App/opencli-plugins-app.git');
104
+ expect(result).toEqual({
105
+ type: 'git',
106
+ cloneUrl: 'https://codehub.example.com/Team/App/opencli-plugins-app.git',
107
+ name: 'opencli-plugins-app',
108
+ });
109
+ });
110
+ it('parses generic HTTPS git URLs without .git suffix', () => {
111
+ const result = _parseSource('https://gitlab.example.com/org/my-plugin');
112
+ expect(result).toEqual({
113
+ type: 'git',
114
+ cloneUrl: 'https://gitlab.example.com/org/my-plugin.git',
115
+ name: 'my-plugin',
116
+ });
117
+ });
118
+ it('still prefers GitHub shorthand over generic HTTPS for github.com', () => {
119
+ const result = _parseSource('https://github.com/user/repo');
120
+ // Should be handled by the GitHub-specific matcher (normalizes URL)
121
+ expect(result).toEqual({
122
+ type: 'git',
123
+ cloneUrl: 'https://github.com/user/repo.git',
124
+ name: 'repo',
125
+ });
126
+ });
42
127
  });
43
128
  describe('validatePluginStructure', () => {
44
129
  const testDir = path.join(PLUGINS_DIR, '__test-validate__');
@@ -95,29 +180,29 @@ describe('validatePluginStructure', () => {
95
180
  });
96
181
  });
97
182
  describe('lock file', () => {
98
- const backupPath = `${LOCK_FILE}.test-backup`;
183
+ const backupPath = `${getLockFilePath()}.test-backup`;
99
184
  let hadOriginal = false;
100
185
  beforeEach(() => {
101
- hadOriginal = fs.existsSync(LOCK_FILE);
186
+ hadOriginal = fs.existsSync(getLockFilePath());
102
187
  if (hadOriginal) {
103
188
  fs.mkdirSync(path.dirname(backupPath), { recursive: true });
104
- fs.copyFileSync(LOCK_FILE, backupPath);
189
+ fs.copyFileSync(getLockFilePath(), backupPath);
105
190
  }
106
191
  });
107
192
  afterEach(() => {
108
193
  if (hadOriginal) {
109
- fs.copyFileSync(backupPath, LOCK_FILE);
194
+ fs.copyFileSync(backupPath, getLockFilePath());
110
195
  fs.unlinkSync(backupPath);
111
196
  return;
112
197
  }
113
198
  try {
114
- fs.unlinkSync(LOCK_FILE);
199
+ fs.unlinkSync(getLockFilePath());
115
200
  }
116
201
  catch { }
117
202
  });
118
203
  it('reads empty lock when file does not exist', () => {
119
204
  try {
120
- fs.unlinkSync(LOCK_FILE);
205
+ fs.unlinkSync(getLockFilePath());
121
206
  }
122
207
  catch { }
123
208
  expect(_readLockFile()).toEqual({});
@@ -125,12 +210,12 @@ describe('lock file', () => {
125
210
  it('round-trips lock entries', () => {
126
211
  const entries = {
127
212
  'test-plugin': {
128
- source: 'https://github.com/user/repo.git',
213
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
129
214
  commitHash: 'abc1234567890def',
130
215
  installedAt: '2025-01-01T00:00:00.000Z',
131
216
  },
132
217
  'another-plugin': {
133
- source: 'https://github.com/user/another.git',
218
+ source: { kind: 'git', url: 'https://github.com/user/another.git' },
134
219
  commitHash: 'def4567890123abc',
135
220
  installedAt: '2025-02-01T00:00:00.000Z',
136
221
  updatedAt: '2025-03-01T00:00:00.000Z',
@@ -140,10 +225,97 @@ describe('lock file', () => {
140
225
  expect(_readLockFile()).toEqual(entries);
141
226
  });
142
227
  it('handles malformed lock file gracefully', () => {
143
- fs.mkdirSync(path.dirname(LOCK_FILE), { recursive: true });
144
- fs.writeFileSync(LOCK_FILE, 'not valid json');
228
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
229
+ fs.writeFileSync(getLockFilePath(), 'not valid json');
145
230
  expect(_readLockFile()).toEqual({});
146
231
  });
232
+ it('keeps the previous lockfile contents when atomic rewrite fails', () => {
233
+ const existing = {
234
+ stable: {
235
+ source: { kind: 'git', url: 'https://github.com/user/stable.git' },
236
+ commitHash: 'stable1234567890',
237
+ installedAt: '2025-01-01T00:00:00.000Z',
238
+ },
239
+ };
240
+ _writeLockFile(existing);
241
+ const renameSync = vi.fn(() => {
242
+ throw new Error('rename failed');
243
+ });
244
+ const rmSync = vi.fn(() => undefined);
245
+ expect(() => _writeLockFileWithFs({
246
+ broken: {
247
+ source: { kind: 'git', url: 'https://github.com/user/broken.git' },
248
+ commitHash: 'broken1234567890',
249
+ installedAt: '2025-02-01T00:00:00.000Z',
250
+ },
251
+ }, {
252
+ mkdirSync: fs.mkdirSync,
253
+ writeFileSync: fs.writeFileSync,
254
+ renameSync,
255
+ rmSync,
256
+ })).toThrow('rename failed');
257
+ expect(_readLockFile()).toEqual(existing);
258
+ expect(rmSync).toHaveBeenCalledTimes(1);
259
+ });
260
+ it('migrates legacy string sources to structured sources on read', () => {
261
+ const legacyLocalPath = path.resolve(path.join(os.tmpdir(), 'opencli-legacy-local-plugin'));
262
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
263
+ fs.writeFileSync(getLockFilePath(), JSON.stringify({
264
+ alpha: {
265
+ source: 'https://github.com/user/opencli-plugins.git',
266
+ commitHash: 'abc1234567890def',
267
+ installedAt: '2025-01-01T00:00:00.000Z',
268
+ monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
269
+ },
270
+ beta: {
271
+ source: `local:${legacyLocalPath}`,
272
+ commitHash: 'local',
273
+ installedAt: '2025-01-01T00:00:00.000Z',
274
+ },
275
+ }, null, 2));
276
+ expect(_readLockFile()).toEqual({
277
+ alpha: {
278
+ source: {
279
+ kind: 'monorepo',
280
+ url: 'https://github.com/user/opencli-plugins.git',
281
+ repoName: 'opencli-plugins',
282
+ subPath: 'packages/alpha',
283
+ },
284
+ commitHash: 'abc1234567890def',
285
+ installedAt: '2025-01-01T00:00:00.000Z',
286
+ },
287
+ beta: {
288
+ source: { kind: 'local', path: legacyLocalPath },
289
+ commitHash: 'local',
290
+ installedAt: '2025-01-01T00:00:00.000Z',
291
+ },
292
+ });
293
+ });
294
+ it('returns normalized entries even when migration rewrite fails', () => {
295
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
296
+ fs.writeFileSync(getLockFilePath(), JSON.stringify({
297
+ alpha: {
298
+ source: 'https://github.com/user/opencli-plugins.git',
299
+ commitHash: 'abc1234567890def',
300
+ installedAt: '2025-01-01T00:00:00.000Z',
301
+ monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
302
+ },
303
+ }, null, 2));
304
+ expect(_readLockFileWithWriter(() => {
305
+ throw new Error('disk full');
306
+ })).toEqual({
307
+ alpha: {
308
+ source: {
309
+ kind: 'monorepo',
310
+ url: 'https://github.com/user/opencli-plugins.git',
311
+ repoName: 'opencli-plugins',
312
+ subPath: 'packages/alpha',
313
+ },
314
+ commitHash: 'abc1234567890def',
315
+ installedAt: '2025-01-01T00:00:00.000Z',
316
+ },
317
+ });
318
+ });
147
319
  });
148
320
  describe('getCommitHash', () => {
149
321
  it('returns a hash for a git repo', () => {
@@ -161,7 +333,6 @@ describe('resolveEsbuildBin', () => {
161
333
  expect(binPath).not.toBeNull();
162
334
  expect(typeof binPath).toBe('string');
163
335
  expect(fs.existsSync(binPath)).toBe(true);
164
- // On Windows the resolved path ends with 'esbuild.cmd', on Unix 'esbuild'
165
336
  expect(binPath).toMatch(/esbuild(\.cmd)?$/);
166
337
  });
167
338
  });
@@ -186,7 +357,7 @@ describe('listPlugins', () => {
186
357
  fs.writeFileSync(path.join(testDir, 'hello.yaml'), 'site: test\nname: hello\n');
187
358
  const lock = _readLockFile();
188
359
  lock['__test-list-plugin__'] = {
189
- source: 'https://github.com/user/repo.git',
360
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
190
361
  commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
191
362
  installedAt: '2025-01-01T00:00:00.000Z',
192
363
  };
@@ -200,10 +371,44 @@ describe('listPlugins', () => {
200
371
  _writeLockFile(lock);
201
372
  });
202
373
  it('returns empty array when no plugins dir', () => {
203
- // listPlugins should handle missing dir gracefully
204
374
  const plugins = listPlugins();
205
375
  expect(Array.isArray(plugins)).toBe(true);
206
376
  });
377
+ it('prefers lockfile source for local symlink plugins', () => {
378
+ const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-list-'));
379
+ const linkPath = path.join(PLUGINS_DIR, '__test-list-plugin__');
380
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
381
+ fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
382
+ try {
383
+ fs.unlinkSync(linkPath);
384
+ }
385
+ catch { }
386
+ try {
387
+ fs.rmSync(linkPath, { recursive: true, force: true });
388
+ }
389
+ catch { }
390
+ fs.symlinkSync(localTarget, linkPath, 'dir');
391
+ const lock = _readLockFile();
392
+ lock['__test-list-plugin__'] = {
393
+ source: { kind: 'local', path: localTarget },
394
+ commitHash: 'local',
395
+ installedAt: '2025-01-01T00:00:00.000Z',
396
+ };
397
+ _writeLockFile(lock);
398
+ const plugins = listPlugins();
399
+ const found = plugins.find(p => p.name === '__test-list-plugin__');
400
+ expect(found?.source).toBe(`local:${localTarget}`);
401
+ try {
402
+ fs.unlinkSync(linkPath);
403
+ }
404
+ catch { }
405
+ try {
406
+ fs.rmSync(localTarget, { recursive: true, force: true });
407
+ }
408
+ catch { }
409
+ delete lock['__test-list-plugin__'];
410
+ _writeLockFile(lock);
411
+ });
207
412
  });
208
413
  describe('uninstallPlugin', () => {
209
414
  const testDir = path.join(PLUGINS_DIR, '__test-uninstall__');
@@ -224,7 +429,7 @@ describe('uninstallPlugin', () => {
224
429
  fs.writeFileSync(path.join(testDir, 'test.yaml'), 'site: test');
225
430
  const lock = _readLockFile();
226
431
  lock['__test-uninstall__'] = {
227
- source: 'https://github.com/user/repo.git',
432
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
228
433
  commitHash: 'abc123',
229
434
  installedAt: '2025-01-01T00:00:00.000Z',
230
435
  };
@@ -240,6 +445,40 @@ describe('updatePlugin', () => {
240
445
  it('throws for non-existent plugin', () => {
241
446
  expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
242
447
  });
448
+ it('refreshes local plugins without running git pull', () => {
449
+ const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-update-'));
450
+ const linkPath = path.join(PLUGINS_DIR, '__test-local-update__');
451
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
452
+ fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
453
+ fs.symlinkSync(localTarget, linkPath, 'dir');
454
+ const lock = _readLockFile();
455
+ lock['__test-local-update__'] = {
456
+ source: { kind: 'local', path: localTarget },
457
+ commitHash: 'local',
458
+ installedAt: '2025-01-01T00:00:00.000Z',
459
+ };
460
+ _writeLockFile(lock);
461
+ mockExecFileSync.mockClear();
462
+ updatePlugin('__test-local-update__');
463
+ expect(mockExecFileSync.mock.calls.some(([cmd, args, opts]) => cmd === 'git'
464
+ && Array.isArray(args)
465
+ && args[0] === 'pull'
466
+ && opts?.cwd === linkPath)).toBe(false);
467
+ const updated = _readLockFile()['__test-local-update__'];
468
+ expect(updated?.source).toEqual({ kind: 'local', path: path.resolve(localTarget) });
469
+ expect(updated?.updatedAt).toBeDefined();
470
+ try {
471
+ fs.unlinkSync(linkPath);
472
+ }
473
+ catch { }
474
+ try {
475
+ fs.rmSync(localTarget, { recursive: true, force: true });
476
+ }
477
+ catch { }
478
+ const finalLock = _readLockFile();
479
+ delete finalLock['__test-local-update__'];
480
+ _writeLockFile(finalLock);
481
+ });
243
482
  });
244
483
  vi.mock('node:child_process', () => {
245
484
  return {
@@ -310,6 +549,23 @@ describe('updateAllPlugins', () => {
310
549
  fs.writeFileSync(path.join(testDirA, 'cmd.yaml'), 'site: a');
311
550
  fs.writeFileSync(path.join(testDirB, 'cmd.yaml'), 'site: b');
312
551
  fs.writeFileSync(path.join(testDirC, 'cmd.yaml'), 'site: c');
552
+ const lock = _readLockFile();
553
+ lock['plugin-a'] = {
554
+ source: { kind: 'git', url: 'https://github.com/user/plugin-a.git' },
555
+ commitHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
556
+ installedAt: '2025-01-01T00:00:00.000Z',
557
+ };
558
+ lock['plugin-b'] = {
559
+ source: { kind: 'git', url: 'https://github.com/user/plugin-b.git' },
560
+ commitHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
561
+ installedAt: '2025-01-01T00:00:00.000Z',
562
+ };
563
+ lock['plugin-c'] = {
564
+ source: { kind: 'git', url: 'https://github.com/user/plugin-c.git' },
565
+ commitHash: 'cccccccccccccccccccccccccccccccccccccccc',
566
+ installedAt: '2025-01-01T00:00:00.000Z',
567
+ };
568
+ _writeLockFile(lock);
313
569
  });
314
570
  afterEach(() => {
315
571
  try {
@@ -324,9 +580,33 @@ describe('updateAllPlugins', () => {
324
580
  fs.rmSync(testDirC, { recursive: true });
325
581
  }
326
582
  catch { }
583
+ const lock = _readLockFile();
584
+ delete lock['plugin-a'];
585
+ delete lock['plugin-b'];
586
+ delete lock['plugin-c'];
587
+ _writeLockFile(lock);
327
588
  vi.clearAllMocks();
328
589
  });
329
590
  it('collects successes and failures without throwing', () => {
591
+ mockExecFileSync.mockImplementation((cmd, args) => {
592
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
593
+ const cloneUrl = String(args[3]);
594
+ const cloneDir = String(args[4]);
595
+ fs.mkdirSync(cloneDir, { recursive: true });
596
+ fs.writeFileSync(path.join(cloneDir, 'cmd.yaml'), 'site: test\nname: hello\n');
597
+ if (cloneUrl.includes('plugin-b')) {
598
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: 'plugin-b' }));
599
+ }
600
+ return '';
601
+ }
602
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
603
+ throw new Error('Network error');
604
+ }
605
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
606
+ return '1234567890abcdef1234567890abcdef12345678\n';
607
+ }
608
+ return '';
609
+ });
330
610
  const results = _updateAllPlugins();
331
611
  const resA = results.find(r => r.name === 'plugin-a');
332
612
  const resB = results.find(r => r.name === 'plugin-b');
@@ -345,6 +625,7 @@ describe('parseSource with monorepo subplugin', () => {
345
625
  it('parses github:user/repo/subplugin format', () => {
346
626
  const result = _parseSource('github:ByteYue/opencli-plugins/polymarket');
347
627
  expect(result).toEqual({
628
+ type: 'git',
348
629
  cloneUrl: 'https://github.com/ByteYue/opencli-plugins.git',
349
630
  name: 'opencli-plugins',
350
631
  subPlugin: 'polymarket',
@@ -358,6 +639,7 @@ describe('parseSource with monorepo subplugin', () => {
358
639
  it('still parses github:user/repo without subplugin', () => {
359
640
  const result = _parseSource('github:user/my-repo');
360
641
  expect(result).toEqual({
642
+ type: 'git',
361
643
  cloneUrl: 'https://github.com/user/my-repo.git',
362
644
  name: 'my-repo',
363
645
  });
@@ -394,23 +676,23 @@ describe('monorepo uninstall with symlink', () => {
394
676
  let monoDir;
395
677
  beforeEach(() => {
396
678
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-mono-uninstall-'));
397
- // We need to use the real PLUGINS_DIR for uninstallPlugin() to work
398
679
  pluginDir = path.join(PLUGINS_DIR, '__test-mono-sub__');
399
680
  monoDir = path.join(_getMonoreposDir(), '__test-mono__');
400
- // Set up monorepo structure
401
681
  const subDir = path.join(monoDir, 'packages', 'sub');
402
682
  fs.mkdirSync(subDir, { recursive: true });
403
683
  fs.writeFileSync(path.join(subDir, 'cmd.yaml'), 'site: test');
404
- // Create symlink in plugins dir
405
684
  fs.mkdirSync(PLUGINS_DIR, { recursive: true });
406
685
  fs.symlinkSync(subDir, pluginDir, 'dir');
407
- // Set up lock file with monorepo entry
408
686
  const lock = _readLockFile();
409
687
  lock['__test-mono-sub__'] = {
410
- source: 'https://github.com/user/test.git',
688
+ source: {
689
+ kind: 'monorepo',
690
+ url: 'https://github.com/user/test.git',
691
+ repoName: '__test-mono__',
692
+ subPath: 'packages/sub',
693
+ },
411
694
  commitHash: 'abc123',
412
695
  installedAt: '2025-01-01T00:00:00.000Z',
413
- monorepo: { name: '__test-mono__', subPath: 'packages/sub' },
414
696
  };
415
697
  _writeLockFile(lock);
416
698
  });
@@ -427,42 +709,36 @@ describe('monorepo uninstall with symlink', () => {
427
709
  fs.rmSync(monoDir, { recursive: true, force: true });
428
710
  }
429
711
  catch { }
430
- // Clean up lock entry
431
712
  const lock = _readLockFile();
432
713
  delete lock['__test-mono-sub__'];
433
714
  _writeLockFile(lock);
434
715
  });
435
716
  it('removes symlink but keeps monorepo if other sub-plugins reference it', () => {
436
- // Add another sub-plugin referencing the same monorepo
437
717
  const lock = _readLockFile();
438
718
  lock['__test-mono-other__'] = {
439
- source: 'https://github.com/user/test.git',
719
+ source: {
720
+ kind: 'monorepo',
721
+ url: 'https://github.com/user/test.git',
722
+ repoName: '__test-mono__',
723
+ subPath: 'packages/other',
724
+ },
440
725
  commitHash: 'abc123',
441
726
  installedAt: '2025-01-01T00:00:00.000Z',
442
- monorepo: { name: '__test-mono__', subPath: 'packages/other' },
443
727
  };
444
728
  _writeLockFile(lock);
445
729
  uninstallPlugin('__test-mono-sub__');
446
- // Symlink removed
447
730
  expect(fs.existsSync(pluginDir)).toBe(false);
448
- // Monorepo dir still exists (other sub-plugin references it)
449
731
  expect(fs.existsSync(monoDir)).toBe(true);
450
- // Lock entry removed
451
732
  expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
452
- // Other lock entry still present
453
733
  expect(_readLockFile()['__test-mono-other__']).toBeDefined();
454
- // Clean up the other entry
455
734
  const finalLock = _readLockFile();
456
735
  delete finalLock['__test-mono-other__'];
457
736
  _writeLockFile(finalLock);
458
737
  });
459
738
  it('removes symlink AND monorepo dir when last sub-plugin is uninstalled', () => {
460
739
  uninstallPlugin('__test-mono-sub__');
461
- // Symlink removed
462
740
  expect(fs.existsSync(pluginDir)).toBe(false);
463
- // Monorepo dir also removed (no more references)
464
741
  expect(fs.existsSync(monoDir)).toBe(false);
465
- // Lock entry removed
466
742
  expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
467
743
  });
468
744
  });
@@ -470,23 +746,24 @@ describe('listPlugins with monorepo metadata', () => {
470
746
  const testSymlinkTarget = path.join(os.tmpdir(), 'opencli-list-mono-target');
471
747
  const testLink = path.join(PLUGINS_DIR, '__test-mono-list__');
472
748
  beforeEach(() => {
473
- // Create a target dir with a command file
474
749
  fs.mkdirSync(testSymlinkTarget, { recursive: true });
475
750
  fs.writeFileSync(path.join(testSymlinkTarget, 'hello.yaml'), 'site: test\nname: hello\n');
476
- // Create symlink
477
751
  fs.mkdirSync(PLUGINS_DIR, { recursive: true });
478
752
  try {
479
753
  fs.unlinkSync(testLink);
480
754
  }
481
755
  catch { }
482
756
  fs.symlinkSync(testSymlinkTarget, testLink, 'dir');
483
- // Set up lock file with monorepo entry
484
757
  const lock = _readLockFile();
485
758
  lock['__test-mono-list__'] = {
486
- source: 'https://github.com/user/test-mono.git',
759
+ source: {
760
+ kind: 'monorepo',
761
+ url: 'https://github.com/user/test-mono.git',
762
+ repoName: 'test-mono',
763
+ subPath: 'packages/list',
764
+ },
487
765
  commitHash: 'def456def456def456def456def456def456def4',
488
766
  installedAt: '2025-01-01T00:00:00.000Z',
489
- monorepo: { name: 'test-mono', subPath: 'packages/list' },
490
767
  };
491
768
  _writeLockFile(lock);
492
769
  });
@@ -512,3 +789,524 @@ describe('listPlugins with monorepo metadata', () => {
512
789
  expect(found.source).toBe('https://github.com/user/test-mono.git');
513
790
  });
514
791
  });
792
+ describe('installLocalPlugin', () => {
793
+ let tmpDir;
794
+ const pluginName = '__test-local-plugin__';
795
+ beforeEach(() => {
796
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-install-'));
797
+ fs.writeFileSync(path.join(tmpDir, 'hello.yaml'), 'site: test\nname: hello\n');
798
+ });
799
+ afterEach(() => {
800
+ const linkPath = path.join(PLUGINS_DIR, pluginName);
801
+ try {
802
+ fs.unlinkSync(linkPath);
803
+ }
804
+ catch { }
805
+ try {
806
+ fs.rmSync(tmpDir, { recursive: true, force: true });
807
+ }
808
+ catch { }
809
+ const lock = _readLockFile();
810
+ delete lock[pluginName];
811
+ _writeLockFile(lock);
812
+ });
813
+ it('creates a symlink to the local directory', () => {
814
+ const result = _installLocalPlugin(tmpDir, pluginName);
815
+ expect(result).toBe(pluginName);
816
+ const linkPath = path.join(PLUGINS_DIR, pluginName);
817
+ expect(fs.existsSync(linkPath)).toBe(true);
818
+ expect(_isSymlinkSync(linkPath)).toBe(true);
819
+ });
820
+ it('records local: source in lockfile', () => {
821
+ _installLocalPlugin(tmpDir, pluginName);
822
+ const lock = _readLockFile();
823
+ expect(lock[pluginName]).toBeDefined();
824
+ expect(lock[pluginName].source).toEqual({ kind: 'local', path: path.resolve(tmpDir) });
825
+ });
826
+ it('lists the recorded local source', () => {
827
+ _installLocalPlugin(tmpDir, pluginName);
828
+ const plugins = listPlugins();
829
+ const found = plugins.find(p => p.name === pluginName);
830
+ expect(found).toBeDefined();
831
+ expect(found.source).toBe(`local:${path.resolve(tmpDir)}`);
832
+ });
833
+ it('throws for non-existent path', () => {
834
+ expect(() => _installLocalPlugin('/does/not/exist', 'x')).toThrow('does not exist');
835
+ });
836
+ });
837
+ describe('isLocalPluginSource', () => {
838
+ it('detects lockfile local sources', () => {
839
+ expect(_isLocalPluginSource('local:/tmp/plugin')).toBe(true);
840
+ expect(_isLocalPluginSource('https://github.com/user/repo.git')).toBe(false);
841
+ expect(_isLocalPluginSource(undefined)).toBe(false);
842
+ });
843
+ });
844
+ describe('plugin source helpers', () => {
845
+ it('formats local plugin sources consistently', () => {
846
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
847
+ expect(_toLocalPluginSource(dir)).toBe(`local:${path.resolve(dir)}`);
848
+ });
849
+ it('serializes structured local sources consistently', () => {
850
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
851
+ expect(_toStoredPluginSource({ kind: 'local', path: dir })).toBe(`local:${path.resolve(dir)}`);
852
+ });
853
+ it('prefers lockfile source over git remote lookup', () => {
854
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
855
+ const localPath = path.resolve(path.join(os.tmpdir(), 'opencli-plugin-source-local'));
856
+ const source = _resolveStoredPluginSource({
857
+ source: { kind: 'local', path: localPath },
858
+ commitHash: 'local',
859
+ installedAt: '2025-01-01T00:00:00.000Z',
860
+ }, dir);
861
+ expect(source).toBe(`local:${localPath}`);
862
+ });
863
+ it('returns structured monorepo sources unchanged', () => {
864
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
865
+ const source = _resolvePluginSource({
866
+ source: {
867
+ kind: 'monorepo',
868
+ url: 'https://github.com/user/opencli-plugins.git',
869
+ repoName: 'opencli-plugins',
870
+ subPath: 'packages/alpha',
871
+ },
872
+ commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
873
+ installedAt: '2025-01-01T00:00:00.000Z',
874
+ }, dir);
875
+ expect(source).toEqual({
876
+ kind: 'monorepo',
877
+ url: 'https://github.com/user/opencli-plugins.git',
878
+ repoName: 'opencli-plugins',
879
+ subPath: 'packages/alpha',
880
+ });
881
+ });
882
+ });
883
+ describe('moveDir', () => {
884
+ it('cleans up destination when EXDEV fallback copy fails', () => {
885
+ const src = path.join(os.tmpdir(), 'opencli-move-src');
886
+ const dest = path.join(os.tmpdir(), 'opencli-move-dest');
887
+ const renameErr = Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' });
888
+ const copyErr = new Error('copy failed');
889
+ const renameSync = vi.fn(() => { throw renameErr; });
890
+ const cpSync = vi.fn(() => { throw copyErr; });
891
+ const rmSync = vi.fn(() => undefined);
892
+ expect(() => _moveDir(src, dest, { renameSync, cpSync, rmSync })).toThrow(copyErr);
893
+ expect(renameSync).toHaveBeenCalledWith(src, dest);
894
+ expect(cpSync).toHaveBeenCalledWith(src, dest, { recursive: true });
895
+ expect(rmSync).toHaveBeenCalledWith(dest, { recursive: true, force: true });
896
+ });
897
+ });
898
+ describe('promoteDir', () => {
899
+ it('cleans up temporary publish dir when final rename fails', () => {
900
+ const staging = path.join(os.tmpdir(), 'opencli-promote-stage');
901
+ const dest = path.join(os.tmpdir(), 'opencli-promote-dest');
902
+ const publishErr = new Error('publish failed');
903
+ const existsSync = vi.fn(() => false);
904
+ const mkdirSync = vi.fn(() => undefined);
905
+ const cpSync = vi.fn(() => undefined);
906
+ const rmSync = vi.fn(() => undefined);
907
+ const renameSync = vi.fn((src, _target) => {
908
+ if (String(src) === staging)
909
+ return;
910
+ throw publishErr;
911
+ });
912
+ expect(() => _promoteDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
913
+ const tempDest = renameSync.mock.calls[0][1];
914
+ expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
915
+ expect(renameSync).toHaveBeenNthCalledWith(2, tempDest, dest);
916
+ expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
917
+ });
918
+ });
919
+ describe('replaceDir', () => {
920
+ it('rolls back the original destination when swap fails', () => {
921
+ const staging = path.join(os.tmpdir(), 'opencli-replace-stage');
922
+ const dest = path.join(os.tmpdir(), 'opencli-replace-dest');
923
+ const publishErr = new Error('swap failed');
924
+ const existingPaths = new Set([dest]);
925
+ const existsSync = vi.fn((p) => existingPaths.has(String(p)));
926
+ const mkdirSync = vi.fn(() => undefined);
927
+ const cpSync = vi.fn(() => undefined);
928
+ const rmSync = vi.fn(() => undefined);
929
+ const renameSync = vi.fn((src, target) => {
930
+ if (String(src) === staging) {
931
+ existingPaths.add(String(target));
932
+ return;
933
+ }
934
+ if (String(src) === dest) {
935
+ existingPaths.delete(dest);
936
+ existingPaths.add(String(target));
937
+ return;
938
+ }
939
+ if (String(target) === dest)
940
+ throw publishErr;
941
+ if (existingPaths.has(String(src))) {
942
+ existingPaths.delete(String(src));
943
+ existingPaths.add(String(target));
944
+ }
945
+ });
946
+ expect(() => _replaceDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
947
+ const tempDest = renameSync.mock.calls[0][1];
948
+ const backupDest = renameSync.mock.calls[1][1];
949
+ expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
950
+ expect(renameSync).toHaveBeenNthCalledWith(2, dest, backupDest);
951
+ expect(renameSync).toHaveBeenNthCalledWith(3, tempDest, dest);
952
+ expect(renameSync).toHaveBeenNthCalledWith(4, backupDest, dest);
953
+ expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
954
+ });
955
+ });
956
+ describe('installPlugin transactional staging', () => {
957
+ const standaloneSource = 'github:user/opencli-plugin-__test-transactional-standalone__';
958
+ const standaloneName = '__test-transactional-standalone__';
959
+ const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
960
+ const monorepoSource = 'github:user/opencli-plugins-__test-transactional__';
961
+ const monorepoRepoDir = path.join(_getMonoreposDir(), 'opencli-plugins-__test-transactional__');
962
+ const monorepoLink = path.join(PLUGINS_DIR, 'alpha');
963
+ beforeEach(() => {
964
+ mockExecFileSync.mockClear();
965
+ mockExecSync.mockClear();
966
+ });
967
+ afterEach(() => {
968
+ try {
969
+ fs.unlinkSync(monorepoLink);
970
+ }
971
+ catch { }
972
+ try {
973
+ fs.rmSync(monorepoLink, { recursive: true, force: true });
974
+ }
975
+ catch { }
976
+ try {
977
+ fs.rmSync(monorepoRepoDir, { recursive: true, force: true });
978
+ }
979
+ catch { }
980
+ try {
981
+ fs.rmSync(standaloneDir, { recursive: true, force: true });
982
+ }
983
+ catch { }
984
+ const lock = _readLockFile();
985
+ delete lock[standaloneName];
986
+ delete lock.alpha;
987
+ _writeLockFile(lock);
988
+ vi.clearAllMocks();
989
+ });
990
+ it('does not expose the final standalone plugin dir when lifecycle fails in staging', () => {
991
+ mockExecFileSync.mockImplementation((cmd, args) => {
992
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
993
+ const cloneDir = String(args[args.length - 1]);
994
+ fs.mkdirSync(cloneDir, { recursive: true });
995
+ fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
996
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
997
+ return '';
998
+ }
999
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1000
+ throw new Error('boom');
1001
+ }
1002
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1003
+ return '1234567890abcdef1234567890abcdef12345678\n';
1004
+ }
1005
+ return '';
1006
+ });
1007
+ expect(() => installPlugin(standaloneSource)).toThrow(`npm install failed`);
1008
+ expect(fs.existsSync(standaloneDir)).toBe(false);
1009
+ expect(_readLockFile()[standaloneName]).toBeUndefined();
1010
+ });
1011
+ it('does not expose monorepo links or repo dir when lifecycle fails in staging', () => {
1012
+ mockExecFileSync.mockImplementation((cmd, args) => {
1013
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1014
+ const cloneDir = String(args[args.length - 1]);
1015
+ const alphaDir = path.join(cloneDir, 'packages', 'alpha');
1016
+ fs.mkdirSync(alphaDir, { recursive: true });
1017
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
1018
+ name: 'opencli-plugins-__test-transactional__',
1019
+ private: true,
1020
+ }));
1021
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1022
+ plugins: {
1023
+ alpha: { path: 'packages/alpha' },
1024
+ },
1025
+ }));
1026
+ fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
1027
+ return '';
1028
+ }
1029
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1030
+ throw new Error('boom');
1031
+ }
1032
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1033
+ return '1234567890abcdef1234567890abcdef12345678\n';
1034
+ }
1035
+ return '';
1036
+ });
1037
+ expect(() => installPlugin(monorepoSource)).toThrow(`npm install failed`);
1038
+ expect(fs.existsSync(monorepoRepoDir)).toBe(false);
1039
+ expect(fs.existsSync(monorepoLink)).toBe(false);
1040
+ expect(_readLockFile().alpha).toBeUndefined();
1041
+ });
1042
+ });
1043
+ describe('installPlugin with existing monorepo', () => {
1044
+ const repoName = '__test-existing-monorepo__';
1045
+ const repoDir = path.join(_getMonoreposDir(), repoName);
1046
+ const pluginName = 'beta';
1047
+ const pluginLink = path.join(PLUGINS_DIR, pluginName);
1048
+ beforeEach(() => {
1049
+ mockExecFileSync.mockClear();
1050
+ mockExecSync.mockClear();
1051
+ });
1052
+ afterEach(() => {
1053
+ try {
1054
+ fs.unlinkSync(pluginLink);
1055
+ }
1056
+ catch { }
1057
+ try {
1058
+ fs.rmSync(pluginLink, { recursive: true, force: true });
1059
+ }
1060
+ catch { }
1061
+ try {
1062
+ fs.rmSync(repoDir, { recursive: true, force: true });
1063
+ }
1064
+ catch { }
1065
+ const lock = _readLockFile();
1066
+ delete lock[pluginName];
1067
+ _writeLockFile(lock);
1068
+ vi.clearAllMocks();
1069
+ });
1070
+ it('reinstalls root dependencies when adding a sub-plugin from an existing monorepo', () => {
1071
+ const subDir = path.join(repoDir, 'packages', pluginName);
1072
+ fs.mkdirSync(subDir, { recursive: true });
1073
+ fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({
1074
+ name: repoName,
1075
+ private: true,
1076
+ workspaces: ['packages/*'],
1077
+ }));
1078
+ fs.writeFileSync(path.join(repoDir, 'opencli-plugin.json'), JSON.stringify({
1079
+ plugins: {
1080
+ [pluginName]: { path: `packages/${pluginName}` },
1081
+ },
1082
+ }));
1083
+ fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n');
1084
+ mockExecFileSync.mockImplementation((cmd, args) => {
1085
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1086
+ const cloneDir = String(args[4]);
1087
+ fs.mkdirSync(cloneDir, { recursive: true });
1088
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1089
+ plugins: {
1090
+ [pluginName]: { path: `packages/${pluginName}` },
1091
+ },
1092
+ }));
1093
+ return '';
1094
+ }
1095
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1096
+ return '1234567890abcdef1234567890abcdef12345678\n';
1097
+ }
1098
+ return '';
1099
+ });
1100
+ installPlugin(`github:user/${repoName}/${pluginName}`);
1101
+ const npmCalls = mockExecFileSync.mock.calls.filter(([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install');
1102
+ expect(npmCalls.some(([, , opts]) => opts?.cwd === repoDir)).toBe(true);
1103
+ expect(fs.realpathSync(pluginLink)).toBe(fs.realpathSync(subDir));
1104
+ });
1105
+ });
1106
+ describe('updatePlugin transactional staging', () => {
1107
+ const standaloneName = '__test-transactional-update__';
1108
+ const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
1109
+ const monorepoName = '__test-transactional-mono-update__';
1110
+ const monorepoRepoDir = path.join(_getMonoreposDir(), monorepoName);
1111
+ const monorepoPluginName = 'alpha-update';
1112
+ const monorepoLink = path.join(PLUGINS_DIR, monorepoPluginName);
1113
+ beforeEach(() => {
1114
+ mockExecFileSync.mockClear();
1115
+ mockExecSync.mockClear();
1116
+ });
1117
+ afterEach(() => {
1118
+ try {
1119
+ fs.unlinkSync(monorepoLink);
1120
+ }
1121
+ catch { }
1122
+ try {
1123
+ fs.rmSync(monorepoLink, { recursive: true, force: true });
1124
+ }
1125
+ catch { }
1126
+ try {
1127
+ fs.rmSync(monorepoRepoDir, { recursive: true, force: true });
1128
+ }
1129
+ catch { }
1130
+ try {
1131
+ fs.rmSync(standaloneDir, { recursive: true, force: true });
1132
+ }
1133
+ catch { }
1134
+ const lock = _readLockFile();
1135
+ delete lock[standaloneName];
1136
+ delete lock[monorepoPluginName];
1137
+ _writeLockFile(lock);
1138
+ vi.clearAllMocks();
1139
+ });
1140
+ it('keeps the existing standalone plugin when staged update preparation fails', () => {
1141
+ fs.mkdirSync(standaloneDir, { recursive: true });
1142
+ fs.writeFileSync(path.join(standaloneDir, 'old.yaml'), 'site: old\nname: old\n');
1143
+ const lock = _readLockFile();
1144
+ lock[standaloneName] = {
1145
+ source: {
1146
+ kind: 'git',
1147
+ url: 'https://github.com/user/opencli-plugin-__test-transactional-update__.git',
1148
+ },
1149
+ commitHash: 'oldhasholdhasholdhasholdhasholdhasholdh',
1150
+ installedAt: '2025-01-01T00:00:00.000Z',
1151
+ };
1152
+ _writeLockFile(lock);
1153
+ mockExecFileSync.mockImplementation((cmd, args) => {
1154
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1155
+ const cloneDir = String(args[4]);
1156
+ fs.mkdirSync(cloneDir, { recursive: true });
1157
+ fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
1158
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
1159
+ return '';
1160
+ }
1161
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1162
+ throw new Error('boom');
1163
+ }
1164
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1165
+ return '1234567890abcdef1234567890abcdef12345678\n';
1166
+ }
1167
+ return '';
1168
+ });
1169
+ expect(() => updatePlugin(standaloneName)).toThrow('npm install failed');
1170
+ expect(fs.existsSync(standaloneDir)).toBe(true);
1171
+ expect(fs.readFileSync(path.join(standaloneDir, 'old.yaml'), 'utf-8')).toContain('site: old');
1172
+ expect(_readLockFile()[standaloneName]?.commitHash).toBe('oldhasholdhasholdhasholdhasholdhasholdh');
1173
+ });
1174
+ it('keeps the existing monorepo repo and link when staged update preparation fails', () => {
1175
+ const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName);
1176
+ fs.mkdirSync(subDir, { recursive: true });
1177
+ fs.writeFileSync(path.join(subDir, 'old.yaml'), 'site: old\nname: old\n');
1178
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
1179
+ fs.symlinkSync(subDir, monorepoLink, 'dir');
1180
+ const lock = _readLockFile();
1181
+ lock[monorepoPluginName] = {
1182
+ source: {
1183
+ kind: 'monorepo',
1184
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1185
+ repoName: monorepoName,
1186
+ subPath: `packages/${monorepoPluginName}`,
1187
+ },
1188
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1189
+ installedAt: '2025-01-01T00:00:00.000Z',
1190
+ };
1191
+ _writeLockFile(lock);
1192
+ mockExecFileSync.mockImplementation((cmd, args) => {
1193
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1194
+ const cloneDir = String(args[4]);
1195
+ const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName);
1196
+ fs.mkdirSync(alphaDir, { recursive: true });
1197
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
1198
+ name: 'opencli-plugins-__test-transactional-mono-update__',
1199
+ private: true,
1200
+ }));
1201
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1202
+ plugins: {
1203
+ [monorepoPluginName]: { path: `packages/${monorepoPluginName}` },
1204
+ },
1205
+ }));
1206
+ fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
1207
+ return '';
1208
+ }
1209
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1210
+ throw new Error('boom');
1211
+ }
1212
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1213
+ return '1234567890abcdef1234567890abcdef12345678\n';
1214
+ }
1215
+ return '';
1216
+ });
1217
+ expect(() => updatePlugin(monorepoPluginName)).toThrow('npm install failed');
1218
+ expect(fs.existsSync(monorepoRepoDir)).toBe(true);
1219
+ expect(fs.existsSync(monorepoLink)).toBe(true);
1220
+ expect(fs.readFileSync(path.join(subDir, 'old.yaml'), 'utf-8')).toContain('site: old');
1221
+ expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe('oldmonooldmonooldmonooldmonooldmonoold');
1222
+ });
1223
+ it('relinks monorepo plugins when the updated manifest moves their subPath', () => {
1224
+ const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
1225
+ fs.mkdirSync(oldSubDir, { recursive: true });
1226
+ fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
1227
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
1228
+ fs.symlinkSync(oldSubDir, monorepoLink, 'dir');
1229
+ const lock = _readLockFile();
1230
+ lock[monorepoPluginName] = {
1231
+ source: {
1232
+ kind: 'monorepo',
1233
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1234
+ repoName: monorepoName,
1235
+ subPath: 'packages/old-alpha',
1236
+ },
1237
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1238
+ installedAt: '2025-01-01T00:00:00.000Z',
1239
+ };
1240
+ _writeLockFile(lock);
1241
+ mockExecFileSync.mockImplementation((cmd, args) => {
1242
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1243
+ const cloneDir = String(args[4]);
1244
+ const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
1245
+ fs.mkdirSync(movedDir, { recursive: true });
1246
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1247
+ plugins: {
1248
+ [monorepoPluginName]: { path: 'packages/moved-alpha' },
1249
+ },
1250
+ }));
1251
+ fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
1252
+ return '';
1253
+ }
1254
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1255
+ return '1234567890abcdef1234567890abcdef12345678\n';
1256
+ }
1257
+ return '';
1258
+ });
1259
+ updatePlugin(monorepoPluginName);
1260
+ const expectedTarget = path.join(monorepoRepoDir, 'packages', 'moved-alpha');
1261
+ expect(fs.realpathSync(monorepoLink)).toBe(fs.realpathSync(expectedTarget));
1262
+ expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
1263
+ kind: 'monorepo',
1264
+ subPath: 'packages/moved-alpha',
1265
+ });
1266
+ });
1267
+ it('rolls back the monorepo repo swap when relinking fails', () => {
1268
+ const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
1269
+ fs.mkdirSync(oldSubDir, { recursive: true });
1270
+ fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
1271
+ fs.mkdirSync(monorepoLink, { recursive: true });
1272
+ fs.writeFileSync(path.join(monorepoLink, 'blocker.txt'), 'not a symlink');
1273
+ const lock = _readLockFile();
1274
+ lock[monorepoPluginName] = {
1275
+ source: {
1276
+ kind: 'monorepo',
1277
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1278
+ repoName: monorepoName,
1279
+ subPath: 'packages/old-alpha',
1280
+ },
1281
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1282
+ installedAt: '2025-01-01T00:00:00.000Z',
1283
+ };
1284
+ _writeLockFile(lock);
1285
+ mockExecFileSync.mockImplementation((cmd, args) => {
1286
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1287
+ const cloneDir = String(args[4]);
1288
+ const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
1289
+ fs.mkdirSync(movedDir, { recursive: true });
1290
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1291
+ plugins: {
1292
+ [monorepoPluginName]: { path: 'packages/moved-alpha' },
1293
+ },
1294
+ }));
1295
+ fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
1296
+ return '';
1297
+ }
1298
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1299
+ return '1234567890abcdef1234567890abcdef12345678\n';
1300
+ }
1301
+ return '';
1302
+ });
1303
+ expect(() => updatePlugin(monorepoPluginName)).toThrow('to be a symlink');
1304
+ expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'old-alpha', 'old.yaml'))).toBe(true);
1305
+ expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'moved-alpha'))).toBe(false);
1306
+ expect(fs.readFileSync(path.join(monorepoLink, 'blocker.txt'), 'utf-8')).toBe('not a symlink');
1307
+ expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
1308
+ kind: 'monorepo',
1309
+ subPath: 'packages/old-alpha',
1310
+ });
1311
+ });
1312
+ });