plugin.test.ts
1 /** 2 * Tests for plugin management: install, uninstall, list, and lock file support. 3 */ 4 5 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 6 import * as fs from 'node:fs'; 7 import * as os from 'node:os'; 8 import * as path from 'node:path'; 9 import { pathToFileURL } from 'node:url'; 10 import { PLUGINS_DIR } from './discovery.js'; 11 import type { LockEntry } from './plugin.js'; 12 import * as pluginModule from './plugin.js'; 13 14 const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({ 15 mockExecFileSync: vi.fn(), 16 mockExecSync: vi.fn(), 17 })); 18 19 const { 20 _getCommitHash, 21 _installDependencies, 22 _postInstallMonorepoLifecycle, 23 installPlugin, 24 listPlugins, 25 _readLockFile, 26 _readLockFileWithWriter, 27 _resolveEsbuildBin, 28 _resolveHostOpencliRoot, 29 uninstallPlugin, 30 updatePlugin, 31 _parseSource, 32 _updateAllPlugins, 33 _validatePluginStructure, 34 _writeLockFile, 35 _writeLockFileWithFs, 36 _isSymlinkSync, 37 _getMonoreposDir, 38 getLockFilePath, 39 _installLocalPlugin, 40 _isLocalPluginSource, 41 _moveDir, 42 _resolvePluginSource, 43 _resolveStoredPluginSource, 44 _toStoredPluginSource, 45 _toLocalPluginSource, 46 } = pluginModule; 47 48 describe('parseSource', () => { 49 it('parses github:user/repo format', () => { 50 const result = _parseSource('github:ByteYue/opencli-plugin-github-trending'); 51 expect(result).toEqual({ 52 type: 'git', 53 cloneUrl: 'https://github.com/ByteYue/opencli-plugin-github-trending.git', 54 name: 'github-trending', 55 }); 56 }); 57 58 it('parses https URL format', () => { 59 const result = _parseSource('https://github.com/ByteYue/opencli-plugin-hot-digest'); 60 expect(result).toEqual({ 61 type: 'git', 62 cloneUrl: 'https://github.com/ByteYue/opencli-plugin-hot-digest.git', 63 name: 'hot-digest', 64 }); 65 }); 66 67 it('strips opencli-plugin- prefix from name', () => { 68 const result = _parseSource('github:user/opencli-plugin-my-tool'); 69 expect(result!.name).toBe('my-tool'); 70 }); 71 72 it('keeps name without prefix', () => { 73 const result = _parseSource('github:user/awesome-cli'); 74 expect(result!.name).toBe('awesome-cli'); 75 }); 76 77 it('returns null for invalid source', () => { 78 expect(_parseSource('invalid')).toBeNull(); 79 expect(_parseSource('npm:some-package')).toBeNull(); 80 }); 81 82 it('parses file:// local plugin directories', () => { 83 const localDir = path.join(os.tmpdir(), 'opencli-plugin-test'); 84 const fileUrl = pathToFileURL(localDir).href; 85 const result = _parseSource(fileUrl); 86 expect(result).toEqual({ 87 type: 'local', 88 localPath: localDir, 89 name: 'test', 90 }); 91 }); 92 93 it('parses plain absolute local plugin directories', () => { 94 const localDir = path.join(os.tmpdir(), 'my-plugin'); 95 const result = _parseSource(localDir); 96 expect(result).toEqual({ 97 type: 'local', 98 localPath: localDir, 99 name: 'my-plugin', 100 }); 101 }); 102 103 it('strips opencli-plugin- prefix for local paths', () => { 104 const localDir = path.join(os.tmpdir(), 'opencli-plugin-foo'); 105 const result = _parseSource(localDir); 106 expect(result!.name).toBe('foo'); 107 }); 108 109 // ── Generic git URL support ── 110 it('parses ssh:// URLs', () => { 111 const result = _parseSource('ssh://git@gitlab.com/team/opencli-plugin-tools.git'); 112 expect(result).toEqual({ 113 type: 'git', 114 cloneUrl: 'ssh://git@gitlab.com/team/opencli-plugin-tools.git', 115 name: 'tools', 116 }); 117 }); 118 119 it('parses ssh:// URLs without .git suffix', () => { 120 const result = _parseSource('ssh://git@gitlab.com/team/my-plugin'); 121 expect(result).toEqual({ 122 type: 'git', 123 cloneUrl: 'ssh://git@gitlab.com/team/my-plugin', 124 name: 'my-plugin', 125 }); 126 }); 127 128 it('parses git@ SCP-style URLs', () => { 129 const result = _parseSource('git@gitlab.com:team/my-plugin.git'); 130 expect(result).toEqual({ 131 type: 'git', 132 cloneUrl: 'git@gitlab.com:team/my-plugin.git', 133 name: 'my-plugin', 134 }); 135 }); 136 137 it('parses git@ SCP-style URLs and strips opencli-plugin- prefix', () => { 138 const result = _parseSource('git@github.com:user/opencli-plugin-awesome.git'); 139 expect(result).toEqual({ 140 type: 'git', 141 cloneUrl: 'git@github.com:user/opencli-plugin-awesome.git', 142 name: 'awesome', 143 }); 144 }); 145 146 it('parses generic HTTPS git URLs (non-GitHub)', () => { 147 const result = _parseSource('https://codehub.example.com/Team/App/opencli-plugins-app.git'); 148 expect(result).toEqual({ 149 type: 'git', 150 cloneUrl: 'https://codehub.example.com/Team/App/opencli-plugins-app.git', 151 name: 'opencli-plugins-app', 152 }); 153 }); 154 155 it('parses generic HTTPS git URLs without .git suffix', () => { 156 const result = _parseSource('https://gitlab.example.com/org/my-plugin'); 157 expect(result).toEqual({ 158 type: 'git', 159 cloneUrl: 'https://gitlab.example.com/org/my-plugin.git', 160 name: 'my-plugin', 161 }); 162 }); 163 164 it('still prefers GitHub shorthand over generic HTTPS for github.com', () => { 165 const result = _parseSource('https://github.com/user/repo'); 166 // Should be handled by the GitHub-specific matcher (normalizes URL) 167 expect(result).toEqual({ 168 type: 'git', 169 cloneUrl: 'https://github.com/user/repo.git', 170 name: 'repo', 171 }); 172 }); 173 }); 174 175 describe('validatePluginStructure', () => { 176 const testDir = path.join(PLUGINS_DIR, '__test-validate__'); 177 178 beforeEach(() => { 179 fs.mkdirSync(testDir, { recursive: true }); 180 }); 181 182 afterEach(() => { 183 try { fs.rmSync(testDir, { recursive: true }); } catch {} 184 }); 185 186 it('returns invalid for non-existent directory', () => { 187 const res = _validatePluginStructure(path.join(PLUGINS_DIR, '__does_not_exist__')); 188 expect(res.valid).toBe(false); 189 expect(res.errors[0]).toContain('does not exist'); 190 }); 191 192 it('returns invalid for empty directory', () => { 193 const res = _validatePluginStructure(testDir); 194 expect(res.valid).toBe(false); 195 expect(res.errors[0]).toContain('No command files found'); 196 }); 197 198 it('returns invalid for YAML-only plugin (YAML no longer supported)', () => { 199 fs.writeFileSync(path.join(testDir, 'cmd.yaml'), 'site: test'); 200 const res = _validatePluginStructure(testDir); 201 expect(res.valid).toBe(false); 202 expect(res.errors[0]).toContain('No command files found'); 203 }); 204 205 it('returns valid for JS plugin', () => { 206 fs.writeFileSync(path.join(testDir, 'cmd.js'), 'console.log("hi");'); 207 const res = _validatePluginStructure(testDir); 208 expect(res.valid).toBe(true); 209 expect(res.errors).toHaveLength(0); 210 }); 211 212 it('returns invalid for TS plugin without package.json', () => { 213 fs.writeFileSync(path.join(testDir, 'cmd.ts'), 'console.log("hi");'); 214 const res = _validatePluginStructure(testDir); 215 expect(res.valid).toBe(false); 216 expect(res.errors[0]).toContain('contains .ts files but no package.json'); 217 }); 218 219 it('returns invalid for TS plugin with missing type: module', () => { 220 fs.writeFileSync(path.join(testDir, 'cmd.ts'), 'console.log("hi");'); 221 fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({ name: 'test' })); 222 const res = _validatePluginStructure(testDir); 223 expect(res.valid).toBe(false); 224 expect(res.errors[0]).toContain('must have "type": "module"'); 225 }); 226 227 it('returns valid for TS plugin with correct package.json', () => { 228 fs.writeFileSync(path.join(testDir, 'cmd.ts'), 'console.log("hi");'); 229 fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({ type: 'module' })); 230 const res = _validatePluginStructure(testDir); 231 expect(res.valid).toBe(true); 232 expect(res.errors).toHaveLength(0); 233 }); 234 }); 235 236 describe('lock file', () => { 237 const backupPath = `${getLockFilePath()}.test-backup`; 238 let hadOriginal = false; 239 240 beforeEach(() => { 241 hadOriginal = fs.existsSync(getLockFilePath()); 242 if (hadOriginal) { 243 fs.mkdirSync(path.dirname(backupPath), { recursive: true }); 244 fs.copyFileSync(getLockFilePath(), backupPath); 245 } 246 }); 247 248 afterEach(() => { 249 if (hadOriginal) { 250 fs.copyFileSync(backupPath, getLockFilePath()); 251 fs.unlinkSync(backupPath); 252 return; 253 } 254 try { fs.unlinkSync(getLockFilePath()); } catch {} 255 }); 256 257 it('reads empty lock when file does not exist', () => { 258 try { fs.unlinkSync(getLockFilePath()); } catch {} 259 expect(_readLockFile()).toEqual({}); 260 }); 261 262 it('round-trips lock entries', () => { 263 const entries: Record<string, LockEntry> = { 264 'test-plugin': { 265 source: { kind: 'git', url: 'https://github.com/user/repo.git' }, 266 commitHash: 'abc1234567890def', 267 installedAt: '2025-01-01T00:00:00.000Z', 268 }, 269 'another-plugin': { 270 source: { kind: 'git', url: 'https://github.com/user/another.git' }, 271 commitHash: 'def4567890123abc', 272 installedAt: '2025-02-01T00:00:00.000Z', 273 updatedAt: '2025-03-01T00:00:00.000Z', 274 }, 275 }; 276 277 _writeLockFile(entries); 278 expect(_readLockFile()).toEqual(entries); 279 }); 280 281 it('handles malformed lock file gracefully', () => { 282 fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true }); 283 fs.writeFileSync(getLockFilePath(), 'not valid json'); 284 expect(_readLockFile()).toEqual({}); 285 }); 286 287 it('keeps the previous lockfile contents when atomic rewrite fails', () => { 288 const existing: Record<string, LockEntry> = { 289 stable: { 290 source: { kind: 'git', url: 'https://github.com/user/stable.git' }, 291 commitHash: 'stable1234567890', 292 installedAt: '2025-01-01T00:00:00.000Z', 293 }, 294 }; 295 _writeLockFile(existing); 296 297 const renameSync = vi.fn(() => { 298 throw new Error('rename failed'); 299 }); 300 const rmSync = vi.fn(() => undefined); 301 302 expect(() => _writeLockFileWithFs({ 303 broken: { 304 source: { kind: 'git', url: 'https://github.com/user/broken.git' }, 305 commitHash: 'broken1234567890', 306 installedAt: '2025-02-01T00:00:00.000Z', 307 }, 308 }, { 309 mkdirSync: fs.mkdirSync, 310 writeFileSync: fs.writeFileSync, 311 renameSync, 312 rmSync, 313 })).toThrow('rename failed'); 314 315 expect(_readLockFile()).toEqual(existing); 316 expect(rmSync).toHaveBeenCalledTimes(1); 317 }); 318 319 it('migrates legacy string sources to structured sources on read', () => { 320 const legacyLocalPath = path.resolve(path.join(os.tmpdir(), 'opencli-legacy-local-plugin')); 321 fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true }); 322 fs.writeFileSync(getLockFilePath(), JSON.stringify({ 323 alpha: { 324 source: 'https://github.com/user/opencli-plugins.git', 325 commitHash: 'abc1234567890def', 326 installedAt: '2025-01-01T00:00:00.000Z', 327 monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' }, 328 }, 329 beta: { 330 source: `local:${legacyLocalPath}`, 331 commitHash: 'local', 332 installedAt: '2025-01-01T00:00:00.000Z', 333 }, 334 }, null, 2)); 335 336 expect(_readLockFile()).toEqual({ 337 alpha: { 338 source: { 339 kind: 'monorepo', 340 url: 'https://github.com/user/opencli-plugins.git', 341 repoName: 'opencli-plugins', 342 subPath: 'packages/alpha', 343 }, 344 commitHash: 'abc1234567890def', 345 installedAt: '2025-01-01T00:00:00.000Z', 346 }, 347 beta: { 348 source: { kind: 'local', path: legacyLocalPath }, 349 commitHash: 'local', 350 installedAt: '2025-01-01T00:00:00.000Z', 351 }, 352 }); 353 }); 354 355 it('returns normalized entries even when migration rewrite fails', () => { 356 fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true }); 357 fs.writeFileSync(getLockFilePath(), JSON.stringify({ 358 alpha: { 359 source: 'https://github.com/user/opencli-plugins.git', 360 commitHash: 'abc1234567890def', 361 installedAt: '2025-01-01T00:00:00.000Z', 362 monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' }, 363 }, 364 }, null, 2)); 365 366 expect(_readLockFileWithWriter(() => { 367 throw new Error('disk full'); 368 })).toEqual({ 369 alpha: { 370 source: { 371 kind: 'monorepo', 372 url: 'https://github.com/user/opencli-plugins.git', 373 repoName: 'opencli-plugins', 374 subPath: 'packages/alpha', 375 }, 376 commitHash: 'abc1234567890def', 377 installedAt: '2025-01-01T00:00:00.000Z', 378 }, 379 }); 380 }); 381 }); 382 383 describe('getCommitHash', () => { 384 it('returns a hash for a git repo', () => { 385 const hash = _getCommitHash(process.cwd()); 386 expect(hash).toBeDefined(); 387 expect(hash).toMatch(/^[0-9a-f]{40}$/); 388 }); 389 390 it('returns undefined for non-git directory', () => { 391 expect(_getCommitHash(os.tmpdir())).toBeUndefined(); 392 }); 393 }); 394 395 describe('resolveEsbuildBin', () => { 396 it('resolves a usable esbuild executable path', () => { 397 const binPath = _resolveEsbuildBin(); 398 expect(binPath).not.toBeNull(); 399 expect(typeof binPath).toBe('string'); 400 expect(fs.existsSync(binPath!)).toBe(true); 401 expect(binPath).toMatch(/esbuild(\.cmd)?$/); 402 }); 403 }); 404 405 describe('resolveHostOpencliRoot', () => { 406 let tmpDir: string; 407 408 beforeEach(() => { 409 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-host-root-test-')); 410 }); 411 412 afterEach(() => { 413 fs.rmSync(tmpDir, { recursive: true, force: true }); 414 }); 415 416 it('walks up from compiled dist/src files to the package root', () => { 417 fs.writeFileSync( 418 path.join(tmpDir, 'package.json'), 419 JSON.stringify({ name: '@jackwener/opencli' }), 420 ); 421 const distSrcDir = path.join(tmpDir, 'dist', 'src'); 422 fs.mkdirSync(distSrcDir, { recursive: true }); 423 424 expect(_resolveHostOpencliRoot(path.join(distSrcDir, 'plugin.js'))).toBe(tmpDir); 425 }); 426 }); 427 428 describe('listPlugins', () => { 429 const testDir = path.join(PLUGINS_DIR, '__test-list-plugin__'); 430 431 afterEach(() => { 432 try { fs.rmSync(testDir, { recursive: true }); } catch {} 433 }); 434 435 it('lists installed plugins', () => { 436 fs.mkdirSync(testDir, { recursive: true }); 437 fs.writeFileSync(path.join(testDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 438 439 const plugins = listPlugins(); 440 const found = plugins.find(p => p.name === '__test-list-plugin__'); 441 expect(found).toBeDefined(); 442 expect(found!.commands).toContain('hello'); 443 }); 444 445 it('includes version metadata from the lock file', () => { 446 fs.mkdirSync(testDir, { recursive: true }); 447 fs.writeFileSync(path.join(testDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 448 449 const lock = _readLockFile(); 450 lock['__test-list-plugin__'] = { 451 source: { kind: 'git', url: 'https://github.com/user/repo.git' }, 452 commitHash: 'abcdef1234567890abcdef1234567890abcdef12', 453 installedAt: '2025-01-01T00:00:00.000Z', 454 }; 455 _writeLockFile(lock); 456 457 const plugins = listPlugins(); 458 const found = plugins.find(p => p.name === '__test-list-plugin__'); 459 expect(found).toBeDefined(); 460 expect(found!.version).toBe('abcdef1'); 461 expect(found!.installedAt).toBe('2025-01-01T00:00:00.000Z'); 462 463 delete lock['__test-list-plugin__']; 464 _writeLockFile(lock); 465 }); 466 467 it('returns empty array when no plugins dir', () => { 468 const plugins = listPlugins(); 469 expect(Array.isArray(plugins)).toBe(true); 470 }); 471 472 it('prefers lockfile source for local symlink plugins', () => { 473 const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-list-')); 474 const linkPath = path.join(PLUGINS_DIR, '__test-list-plugin__'); 475 476 fs.mkdirSync(PLUGINS_DIR, { recursive: true }); 477 fs.writeFileSync(path.join(localTarget, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 478 try { fs.unlinkSync(linkPath); } catch {} 479 try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch {} 480 fs.symlinkSync(localTarget, linkPath, 'dir'); 481 482 const lock = _readLockFile(); 483 lock['__test-list-plugin__'] = { 484 source: { kind: 'local', path: localTarget }, 485 commitHash: 'local', 486 installedAt: '2025-01-01T00:00:00.000Z', 487 }; 488 _writeLockFile(lock); 489 490 const plugins = listPlugins(); 491 const found = plugins.find(p => p.name === '__test-list-plugin__'); 492 expect(found?.source).toBe(`local:${localTarget}`); 493 494 try { fs.unlinkSync(linkPath); } catch {} 495 try { fs.rmSync(localTarget, { recursive: true, force: true }); } catch {} 496 delete lock['__test-list-plugin__']; 497 _writeLockFile(lock); 498 }); 499 }); 500 501 describe('uninstallPlugin', () => { 502 const testDir = path.join(PLUGINS_DIR, '__test-uninstall__'); 503 504 afterEach(() => { 505 try { fs.rmSync(testDir, { recursive: true }); } catch {} 506 }); 507 508 it('removes plugin directory', () => { 509 fs.mkdirSync(testDir, { recursive: true }); 510 fs.writeFileSync(path.join(testDir, 'test.js'), 'cli({ site: "test", name: "test" })'); 511 512 uninstallPlugin('__test-uninstall__'); 513 expect(fs.existsSync(testDir)).toBe(false); 514 }); 515 516 it('removes lock entry on uninstall', () => { 517 fs.mkdirSync(testDir, { recursive: true }); 518 fs.writeFileSync(path.join(testDir, 'test.js'), 'cli({ site: "test", name: "test" })'); 519 520 const lock = _readLockFile(); 521 lock['__test-uninstall__'] = { 522 source: { kind: 'git', url: 'https://github.com/user/repo.git' }, 523 commitHash: 'abc123', 524 installedAt: '2025-01-01T00:00:00.000Z', 525 }; 526 _writeLockFile(lock); 527 528 uninstallPlugin('__test-uninstall__'); 529 expect(_readLockFile()['__test-uninstall__']).toBeUndefined(); 530 }); 531 532 it('throws for non-existent plugin', () => { 533 expect(() => uninstallPlugin('__nonexistent__')).toThrow('not installed'); 534 }); 535 }); 536 537 describe('updatePlugin', () => { 538 it('throws for non-existent plugin', () => { 539 expect(() => updatePlugin('__nonexistent__')).toThrow('not installed'); 540 }); 541 542 it('refreshes local plugins without running git pull', () => { 543 const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-update-')); 544 const linkPath = path.join(PLUGINS_DIR, '__test-local-update__'); 545 546 fs.mkdirSync(PLUGINS_DIR, { recursive: true }); 547 fs.writeFileSync(path.join(localTarget, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 548 fs.symlinkSync(localTarget, linkPath, 'dir'); 549 550 const lock = _readLockFile(); 551 lock['__test-local-update__'] = { 552 source: { kind: 'local', path: localTarget }, 553 commitHash: 'local', 554 installedAt: '2025-01-01T00:00:00.000Z', 555 }; 556 _writeLockFile(lock); 557 558 mockExecFileSync.mockClear(); 559 updatePlugin('__test-local-update__'); 560 561 expect( 562 mockExecFileSync.mock.calls.some( 563 ([cmd, args, opts]) => cmd === 'git' 564 && Array.isArray(args) 565 && args[0] === 'pull' 566 && opts?.cwd === linkPath, 567 ), 568 ).toBe(false); 569 570 const updated = _readLockFile()['__test-local-update__']; 571 expect(updated?.source).toEqual({ kind: 'local', path: path.resolve(localTarget) }); 572 expect(updated?.updatedAt).toBeDefined(); 573 574 try { fs.unlinkSync(linkPath); } catch {} 575 try { fs.rmSync(localTarget, { recursive: true, force: true }); } catch {} 576 const finalLock = _readLockFile(); 577 delete finalLock['__test-local-update__']; 578 _writeLockFile(finalLock); 579 }); 580 }); 581 582 vi.mock('node:child_process', () => { 583 return { 584 execFileSync: mockExecFileSync.mockImplementation((_cmd, args, opts) => { 585 if (Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 586 if (opts?.cwd === os.tmpdir()) { 587 throw new Error('not a git repository'); 588 } 589 return '1234567890abcdef1234567890abcdef12345678\n'; 590 } 591 if (opts && opts.cwd && String(opts.cwd).endsWith('plugin-b')) { 592 throw new Error('Network error'); 593 } 594 return ''; 595 }), 596 execSync: mockExecSync.mockImplementation(() => ''), 597 }; 598 }); 599 600 describe('installDependencies', () => { 601 beforeEach(() => { 602 mockExecFileSync.mockClear(); 603 mockExecSync.mockClear(); 604 }); 605 606 it('throws when npm install fails', () => { 607 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-plugin-b-')); 608 const failingDir = path.join(tmpDir, 'plugin-b'); 609 fs.mkdirSync(failingDir, { recursive: true }); 610 fs.writeFileSync(path.join(failingDir, 'package.json'), JSON.stringify({ name: 'plugin-b' })); 611 612 expect(() => _installDependencies(failingDir)).toThrow('npm install failed'); 613 614 fs.rmSync(tmpDir, { recursive: true, force: true }); 615 }); 616 }); 617 618 describe('postInstallMonorepoLifecycle', () => { 619 let repoDir: string; 620 let subDir: string; 621 622 beforeEach(() => { 623 mockExecFileSync.mockClear(); 624 mockExecSync.mockClear(); 625 repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-monorepo-')); 626 subDir = path.join(repoDir, 'packages', 'alpha'); 627 fs.mkdirSync(subDir, { recursive: true }); 628 fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({ 629 name: 'opencli-plugins', 630 private: true, 631 workspaces: ['packages/*'], 632 })); 633 fs.writeFileSync(path.join(subDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 634 }); 635 636 afterEach(() => { 637 fs.rmSync(repoDir, { recursive: true, force: true }); 638 }); 639 640 it('installs dependencies at the monorepo root and skips sub-plugins without own dependencies', () => { 641 _postInstallMonorepoLifecycle(repoDir, [subDir]); 642 643 const npmCalls = mockExecFileSync.mock.calls.filter( 644 ([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install', 645 ); 646 647 expect(npmCalls).toHaveLength(1); 648 expect(npmCalls[0][2]).toMatchObject({ cwd: repoDir }); 649 expect(npmCalls.some(([, , opts]) => opts?.cwd === subDir)).toBe(false); 650 }); 651 652 it('also installs dependencies in sub-plugins that declare their own production dependencies', () => { 653 // Give the sub-plugin its own production dependencies 654 fs.writeFileSync(path.join(subDir, 'package.json'), JSON.stringify({ 655 name: 'opencli-plugin-alpha', 656 version: '1.0.0', 657 type: 'module', 658 dependencies: { undici: '^8.0.0' }, 659 })); 660 661 _postInstallMonorepoLifecycle(repoDir, [subDir]); 662 663 const npmCalls = mockExecFileSync.mock.calls.filter( 664 ([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install', 665 ); 666 667 expect(npmCalls).toHaveLength(2); 668 expect(npmCalls[0][2]).toMatchObject({ cwd: repoDir }); 669 expect(npmCalls[1][2]).toMatchObject({ cwd: subDir }); 670 }); 671 }); 672 673 describe('updateAllPlugins', () => { 674 const testDirA = path.join(PLUGINS_DIR, 'plugin-a'); 675 const testDirB = path.join(PLUGINS_DIR, 'plugin-b'); 676 const testDirC = path.join(PLUGINS_DIR, 'plugin-c'); 677 678 beforeEach(() => { 679 fs.mkdirSync(testDirA, { recursive: true }); 680 fs.mkdirSync(testDirB, { recursive: true }); 681 fs.mkdirSync(testDirC, { recursive: true }); 682 fs.writeFileSync(path.join(testDirA, 'cmd.js'), 'cli({ site: "a", name: "cmd" })'); 683 fs.writeFileSync(path.join(testDirB, 'cmd.js'), 'cli({ site: "b", name: "cmd" })'); 684 fs.writeFileSync(path.join(testDirC, 'cmd.js'), 'cli({ site: "c", name: "cmd" })'); 685 686 const lock = _readLockFile(); 687 lock['plugin-a'] = { 688 source: { kind: 'git', url: 'https://github.com/user/plugin-a.git' }, 689 commitHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 690 installedAt: '2025-01-01T00:00:00.000Z', 691 }; 692 lock['plugin-b'] = { 693 source: { kind: 'git', url: 'https://github.com/user/plugin-b.git' }, 694 commitHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 695 installedAt: '2025-01-01T00:00:00.000Z', 696 }; 697 lock['plugin-c'] = { 698 source: { kind: 'git', url: 'https://github.com/user/plugin-c.git' }, 699 commitHash: 'cccccccccccccccccccccccccccccccccccccccc', 700 installedAt: '2025-01-01T00:00:00.000Z', 701 }; 702 _writeLockFile(lock); 703 }); 704 705 afterEach(() => { 706 try { fs.rmSync(testDirA, { recursive: true }); } catch {} 707 try { fs.rmSync(testDirB, { recursive: true }); } catch {} 708 try { fs.rmSync(testDirC, { recursive: true }); } catch {} 709 const lock = _readLockFile(); 710 delete lock['plugin-a']; 711 delete lock['plugin-b']; 712 delete lock['plugin-c']; 713 _writeLockFile(lock); 714 vi.clearAllMocks(); 715 }); 716 717 it('collects successes and failures without throwing', () => { 718 mockExecFileSync.mockImplementation((cmd, args) => { 719 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 720 const cloneUrl = String(args[3]); 721 const cloneDir = String(args[4]); 722 fs.mkdirSync(cloneDir, { recursive: true }); 723 fs.writeFileSync(path.join(cloneDir, 'cmd.js'), 'cli({ site: "test", name: "hello" })'); 724 if (cloneUrl.includes('plugin-b')) { 725 fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: 'plugin-b' })); 726 } 727 return ''; 728 } 729 if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') { 730 throw new Error('Network error'); 731 } 732 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 733 return '1234567890abcdef1234567890abcdef12345678\n'; 734 } 735 return ''; 736 }); 737 738 const results = _updateAllPlugins(); 739 740 const resA = results.find(r => r.name === 'plugin-a'); 741 const resB = results.find(r => r.name === 'plugin-b'); 742 const resC = results.find(r => r.name === 'plugin-c'); 743 744 expect(resA).toBeDefined(); 745 expect(resA!.success).toBe(true); 746 747 expect(resB).toBeDefined(); 748 expect(resB!.success).toBe(false); 749 expect(resB!.error).toContain('Network error'); 750 751 expect(resC).toBeDefined(); 752 expect(resC!.success).toBe(true); 753 }); 754 }); 755 756 // ── Monorepo-specific tests ───────────────────────────────────────────────── 757 758 describe('parseSource with monorepo subplugin', () => { 759 it('parses github:user/repo/subplugin format', () => { 760 const result = _parseSource('github:ByteYue/opencli-plugins/polymarket'); 761 expect(result).toEqual({ 762 type: 'git', 763 cloneUrl: 'https://github.com/ByteYue/opencli-plugins.git', 764 name: 'opencli-plugins', 765 subPlugin: 'polymarket', 766 }); 767 }); 768 769 it('strips opencli-plugin- prefix from repo name in subplugin format', () => { 770 const result = _parseSource('github:user/opencli-plugin-collection/defi'); 771 expect(result!.name).toBe('collection'); 772 expect(result!.subPlugin).toBe('defi'); 773 }); 774 775 it('still parses github:user/repo without subplugin', () => { 776 const result = _parseSource('github:user/my-repo'); 777 expect(result).toEqual({ 778 type: 'git', 779 cloneUrl: 'https://github.com/user/my-repo.git', 780 name: 'my-repo', 781 }); 782 expect(result!.subPlugin).toBeUndefined(); 783 }); 784 }); 785 786 describe('isSymlinkSync', () => { 787 let tmpDir: string; 788 789 beforeEach(() => { 790 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-symlink-test-')); 791 }); 792 793 afterEach(() => { 794 fs.rmSync(tmpDir, { recursive: true, force: true }); 795 }); 796 797 it('returns false for a regular directory', () => { 798 const dir = path.join(tmpDir, 'regular'); 799 fs.mkdirSync(dir); 800 expect(_isSymlinkSync(dir)).toBe(false); 801 }); 802 803 it('returns true for a symlink', () => { 804 const target = path.join(tmpDir, 'target'); 805 const link = path.join(tmpDir, 'link'); 806 fs.mkdirSync(target); 807 fs.symlinkSync(target, link, 'dir'); 808 expect(_isSymlinkSync(link)).toBe(true); 809 }); 810 811 it('returns false for non-existent path', () => { 812 expect(_isSymlinkSync(path.join(tmpDir, 'nope'))).toBe(false); 813 }); 814 }); 815 816 describe('monorepo uninstall with symlink', () => { 817 let tmpDir: string; 818 let pluginDir: string; 819 let monoDir: string; 820 821 beforeEach(() => { 822 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-mono-uninstall-')); 823 pluginDir = path.join(PLUGINS_DIR, '__test-mono-sub__'); 824 monoDir = path.join(_getMonoreposDir(), '__test-mono__'); 825 826 const subDir = path.join(monoDir, 'packages', 'sub'); 827 fs.mkdirSync(subDir, { recursive: true }); 828 fs.writeFileSync(path.join(subDir, 'cmd.js'), 'cli({ site: "test", name: "cmd" })'); 829 830 fs.mkdirSync(PLUGINS_DIR, { recursive: true }); 831 fs.symlinkSync(subDir, pluginDir, 'dir'); 832 833 const lock = _readLockFile(); 834 lock['__test-mono-sub__'] = { 835 source: { 836 kind: 'monorepo', 837 url: 'https://github.com/user/test.git', 838 repoName: '__test-mono__', 839 subPath: 'packages/sub', 840 }, 841 commitHash: 'abc123', 842 installedAt: '2025-01-01T00:00:00.000Z', 843 }; 844 _writeLockFile(lock); 845 }); 846 847 afterEach(() => { 848 try { fs.unlinkSync(pluginDir); } catch {} 849 try { fs.rmSync(pluginDir, { recursive: true, force: true }); } catch {} 850 try { fs.rmSync(monoDir, { recursive: true, force: true }); } catch {} 851 const lock = _readLockFile(); 852 delete lock['__test-mono-sub__']; 853 _writeLockFile(lock); 854 }); 855 856 it('removes symlink but keeps monorepo if other sub-plugins reference it', () => { 857 const lock = _readLockFile(); 858 lock['__test-mono-other__'] = { 859 source: { 860 kind: 'monorepo', 861 url: 'https://github.com/user/test.git', 862 repoName: '__test-mono__', 863 subPath: 'packages/other', 864 }, 865 commitHash: 'abc123', 866 installedAt: '2025-01-01T00:00:00.000Z', 867 }; 868 _writeLockFile(lock); 869 870 uninstallPlugin('__test-mono-sub__'); 871 872 expect(fs.existsSync(pluginDir)).toBe(false); 873 expect(fs.existsSync(monoDir)).toBe(true); 874 expect(_readLockFile()['__test-mono-sub__']).toBeUndefined(); 875 expect(_readLockFile()['__test-mono-other__']).toBeDefined(); 876 877 const finalLock = _readLockFile(); 878 delete finalLock['__test-mono-other__']; 879 _writeLockFile(finalLock); 880 }); 881 882 it('removes symlink AND monorepo dir when last sub-plugin is uninstalled', () => { 883 uninstallPlugin('__test-mono-sub__'); 884 885 expect(fs.existsSync(pluginDir)).toBe(false); 886 expect(fs.existsSync(monoDir)).toBe(false); 887 expect(_readLockFile()['__test-mono-sub__']).toBeUndefined(); 888 }); 889 }); 890 891 describe('listPlugins with monorepo metadata', () => { 892 const testSymlinkTarget = path.join(os.tmpdir(), 'opencli-list-mono-target'); 893 const testLink = path.join(PLUGINS_DIR, '__test-mono-list__'); 894 895 beforeEach(() => { 896 fs.mkdirSync(testSymlinkTarget, { recursive: true }); 897 fs.writeFileSync(path.join(testSymlinkTarget, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 898 899 fs.mkdirSync(PLUGINS_DIR, { recursive: true }); 900 try { fs.unlinkSync(testLink); } catch {} 901 fs.symlinkSync(testSymlinkTarget, testLink, 'dir'); 902 903 const lock = _readLockFile(); 904 lock['__test-mono-list__'] = { 905 source: { 906 kind: 'monorepo', 907 url: 'https://github.com/user/test-mono.git', 908 repoName: 'test-mono', 909 subPath: 'packages/list', 910 }, 911 commitHash: 'def456def456def456def456def456def456def4', 912 installedAt: '2025-01-01T00:00:00.000Z', 913 }; 914 _writeLockFile(lock); 915 }); 916 917 afterEach(() => { 918 try { fs.unlinkSync(testLink); } catch {} 919 try { fs.rmSync(testSymlinkTarget, { recursive: true, force: true }); } catch {} 920 const lock = _readLockFile(); 921 delete lock['__test-mono-list__']; 922 _writeLockFile(lock); 923 }); 924 925 it('lists symlinked plugins with monorepoName', () => { 926 const plugins = listPlugins(); 927 const found = plugins.find(p => p.name === '__test-mono-list__'); 928 expect(found).toBeDefined(); 929 expect(found!.monorepoName).toBe('test-mono'); 930 expect(found!.commands).toContain('hello'); 931 expect(found!.source).toBe('https://github.com/user/test-mono.git'); 932 }); 933 }); 934 935 describe('installLocalPlugin', () => { 936 let tmpDir: string; 937 const pluginName = '__test-local-plugin__'; 938 939 beforeEach(() => { 940 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-install-')); 941 fs.writeFileSync(path.join(tmpDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 942 }); 943 944 afterEach(() => { 945 const linkPath = path.join(PLUGINS_DIR, pluginName); 946 try { fs.unlinkSync(linkPath); } catch {} 947 try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} 948 const lock = _readLockFile(); 949 delete lock[pluginName]; 950 _writeLockFile(lock); 951 }); 952 953 it('creates a symlink to the local directory', () => { 954 const result = _installLocalPlugin(tmpDir, pluginName); 955 expect(result).toBe(pluginName); 956 const linkPath = path.join(PLUGINS_DIR, pluginName); 957 expect(fs.existsSync(linkPath)).toBe(true); 958 expect(_isSymlinkSync(linkPath)).toBe(true); 959 }); 960 961 it('records local: source in lockfile', () => { 962 _installLocalPlugin(tmpDir, pluginName); 963 const lock = _readLockFile(); 964 expect(lock[pluginName]).toBeDefined(); 965 expect(lock[pluginName].source).toEqual({ kind: 'local', path: path.resolve(tmpDir) }); 966 }); 967 968 it('lists the recorded local source', () => { 969 _installLocalPlugin(tmpDir, pluginName); 970 const plugins = listPlugins(); 971 const found = plugins.find(p => p.name === pluginName); 972 expect(found).toBeDefined(); 973 expect(found!.source).toBe(`local:${path.resolve(tmpDir)}`); 974 }); 975 976 it('throws for non-existent path', () => { 977 expect(() => _installLocalPlugin('/does/not/exist', 'x')).toThrow('does not exist'); 978 }); 979 }); 980 981 describe('isLocalPluginSource', () => { 982 it('detects lockfile local sources', () => { 983 expect(_isLocalPluginSource('local:/tmp/plugin')).toBe(true); 984 expect(_isLocalPluginSource('https://github.com/user/repo.git')).toBe(false); 985 expect(_isLocalPluginSource(undefined)).toBe(false); 986 }); 987 }); 988 989 describe('plugin source helpers', () => { 990 it('formats local plugin sources consistently', () => { 991 const dir = path.join(os.tmpdir(), 'opencli-plugin-source'); 992 expect(_toLocalPluginSource(dir)).toBe(`local:${path.resolve(dir)}`); 993 }); 994 995 it('serializes structured local sources consistently', () => { 996 const dir = path.join(os.tmpdir(), 'opencli-plugin-source'); 997 expect(_toStoredPluginSource({ kind: 'local', path: dir })).toBe(`local:${path.resolve(dir)}`); 998 }); 999 1000 it('prefers lockfile source over git remote lookup', () => { 1001 const dir = path.join(os.tmpdir(), 'opencli-plugin-source'); 1002 const localPath = path.resolve(path.join(os.tmpdir(), 'opencli-plugin-source-local')); 1003 const source = _resolveStoredPluginSource({ 1004 source: { kind: 'local', path: localPath }, 1005 commitHash: 'local', 1006 installedAt: '2025-01-01T00:00:00.000Z', 1007 }, dir); 1008 expect(source).toBe(`local:${localPath}`); 1009 }); 1010 1011 it('returns structured monorepo sources unchanged', () => { 1012 const dir = path.join(os.tmpdir(), 'opencli-plugin-source'); 1013 const source = _resolvePluginSource({ 1014 source: { 1015 kind: 'monorepo', 1016 url: 'https://github.com/user/opencli-plugins.git', 1017 repoName: 'opencli-plugins', 1018 subPath: 'packages/alpha', 1019 }, 1020 commitHash: 'abcdef1234567890abcdef1234567890abcdef12', 1021 installedAt: '2025-01-01T00:00:00.000Z', 1022 }, dir); 1023 expect(source).toEqual({ 1024 kind: 'monorepo', 1025 url: 'https://github.com/user/opencli-plugins.git', 1026 repoName: 'opencli-plugins', 1027 subPath: 'packages/alpha', 1028 }); 1029 }); 1030 }); 1031 1032 describe('moveDir', () => { 1033 it('cleans up destination when EXDEV fallback copy fails', () => { 1034 const src = path.join(os.tmpdir(), 'opencli-move-src'); 1035 const dest = path.join(os.tmpdir(), 'opencli-move-dest'); 1036 const renameErr = Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' }); 1037 const copyErr = new Error('copy failed'); 1038 const renameSync = vi.fn(() => { throw renameErr; }); 1039 const cpSync = vi.fn(() => { throw copyErr; }); 1040 const rmSync = vi.fn(() => undefined); 1041 1042 expect(() => _moveDir(src, dest, { renameSync, cpSync, rmSync })).toThrow(copyErr); 1043 expect(renameSync).toHaveBeenCalledWith(src, dest); 1044 expect(cpSync).toHaveBeenCalledWith(src, dest, { recursive: true }); 1045 expect(rmSync).toHaveBeenCalledWith(dest, { recursive: true, force: true }); 1046 }); 1047 }); 1048 1049 describe('installPlugin transactional staging', () => { 1050 const standaloneSource = 'github:user/opencli-plugin-__test-transactional-standalone__'; 1051 const standaloneName = '__test-transactional-standalone__'; 1052 const standaloneDir = path.join(PLUGINS_DIR, standaloneName); 1053 const monorepoSource = 'github:user/opencli-plugins-__test-transactional__'; 1054 const monorepoRepoDir = path.join(_getMonoreposDir(), 'opencli-plugins-__test-transactional__'); 1055 const monorepoLink = path.join(PLUGINS_DIR, 'alpha'); 1056 1057 beforeEach(() => { 1058 mockExecFileSync.mockClear(); 1059 mockExecSync.mockClear(); 1060 }); 1061 1062 afterEach(() => { 1063 try { fs.unlinkSync(monorepoLink); } catch {} 1064 try { fs.rmSync(monorepoLink, { recursive: true, force: true }); } catch {} 1065 try { fs.rmSync(monorepoRepoDir, { recursive: true, force: true }); } catch {} 1066 try { fs.rmSync(standaloneDir, { recursive: true, force: true }); } catch {} 1067 const lock = _readLockFile(); 1068 delete lock[standaloneName]; 1069 delete lock.alpha; 1070 _writeLockFile(lock); 1071 vi.clearAllMocks(); 1072 }); 1073 1074 it('does not expose the final standalone plugin dir when lifecycle fails in staging', () => { 1075 mockExecFileSync.mockImplementation((cmd, args) => { 1076 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 1077 const cloneDir = String(args[args.length - 1]); 1078 fs.mkdirSync(cloneDir, { recursive: true }); 1079 fs.writeFileSync(path.join(cloneDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 1080 fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName })); 1081 return ''; 1082 } 1083 if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') { 1084 throw new Error('boom'); 1085 } 1086 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 1087 return '1234567890abcdef1234567890abcdef12345678\n'; 1088 } 1089 return ''; 1090 }); 1091 1092 expect(() => installPlugin(standaloneSource)).toThrow(`npm install failed`); 1093 expect(fs.existsSync(standaloneDir)).toBe(false); 1094 expect(_readLockFile()[standaloneName]).toBeUndefined(); 1095 }); 1096 1097 it('does not expose monorepo links or repo dir when lifecycle fails in staging', () => { 1098 mockExecFileSync.mockImplementation((cmd, args) => { 1099 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 1100 const cloneDir = String(args[args.length - 1]); 1101 const alphaDir = path.join(cloneDir, 'packages', 'alpha'); 1102 fs.mkdirSync(alphaDir, { recursive: true }); 1103 fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ 1104 name: 'opencli-plugins-__test-transactional__', 1105 private: true, 1106 })); 1107 fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({ 1108 plugins: { 1109 alpha: { path: 'packages/alpha' }, 1110 }, 1111 })); 1112 fs.writeFileSync(path.join(alphaDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 1113 return ''; 1114 } 1115 if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') { 1116 throw new Error('boom'); 1117 } 1118 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 1119 return '1234567890abcdef1234567890abcdef12345678\n'; 1120 } 1121 return ''; 1122 }); 1123 1124 expect(() => installPlugin(monorepoSource)).toThrow(`npm install failed`); 1125 expect(fs.existsSync(monorepoRepoDir)).toBe(false); 1126 expect(fs.existsSync(monorepoLink)).toBe(false); 1127 expect(_readLockFile().alpha).toBeUndefined(); 1128 }); 1129 }); 1130 1131 describe('installPlugin with existing monorepo', () => { 1132 const repoName = '__test-existing-monorepo__'; 1133 const repoDir = path.join(_getMonoreposDir(), repoName); 1134 const pluginName = 'beta'; 1135 const pluginLink = path.join(PLUGINS_DIR, pluginName); 1136 1137 beforeEach(() => { 1138 mockExecFileSync.mockClear(); 1139 mockExecSync.mockClear(); 1140 }); 1141 1142 afterEach(() => { 1143 try { fs.unlinkSync(pluginLink); } catch {} 1144 try { fs.rmSync(pluginLink, { recursive: true, force: true }); } catch {} 1145 try { fs.rmSync(repoDir, { recursive: true, force: true }); } catch {} 1146 const lock = _readLockFile(); 1147 delete lock[pluginName]; 1148 _writeLockFile(lock); 1149 vi.clearAllMocks(); 1150 }); 1151 1152 it('reinstalls root dependencies when adding a sub-plugin from an existing monorepo', () => { 1153 const subDir = path.join(repoDir, 'packages', pluginName); 1154 fs.mkdirSync(subDir, { recursive: true }); 1155 fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({ 1156 name: repoName, 1157 private: true, 1158 workspaces: ['packages/*'], 1159 })); 1160 fs.writeFileSync(path.join(repoDir, 'opencli-plugin.json'), JSON.stringify({ 1161 plugins: { 1162 [pluginName]: { path: `packages/${pluginName}` }, 1163 }, 1164 })); 1165 fs.writeFileSync(path.join(subDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 1166 1167 mockExecFileSync.mockImplementation((cmd, args) => { 1168 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 1169 const cloneDir = String(args[4]); 1170 fs.mkdirSync(cloneDir, { recursive: true }); 1171 fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({ 1172 plugins: { 1173 [pluginName]: { path: `packages/${pluginName}` }, 1174 }, 1175 })); 1176 return ''; 1177 } 1178 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 1179 return '1234567890abcdef1234567890abcdef12345678\n'; 1180 } 1181 return ''; 1182 }); 1183 1184 installPlugin(`github:user/${repoName}/${pluginName}`); 1185 1186 const npmCalls = mockExecFileSync.mock.calls.filter( 1187 ([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install', 1188 ); 1189 expect(npmCalls.some(([, , opts]) => opts?.cwd === repoDir)).toBe(true); 1190 expect(fs.realpathSync(pluginLink)).toBe(fs.realpathSync(subDir)); 1191 }); 1192 }); 1193 1194 describe('updatePlugin transactional staging', () => { 1195 const standaloneName = '__test-transactional-update__'; 1196 const standaloneDir = path.join(PLUGINS_DIR, standaloneName); 1197 const monorepoName = '__test-transactional-mono-update__'; 1198 const monorepoRepoDir = path.join(_getMonoreposDir(), monorepoName); 1199 const monorepoPluginName = 'alpha-update'; 1200 const monorepoLink = path.join(PLUGINS_DIR, monorepoPluginName); 1201 1202 beforeEach(() => { 1203 mockExecFileSync.mockClear(); 1204 mockExecSync.mockClear(); 1205 }); 1206 1207 afterEach(() => { 1208 try { fs.unlinkSync(monorepoLink); } catch {} 1209 try { fs.rmSync(monorepoLink, { recursive: true, force: true }); } catch {} 1210 try { fs.rmSync(monorepoRepoDir, { recursive: true, force: true }); } catch {} 1211 try { fs.rmSync(standaloneDir, { recursive: true, force: true }); } catch {} 1212 const lock = _readLockFile(); 1213 delete lock[standaloneName]; 1214 delete lock[monorepoPluginName]; 1215 _writeLockFile(lock); 1216 vi.clearAllMocks(); 1217 }); 1218 1219 it('keeps the existing standalone plugin when staged update preparation fails', () => { 1220 fs.mkdirSync(standaloneDir, { recursive: true }); 1221 fs.writeFileSync(path.join(standaloneDir, 'old.js'), 'cli({ site: "old", name: "old" })'); 1222 1223 const lock = _readLockFile(); 1224 lock[standaloneName] = { 1225 source: { 1226 kind: 'git', 1227 url: 'https://github.com/user/opencli-plugin-__test-transactional-update__.git', 1228 }, 1229 commitHash: 'oldhasholdhasholdhasholdhasholdhasholdh', 1230 installedAt: '2025-01-01T00:00:00.000Z', 1231 }; 1232 _writeLockFile(lock); 1233 1234 mockExecFileSync.mockImplementation((cmd, args) => { 1235 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 1236 const cloneDir = String(args[4]); 1237 fs.mkdirSync(cloneDir, { recursive: true }); 1238 fs.writeFileSync(path.join(cloneDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 1239 fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName })); 1240 return ''; 1241 } 1242 if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') { 1243 throw new Error('boom'); 1244 } 1245 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 1246 return '1234567890abcdef1234567890abcdef12345678\n'; 1247 } 1248 return ''; 1249 }); 1250 1251 expect(() => updatePlugin(standaloneName)).toThrow('npm install failed'); 1252 expect(fs.existsSync(standaloneDir)).toBe(true); 1253 expect(fs.readFileSync(path.join(standaloneDir, 'old.js'), 'utf-8')).toContain('site: "old"'); 1254 expect(_readLockFile()[standaloneName]?.commitHash).toBe('oldhasholdhasholdhasholdhasholdhasholdh'); 1255 }); 1256 1257 it('keeps the existing monorepo repo and link when staged update preparation fails', () => { 1258 const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName); 1259 fs.mkdirSync(subDir, { recursive: true }); 1260 fs.writeFileSync(path.join(subDir, 'old.js'), 'cli({ site: "old", name: "old" })'); 1261 fs.mkdirSync(PLUGINS_DIR, { recursive: true }); 1262 fs.symlinkSync(subDir, monorepoLink, 'dir'); 1263 1264 const lock = _readLockFile(); 1265 lock[monorepoPluginName] = { 1266 source: { 1267 kind: 'monorepo', 1268 url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git', 1269 repoName: monorepoName, 1270 subPath: `packages/${monorepoPluginName}`, 1271 }, 1272 commitHash: 'oldmonooldmonooldmonooldmonooldmonoold', 1273 installedAt: '2025-01-01T00:00:00.000Z', 1274 }; 1275 _writeLockFile(lock); 1276 1277 mockExecFileSync.mockImplementation((cmd, args) => { 1278 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 1279 const cloneDir = String(args[4]); 1280 const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName); 1281 fs.mkdirSync(alphaDir, { recursive: true }); 1282 fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ 1283 name: 'opencli-plugins-__test-transactional-mono-update__', 1284 private: true, 1285 })); 1286 fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({ 1287 plugins: { 1288 [monorepoPluginName]: { path: `packages/${monorepoPluginName}` }, 1289 }, 1290 })); 1291 fs.writeFileSync(path.join(alphaDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 1292 return ''; 1293 } 1294 if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') { 1295 throw new Error('boom'); 1296 } 1297 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 1298 return '1234567890abcdef1234567890abcdef12345678\n'; 1299 } 1300 return ''; 1301 }); 1302 1303 expect(() => updatePlugin(monorepoPluginName)).toThrow('npm install failed'); 1304 expect(fs.existsSync(monorepoRepoDir)).toBe(true); 1305 expect(fs.existsSync(monorepoLink)).toBe(true); 1306 expect(fs.readFileSync(path.join(subDir, 'old.js'), 'utf-8')).toContain('site: "old"'); 1307 expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe('oldmonooldmonooldmonooldmonooldmonoold'); 1308 }); 1309 1310 it('relinks monorepo plugins when the updated manifest moves their subPath', () => { 1311 const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha'); 1312 fs.mkdirSync(oldSubDir, { recursive: true }); 1313 fs.writeFileSync(path.join(oldSubDir, 'old.js'), 'cli({ site: "old", name: "old" })'); 1314 fs.mkdirSync(PLUGINS_DIR, { recursive: true }); 1315 fs.symlinkSync(oldSubDir, monorepoLink, 'dir'); 1316 1317 const lock = _readLockFile(); 1318 lock[monorepoPluginName] = { 1319 source: { 1320 kind: 'monorepo', 1321 url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git', 1322 repoName: monorepoName, 1323 subPath: 'packages/old-alpha', 1324 }, 1325 commitHash: 'oldmonooldmonooldmonooldmonooldmonoold', 1326 installedAt: '2025-01-01T00:00:00.000Z', 1327 }; 1328 _writeLockFile(lock); 1329 1330 mockExecFileSync.mockImplementation((cmd, args) => { 1331 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 1332 const cloneDir = String(args[4]); 1333 const movedDir = path.join(cloneDir, 'packages', 'moved-alpha'); 1334 fs.mkdirSync(movedDir, { recursive: true }); 1335 fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({ 1336 plugins: { 1337 [monorepoPluginName]: { path: 'packages/moved-alpha' }, 1338 }, 1339 })); 1340 fs.writeFileSync(path.join(movedDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 1341 return ''; 1342 } 1343 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 1344 return '1234567890abcdef1234567890abcdef12345678\n'; 1345 } 1346 return ''; 1347 }); 1348 1349 updatePlugin(monorepoPluginName); 1350 1351 const expectedTarget = path.join(monorepoRepoDir, 'packages', 'moved-alpha'); 1352 expect(fs.realpathSync(monorepoLink)).toBe(fs.realpathSync(expectedTarget)); 1353 expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({ 1354 kind: 'monorepo', 1355 subPath: 'packages/moved-alpha', 1356 }); 1357 }); 1358 1359 it('rejects monorepo updates whose manifest path escapes the repo root', () => { 1360 const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha'); 1361 fs.mkdirSync(oldSubDir, { recursive: true }); 1362 fs.writeFileSync(path.join(oldSubDir, 'old.js'), 'cli({ site: "old", name: "old" })'); 1363 fs.mkdirSync(PLUGINS_DIR, { recursive: true }); 1364 fs.symlinkSync(oldSubDir, monorepoLink, 'dir'); 1365 1366 const lock = _readLockFile(); 1367 lock[monorepoPluginName] = { 1368 source: { 1369 kind: 'monorepo', 1370 url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git', 1371 repoName: monorepoName, 1372 subPath: 'packages/old-alpha', 1373 }, 1374 commitHash: 'oldmonooldmonooldmonooldmonooldmonoold', 1375 installedAt: '2025-01-01T00:00:00.000Z', 1376 }; 1377 _writeLockFile(lock); 1378 1379 mockExecFileSync.mockImplementation((cmd, args) => { 1380 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 1381 const cloneDir = String(args[4]); 1382 fs.mkdirSync(cloneDir, { recursive: true }); 1383 fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({ 1384 plugins: { 1385 [monorepoPluginName]: { path: '../outside-alpha' }, 1386 }, 1387 })); 1388 return ''; 1389 } 1390 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 1391 return '1234567890abcdef1234567890abcdef12345678\n'; 1392 } 1393 return ''; 1394 }); 1395 1396 expect(() => updatePlugin(monorepoPluginName)).toThrow('escapes repo root'); 1397 expect(fs.realpathSync(monorepoLink)).toBe(fs.realpathSync(oldSubDir)); 1398 expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({ 1399 kind: 'monorepo', 1400 subPath: 'packages/old-alpha', 1401 }); 1402 }); 1403 1404 it('rolls back the monorepo repo swap when relinking fails', () => { 1405 const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha'); 1406 fs.mkdirSync(oldSubDir, { recursive: true }); 1407 fs.writeFileSync(path.join(oldSubDir, 'old.js'), 'cli({ site: "old", name: "old" })'); 1408 fs.mkdirSync(monorepoLink, { recursive: true }); 1409 fs.writeFileSync(path.join(monorepoLink, 'blocker.txt'), 'not a symlink'); 1410 1411 const lock = _readLockFile(); 1412 lock[monorepoPluginName] = { 1413 source: { 1414 kind: 'monorepo', 1415 url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git', 1416 repoName: monorepoName, 1417 subPath: 'packages/old-alpha', 1418 }, 1419 commitHash: 'oldmonooldmonooldmonooldmonooldmonoold', 1420 installedAt: '2025-01-01T00:00:00.000Z', 1421 }; 1422 _writeLockFile(lock); 1423 1424 mockExecFileSync.mockImplementation((cmd, args) => { 1425 if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { 1426 const cloneDir = String(args[4]); 1427 const movedDir = path.join(cloneDir, 'packages', 'moved-alpha'); 1428 fs.mkdirSync(movedDir, { recursive: true }); 1429 fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({ 1430 plugins: { 1431 [monorepoPluginName]: { path: 'packages/moved-alpha' }, 1432 }, 1433 })); 1434 fs.writeFileSync(path.join(movedDir, 'hello.js'), 'cli({ site: "test", name: "hello" })'); 1435 return ''; 1436 } 1437 if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { 1438 return '1234567890abcdef1234567890abcdef12345678\n'; 1439 } 1440 return ''; 1441 }); 1442 1443 expect(() => updatePlugin(monorepoPluginName)).toThrow('to be a symlink'); 1444 expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'old-alpha', 'old.js'))).toBe(true); 1445 expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'moved-alpha'))).toBe(false); 1446 expect(fs.readFileSync(path.join(monorepoLink, 'blocker.txt'), 'utf-8')).toBe('not a symlink'); 1447 expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({ 1448 kind: 'monorepo', 1449 subPath: 'packages/old-alpha', 1450 }); 1451 }); 1452 });