/ src / plugin.test.ts
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  });