/ src / plugin.ts
plugin.ts
   1  /**
   2   * Plugin management: install, uninstall, and list plugins.
   3   *
   4   * Plugins live in ~/.opencli/plugins/<name>/.
   5   * Monorepo clones live in ~/.opencli/monorepos/<repo-name>/.
   6   * Install source format: "github:user/repo", "github:user/repo/subplugin",
   7   * "https://github.com/user/repo", "file:///local/plugin", or a local directory path.
   8   */
   9  
  10  import * as fs from 'node:fs';
  11  import * as os from 'node:os';
  12  import * as path from 'node:path';
  13  import { execSync, execFileSync } from 'node:child_process';
  14  import { fileURLToPath } from 'node:url';
  15  import { PLUGINS_DIR } from './discovery.js';
  16  import { getErrorMessage, PluginError } from './errors.js';
  17  import { log } from './logger.js';
  18  import { isRecord } from './utils.js';
  19  import {
  20    readPluginManifest,
  21    isMonorepo,
  22    getEnabledPlugins,
  23    checkCompatibility,
  24    type PluginManifest,
  25  } from './plugin-manifest.js';
  26  
  27  const isWindows = process.platform === 'win32';
  28  const LOCAL_PLUGIN_SOURCE_PREFIX = 'local:';
  29  
  30  /** Get home directory, respecting HOME environment variable for test isolation. */
  31  function getHomeDir(): string {
  32    return process.env.HOME || process.env.USERPROFILE || os.homedir();
  33  }
  34  
  35  /** Path to the lock file that tracks installed plugin versions. */
  36  export function getLockFilePath(): string {
  37    return path.join(getHomeDir(), '.opencli', 'plugins.lock.json');
  38  }
  39  
  40  /** Monorepo clones directory: ~/.opencli/monorepos/ */
  41  export function getMonoreposDir(): string {
  42    return path.join(getHomeDir(), '.opencli', 'monorepos');
  43  }
  44  
  45  export type PluginSourceRecord =
  46    | { kind: 'git'; url: string }
  47    | { kind: 'local'; path: string }
  48    | { kind: 'monorepo'; url: string; repoName: string; subPath: string };
  49  
  50  export interface LockEntry {
  51    source: PluginSourceRecord;
  52    commitHash: string;
  53    installedAt: string;
  54    updatedAt?: string;
  55  }
  56  
  57  export interface PluginInfo {
  58    name: string;
  59    path: string;
  60    commands: string[];
  61    source?: string;
  62    version?: string;
  63    installedAt?: string;
  64    /** If from a monorepo, the monorepo name. */
  65    monorepoName?: string;
  66    /** Description from opencli-plugin.json. */
  67    description?: string;
  68  }
  69  
  70  interface ParsedSource {
  71    type: 'git' | 'local';
  72    name: string;
  73    subPlugin?: string;
  74    cloneUrl?: string;
  75    localPath?: string;
  76  }
  77  
  78  function parseStoredPluginSource(source?: string): PluginSourceRecord | undefined {
  79    if (!source) return undefined;
  80    if (source.startsWith(LOCAL_PLUGIN_SOURCE_PREFIX)) {
  81      return {
  82        kind: 'local',
  83        path: path.resolve(source.slice(LOCAL_PLUGIN_SOURCE_PREFIX.length)),
  84      };
  85    }
  86    return { kind: 'git', url: source };
  87  }
  88  
  89  function isLocalPluginSource(source?: string): boolean {
  90    return parseStoredPluginSource(source)?.kind === 'local';
  91  }
  92  
  93  function toStoredPluginSource(source: PluginSourceRecord): string {
  94    if (source.kind === 'local') {
  95      return `${LOCAL_PLUGIN_SOURCE_PREFIX}${path.resolve(source.path)}`;
  96    }
  97    return source.url;
  98  }
  99  
 100  function toLocalPluginSource(pluginDir: string): string {
 101    return toStoredPluginSource({ kind: 'local', path: pluginDir });
 102  }
 103  
 104  // isRecord is imported from './utils.js'
 105  
 106  function normalizeLegacyMonorepo(
 107    value: unknown,
 108  ): { name: string; subPath: string } | undefined {
 109    if (!isRecord(value)) return undefined;
 110    if (typeof value.name !== 'string' || typeof value.subPath !== 'string') return undefined;
 111    return { name: value.name, subPath: value.subPath };
 112  }
 113  
 114  function normalizePluginSource(
 115    source: unknown,
 116    legacyMonorepo?: { name: string; subPath: string },
 117  ): PluginSourceRecord | undefined {
 118    if (typeof source === 'string') {
 119      const parsed = parseStoredPluginSource(source);
 120      if (!parsed) return undefined;
 121      if (parsed.kind === 'git' && legacyMonorepo) {
 122        return {
 123          kind: 'monorepo',
 124          url: parsed.url,
 125          repoName: legacyMonorepo.name,
 126          subPath: legacyMonorepo.subPath,
 127        };
 128      }
 129      return parsed;
 130    }
 131  
 132    if (!isRecord(source) || typeof source.kind !== 'string') return undefined;
 133    switch (source.kind) {
 134      case 'git':
 135        return typeof source.url === 'string'
 136          ? { kind: 'git', url: source.url }
 137          : undefined;
 138      case 'local':
 139        return typeof source.path === 'string'
 140          ? { kind: 'local', path: path.resolve(source.path) }
 141          : undefined;
 142      case 'monorepo':
 143        return typeof source.url === 'string'
 144          && typeof source.repoName === 'string'
 145          && typeof source.subPath === 'string'
 146          ? {
 147              kind: 'monorepo',
 148              url: source.url,
 149              repoName: source.repoName,
 150              subPath: source.subPath,
 151            }
 152          : undefined;
 153      default:
 154        return undefined;
 155    }
 156  }
 157  
 158  function normalizeLockEntry(value: unknown): LockEntry | undefined {
 159    if (!isRecord(value)) return undefined;
 160  
 161    const legacyMonorepo = normalizeLegacyMonorepo(value.monorepo);
 162    const source = normalizePluginSource(value.source, legacyMonorepo);
 163    if (!source) return undefined;
 164    if (typeof value.commitHash !== 'string' || typeof value.installedAt !== 'string') {
 165      return undefined;
 166    }
 167  
 168    const entry: LockEntry = {
 169      source,
 170      commitHash: value.commitHash,
 171      installedAt: value.installedAt,
 172    };
 173  
 174    if (typeof value.updatedAt === 'string') {
 175      entry.updatedAt = value.updatedAt;
 176    }
 177  
 178    return entry;
 179  }
 180  
 181  function resolvePluginSource(lockEntry: LockEntry | undefined, pluginDir: string): PluginSourceRecord | undefined {
 182    if (lockEntry) {
 183      return lockEntry.source;
 184    }
 185    return parseStoredPluginSource(getPluginSource(pluginDir));
 186  }
 187  
 188  function resolveStoredPluginSource(lockEntry: LockEntry | undefined, pluginDir: string): string | undefined {
 189    const source = resolvePluginSource(lockEntry, pluginDir);
 190    return source ? toStoredPluginSource(source) : undefined;
 191  }
 192  
 193  // ── Filesystem helpers ──────────────────────────────────────────────────────
 194  
 195  /**
 196   * Move a directory, with EXDEV fallback.
 197   * fs.renameSync fails when source and destination are on different
 198   * filesystems (e.g. /tmp → ~/.opencli). In that case we copy then remove.
 199   */
 200  type MoveDirFsOps = Pick<typeof fs, 'renameSync' | 'cpSync' | 'rmSync'>;
 201  
 202  function moveDir(src: string, dest: string, fsOps: MoveDirFsOps = fs): void {
 203    try {
 204      fsOps.renameSync(src, dest);
 205    } catch (err: unknown) {
 206      if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
 207        try {
 208          fsOps.cpSync(src, dest, { recursive: true });
 209        } catch (copyErr) {
 210          try { fsOps.rmSync(dest, { recursive: true, force: true }); } catch {}
 211          throw copyErr;
 212        }
 213        fsOps.rmSync(src, { recursive: true, force: true });
 214      } else {
 215        throw err;
 216      }
 217    }
 218  }
 219  
 220  type ReplaceDirFsOps = MoveDirFsOps & Pick<typeof fs, 'existsSync' | 'mkdirSync'>;
 221  
 222  function createSiblingTempPath(dest: string, kind: 'tmp' | 'bak'): string {
 223    const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
 224    return path.join(path.dirname(dest), `.${path.basename(dest)}.${kind}-${suffix}`);
 225  }
 226  
 227  function cloneRepoToTemp(cloneUrl: string): string {
 228    const tmpCloneDir = path.join(
 229      os.tmpdir(),
 230      `opencli-clone-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
 231    );
 232  
 233    try {
 234      execFileSync('git', ['clone', '--depth', '1', cloneUrl, tmpCloneDir], {
 235        encoding: 'utf-8',
 236        stdio: ['pipe', 'pipe', 'pipe'],
 237      });
 238    } catch (err) {
 239      throw new PluginError(`Failed to clone plugin: ${getErrorMessage(err)}`, 'Check the repository URL and your network connection.');
 240    }
 241  
 242    return tmpCloneDir;
 243  }
 244  
 245  function withTempClone<T>(cloneUrl: string, work: (cloneDir: string) => T): T {
 246    const tmpCloneDir = cloneRepoToTemp(cloneUrl);
 247    try {
 248      return work(tmpCloneDir);
 249    } finally {
 250      try { fs.rmSync(tmpCloneDir, { recursive: true, force: true }); } catch {}
 251    }
 252  }
 253  
 254  function resolveRemotePluginSource(lockEntry: LockEntry | undefined, dir: string): string {
 255    const source = resolvePluginSource(lockEntry, dir);
 256    if (!source || source.kind === 'local') {
 257      throw new Error(`Unable to determine remote source for plugin at ${dir}`);
 258    }
 259    return source.url;
 260  }
 261  
 262  function pathExistsSync(p: string): boolean {
 263    try {
 264      fs.lstatSync(p);
 265      return true;
 266    } catch {
 267      return false;
 268    }
 269  }
 270  
 271  function resolveRepoContainedPath(repoRoot: string, subPath: string): string {
 272    const resolved = path.resolve(repoRoot, subPath);
 273    if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) {
 274      throw new PluginError(`Plugin path "${subPath}" escapes repo root.`);
 275    }
 276    return resolved;
 277  }
 278  
 279  function removePathSync(p: string): void {
 280    try {
 281      const stat = fs.lstatSync(p);
 282      if (stat.isSymbolicLink()) {
 283        fs.unlinkSync(p);
 284        return;
 285      }
 286      fs.rmSync(p, { recursive: true, force: true });
 287    } catch {}
 288  }
 289  
 290  interface TransactionHandle {
 291    finalize(): void;
 292    rollback(): void;
 293  }
 294  
 295  class Transaction {
 296    #handles: TransactionHandle[] = [];
 297    #settled = false;
 298  
 299    track<T extends TransactionHandle>(handle: T): T {
 300      this.#handles.push(handle);
 301      return handle;
 302    }
 303  
 304    commit(): void {
 305      if (this.#settled) return;
 306      this.#settled = true;
 307      for (const handle of this.#handles) {
 308        handle.finalize();
 309      }
 310    }
 311  
 312    rollback(): void {
 313      if (this.#settled) return;
 314      this.#settled = true;
 315      for (const handle of [...this.#handles].reverse()) {
 316        handle.rollback();
 317      }
 318    }
 319  }
 320  
 321  function runTransaction<T>(work: (tx: Transaction) => T): T {
 322    const tx = new Transaction();
 323    try {
 324      const result = work(tx);
 325      tx.commit();
 326      return result;
 327    } catch (err) {
 328      tx.rollback();
 329      throw err;
 330    }
 331  }
 332  
 333  function beginReplaceDir(
 334    stagingDir: string,
 335    dest: string,
 336    fsOps: ReplaceDirFsOps = fs,
 337  ): TransactionHandle {
 338    const destExisted = fsOps.existsSync(dest);
 339    fsOps.mkdirSync(path.dirname(dest), { recursive: true });
 340  
 341    const tempDest = createSiblingTempPath(dest, 'tmp');
 342    const backupDest = destExisted ? createSiblingTempPath(dest, 'bak') : null;
 343    let settled = false;
 344  
 345    try {
 346      moveDir(stagingDir, tempDest, fsOps);
 347      if (backupDest) {
 348        fsOps.renameSync(dest, backupDest);
 349      }
 350      fsOps.renameSync(tempDest, dest);
 351    } catch (err) {
 352      try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
 353      if (backupDest && !fsOps.existsSync(dest)) {
 354        try { fsOps.renameSync(backupDest, dest); } catch {}
 355      }
 356      throw err;
 357    }
 358  
 359    return {
 360      finalize() {
 361        if (settled) return;
 362        settled = true;
 363        if (backupDest) {
 364          try { fsOps.rmSync(backupDest, { recursive: true, force: true }); } catch {}
 365        }
 366      },
 367      rollback() {
 368        if (settled) return;
 369        settled = true;
 370        try { fsOps.rmSync(dest, { recursive: true, force: true }); } catch {}
 371        if (backupDest) {
 372          try { fsOps.renameSync(backupDest, dest); } catch {}
 373        }
 374        try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
 375      },
 376    };
 377  }
 378  
 379  function beginReplaceSymlink(target: string, linkPath: string): TransactionHandle {
 380    const linkExists = pathExistsSync(linkPath);
 381    if (linkExists && !isSymlinkSync(linkPath)) {
 382      throw new Error(`Expected monorepo plugin link at ${linkPath} to be a symlink`);
 383    }
 384  
 385    fs.mkdirSync(path.dirname(linkPath), { recursive: true });
 386  
 387    const tempLink = createSiblingTempPath(linkPath, 'tmp');
 388    const backupLink = linkExists ? createSiblingTempPath(linkPath, 'bak') : null;
 389    const linkType = isWindows ? 'junction' : 'dir';
 390    let settled = false;
 391  
 392    try {
 393      fs.symlinkSync(target, tempLink, linkType);
 394      if (backupLink) {
 395        fs.renameSync(linkPath, backupLink);
 396      }
 397      fs.renameSync(tempLink, linkPath);
 398    } catch (err) {
 399      removePathSync(tempLink);
 400      if (backupLink && !pathExistsSync(linkPath)) {
 401        try { fs.renameSync(backupLink, linkPath); } catch {}
 402      }
 403      throw err;
 404    }
 405  
 406    return {
 407      finalize() {
 408        if (settled) return;
 409        settled = true;
 410        if (backupLink) {
 411          removePathSync(backupLink);
 412        }
 413      },
 414      rollback() {
 415        if (settled) return;
 416        settled = true;
 417        removePathSync(linkPath);
 418        if (backupLink && !pathExistsSync(linkPath)) {
 419          try { fs.renameSync(backupLink, linkPath); } catch {}
 420        }
 421        removePathSync(tempLink);
 422      },
 423    };
 424  }
 425  
 426  // ── Validation helpers ──────────────────────────────────────────────────────
 427  
 428  export interface ValidationResult {
 429    valid: boolean;
 430    errors: string[];
 431  }
 432  
 433  // ── Lock file helpers ───────────────────────────────────────────────────────
 434  
 435  function readLockFileWithWriter(
 436    writeLock: (lock: Record<string, LockEntry>) => void = writeLockFile,
 437  ): Record<string, LockEntry> {
 438    try {
 439      const raw = fs.readFileSync(getLockFilePath(), 'utf-8');
 440      const parsed = JSON.parse(raw) as unknown;
 441      if (!isRecord(parsed)) return {};
 442  
 443      const lock: Record<string, LockEntry> = {};
 444      let changed = false;
 445  
 446      for (const [name, entry] of Object.entries(parsed)) {
 447        const normalized = normalizeLockEntry(entry);
 448        if (!normalized) {
 449          changed = true;
 450          continue;
 451        }
 452  
 453        lock[name] = normalized;
 454        if (JSON.stringify(entry) !== JSON.stringify(normalized)) {
 455          changed = true;
 456        }
 457      }
 458  
 459      if (changed) {
 460        try {
 461          writeLock(lock);
 462        } catch {}
 463      }
 464  
 465      return lock;
 466    } catch {
 467      return {};
 468    }
 469  }
 470  
 471  export function readLockFile(): Record<string, LockEntry> {
 472    return readLockFileWithWriter(writeLockFile);
 473  }
 474  
 475  type WriteLockFileFsOps = Pick<typeof fs, 'mkdirSync' | 'writeFileSync' | 'renameSync' | 'rmSync'>;
 476  
 477  function writeLockFileWithFs(
 478    lock: Record<string, LockEntry>,
 479    fsOps: WriteLockFileFsOps = fs,
 480  ): void {
 481    const lockPath = getLockFilePath();
 482    fsOps.mkdirSync(path.dirname(lockPath), { recursive: true });
 483    const tempPath = createSiblingTempPath(lockPath, 'tmp');
 484  
 485    try {
 486      fsOps.writeFileSync(tempPath, JSON.stringify(lock, null, 2) + '\n');
 487      fsOps.renameSync(tempPath, lockPath);
 488    } catch (err) {
 489      try { fsOps.rmSync(tempPath, { force: true }); } catch {}
 490      throw err;
 491    }
 492  }
 493  
 494  export function writeLockFile(lock: Record<string, LockEntry>): void {
 495    writeLockFileWithFs(lock, fs);
 496  }
 497  
 498  /** Get the HEAD commit hash of a git repo directory. */
 499  export function getCommitHash(dir: string): string | undefined {
 500    try {
 501      return execFileSync('git', ['rev-parse', 'HEAD'], {
 502        cwd: dir,
 503        encoding: 'utf-8',
 504        stdio: ['pipe', 'pipe', 'pipe'],
 505      }).trim();
 506    } catch {
 507      return undefined;
 508    }
 509  }
 510  
 511  /**
 512   * Validate that a downloaded plugin directory is a structurally valid plugin.
 513   * Checks for at least one command file (.ts, .js) and a valid
 514   * package.json if it contains .ts files.
 515   */
 516  export function validatePluginStructure(pluginDir: string): ValidationResult {
 517    const errors: string[] = [];
 518  
 519    if (!fs.existsSync(pluginDir)) {
 520      return { valid: false, errors: ['Plugin directory does not exist'] };
 521    }
 522  
 523    const files = fs.readdirSync(pluginDir);
 524    const hasTs = files.some(f => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts'));
 525    const hasJs = files.some(f => f.endsWith('.js') && !f.endsWith('.d.js'));
 526  
 527    if (!hasTs && !hasJs) {
 528      errors.push('No command files found in plugin directory. A plugin must contain at least one .ts or .js command file.');
 529    }
 530  
 531    if (hasTs) {
 532      const pkgJsonPath = path.join(pluginDir, 'package.json');
 533      if (!fs.existsSync(pkgJsonPath)) {
 534        errors.push('Plugin contains .ts files but no package.json. A package.json with "type": "module" and "@jackwener/opencli" peer dependency is required for TS plugins.');
 535      } else {
 536        try {
 537          const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
 538          if (pkg.type !== 'module') {
 539            errors.push('Plugin package.json must have "type": "module" for TypeScript plugins.');
 540          }
 541        } catch {
 542          errors.push('Plugin package.json is malformed or invalid JSON.');
 543        }
 544      }
 545    }
 546  
 547    return { valid: errors.length === 0, errors };
 548  }
 549  
 550  /** Check whether a directory has its own production dependencies in package.json. */
 551  function hasOwnDependencies(dir: string): boolean {
 552    const pkgPath = path.join(dir, 'package.json');
 553    if (!fs.existsSync(pkgPath)) return false;
 554    try {
 555      const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
 556      return pkg.dependencies != null && Object.keys(pkg.dependencies).length > 0;
 557    } catch {
 558      return false;
 559    }
 560  }
 561  
 562  function installDependencies(dir: string): void {
 563    const pkgJsonPath = path.join(dir, 'package.json');
 564    if (!fs.existsSync(pkgJsonPath)) return;
 565  
 566    try {
 567      execFileSync('npm', ['install', '--omit=dev'], {
 568        cwd: dir,
 569        encoding: 'utf-8',
 570        stdio: ['pipe', 'pipe', 'pipe'],
 571        ...(isWindows && { shell: true }),
 572      });
 573    } catch (err) {
 574      throw new PluginError(`npm install failed in ${dir}: ${getErrorMessage(err)}`, 'Check your network connection and npm configuration.');
 575    }
 576  }
 577  
 578  function finalizePluginRuntime(pluginDir: string): void {
 579    // Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
 580    // against the running host, not a stale npm-published version.
 581    linkHostOpencli(pluginDir);
 582  
 583    // Transpile .ts → .js via esbuild (production node can't load .ts directly).
 584    transpilePluginTs(pluginDir);
 585  }
 586  
 587  /**
 588   * Shared post-install lifecycle for standalone plugins.
 589   */
 590  function postInstallLifecycle(pluginDir: string): void {
 591    installDependencies(pluginDir);
 592    finalizePluginRuntime(pluginDir);
 593  }
 594  
 595  /**
 596   * Monorepo lifecycle: install shared deps at repo root, then install and finalize each sub-plugin.
 597   *
 598   * The root install covers monorepos that use npm workspaces to hoist dependencies.
 599   * For monorepos that do NOT use workspaces, sub-plugins may declare their own
 600   * production dependencies in their package.json.  We install those per sub-plugin
 601   * so that runtime imports (e.g. `undici`) can be resolved from the sub-plugin
 602   * directory.  When the root already satisfies all deps this is a fast no-op.
 603   */
 604  function postInstallMonorepoLifecycle(repoDir: string, pluginDirs: string[]): void {
 605    installDependencies(repoDir);
 606    for (const pluginDir of pluginDirs) {
 607      if (pluginDir !== repoDir && hasOwnDependencies(pluginDir)) {
 608        installDependencies(pluginDir);
 609      }
 610      finalizePluginRuntime(pluginDir);
 611    }
 612  }
 613  
 614  function ensureStandalonePluginReady(pluginDir: string): void {
 615    const validation = validatePluginStructure(pluginDir);
 616    if (!validation.valid) {
 617      throw new PluginError(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
 618    }
 619  
 620    postInstallLifecycle(pluginDir);
 621  }
 622  
 623  type LockEntryInput = Omit<LockEntry, 'installedAt'> & Partial<Pick<LockEntry, 'installedAt'>>;
 624  
 625  function upsertLockEntry(
 626    lock: Record<string, LockEntry>,
 627    name: string,
 628    entry: LockEntryInput,
 629  ): void {
 630    lock[name] = {
 631      ...entry,
 632      installedAt: entry.installedAt ?? new Date().toISOString(),
 633    };
 634  }
 635  
 636  function publishStandalonePlugin(
 637    stagingDir: string,
 638    targetDir: string,
 639    writeLock: (commitHash: string | undefined) => void,
 640    ): void {
 641    runTransaction((tx) => {
 642      tx.track(beginReplaceDir(stagingDir, targetDir));
 643      writeLock(getCommitHash(targetDir));
 644    });
 645  }
 646  
 647  interface MonorepoPublishPlugin {
 648    name: string;
 649    subPath: string;
 650  }
 651  
 652  function publishMonorepoPlugins(
 653    repoDir: string,
 654    pluginsDir: string,
 655    plugins: MonorepoPublishPlugin[],
 656    publishRepo?: { stagingDir: string; parentDir: string },
 657    writeLock?: (commitHash: string | undefined) => void,
 658  ): void {
 659    runTransaction((tx) => {
 660      if (publishRepo) {
 661        fs.mkdirSync(publishRepo.parentDir, { recursive: true });
 662        tx.track(beginReplaceDir(publishRepo.stagingDir, repoDir));
 663      }
 664  
 665      const commitHash = getCommitHash(repoDir);
 666      for (const plugin of plugins) {
 667        const linkPath = path.join(pluginsDir, plugin.name);
 668        const subDir = resolveRepoContainedPath(repoDir, plugin.subPath);
 669        tx.track(beginReplaceSymlink(subDir, linkPath));
 670      }
 671  
 672      writeLock?.(commitHash);
 673    });
 674  }
 675  
 676  /**
 677   * Install a plugin from a source.
 678   * Supports:
 679   *   "github:user/repo"            — single plugin or full monorepo
 680   *   "github:user/repo/subplugin"  — specific sub-plugin from a monorepo
 681   *   "https://github.com/user/repo"
 682   *   "file:///absolute/path"       — local plugin directory (symlinked)
 683   *   "/absolute/path"              — local plugin directory (symlinked)
 684   *
 685   * Returns the installed plugin name(s).
 686   */
 687  export function installPlugin(source: string): string | string[] {
 688    const parsed = parseSource(source);
 689    if (!parsed) {
 690      throw new Error(
 691        `Invalid plugin source: "${source}"\n` +
 692        `Supported formats:\n` +
 693        `  github:user/repo\n` +
 694        `  github:user/repo/subplugin\n` +
 695        `  https://github.com/user/repo\n` +
 696        `  https://<host>/<path>/repo.git\n` +
 697        `  ssh://git@<host>/<path>/repo.git\n` +
 698        `  git@<host>:user/repo.git\n` +
 699        `  file:///absolute/path\n` +
 700        `  /absolute/path`
 701      );
 702    }
 703  
 704    const { name: repoName, subPlugin } = parsed;
 705  
 706    if (parsed.type === 'local') {
 707      return installLocalPlugin(parsed.localPath!, repoName);
 708    }
 709  
 710    return withTempClone(parsed.cloneUrl!, (tmpCloneDir) => {
 711      const manifest = readPluginManifest(tmpCloneDir);
 712  
 713      // Check top-level compatibility
 714      if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
 715        throw new Error(
 716          `Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
 717        );
 718      }
 719  
 720      if (manifest && isMonorepo(manifest)) {
 721        return installMonorepo(tmpCloneDir, parsed.cloneUrl!, repoName, manifest, subPlugin);
 722      }
 723  
 724      // Single plugin mode
 725      return installSinglePlugin(tmpCloneDir, parsed.cloneUrl!, repoName, manifest);
 726    });
 727  }
 728  
 729  /** Install a single (non-monorepo) plugin. */
 730  function installSinglePlugin(
 731    cloneDir: string,
 732    cloneUrl: string,
 733    name: string,
 734    manifest: PluginManifest | null,
 735  ): string {
 736    const pluginName = manifest?.name ?? name;
 737    const targetDir = path.join(PLUGINS_DIR, pluginName);
 738  
 739    if (fs.existsSync(targetDir)) {
 740      throw new PluginError(`Plugin "${pluginName}" is already installed at ${targetDir}`, 'Use "opencli plugin uninstall" first, or pick a different name.');
 741    }
 742  
 743    ensureStandalonePluginReady(cloneDir);
 744    publishStandalonePlugin(cloneDir, targetDir, (commitHash) => {
 745      const lock = readLockFile();
 746      if (commitHash) {
 747        upsertLockEntry(lock, pluginName, {
 748          source: { kind: 'git', url: cloneUrl },
 749          commitHash,
 750        });
 751        writeLockFile(lock);
 752      }
 753    });
 754  
 755    return pluginName;
 756  }
 757  
 758  /**
 759   * Install a local plugin by creating a symlink.
 760   * Used for plugin development: the source directory is symlinked into
 761   * the plugins dir so changes are reflected immediately.
 762   */
 763  function installLocalPlugin(localPath: string, name: string): string {
 764    if (!fs.existsSync(localPath)) {
 765      throw new PluginError(`Local plugin path does not exist: ${localPath}`);
 766    }
 767  
 768    const stat = fs.statSync(localPath);
 769    if (!stat.isDirectory()) {
 770      throw new PluginError(`Local plugin path is not a directory: ${localPath}`);
 771    }
 772  
 773    const manifest = readPluginManifest(localPath);
 774  
 775    if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
 776      throw new PluginError(
 777        `Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`,
 778        'Upgrade opencli to a compatible version.',
 779      );
 780    }
 781  
 782    const pluginName = manifest?.name ?? name;
 783    const targetDir = path.join(PLUGINS_DIR, pluginName);
 784  
 785    if (fs.existsSync(targetDir)) {
 786      throw new PluginError(`Plugin "${pluginName}" is already installed at ${targetDir}`, 'Use "opencli plugin uninstall" first, or pick a different name.');
 787    }
 788  
 789    const validation = validatePluginStructure(localPath);
 790    if (!validation.valid) {
 791      throw new PluginError(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
 792    }
 793  
 794    fs.mkdirSync(PLUGINS_DIR, { recursive: true });
 795  
 796    const resolvedPath = path.resolve(localPath);
 797    const linkType = isWindows ? 'junction' : 'dir';
 798    fs.symlinkSync(resolvedPath, targetDir, linkType);
 799  
 800    installDependencies(localPath);
 801    finalizePluginRuntime(localPath);
 802  
 803    const lock = readLockFile();
 804    const commitHash = getCommitHash(localPath);
 805    upsertLockEntry(lock, pluginName, {
 806      source: { kind: 'local', path: resolvedPath },
 807      commitHash: commitHash ?? 'local',
 808    });
 809    writeLockFile(lock);
 810  
 811    return pluginName;
 812  }
 813  
 814  function updateLocalPlugin(
 815    name: string,
 816    targetDir: string,
 817    lock: Record<string, LockEntry>,
 818    lockEntry?: LockEntry,
 819  ): void {
 820    const pluginDir = fs.realpathSync(targetDir);
 821  
 822    const validation = validatePluginStructure(pluginDir);
 823    if (!validation.valid) {
 824      log.warn(`Plugin "${name}" structure invalid:\n- ${validation.errors.join('\n- ')}`);
 825    }
 826  
 827    postInstallLifecycle(pluginDir);
 828  
 829    upsertLockEntry(lock, name, {
 830      source: lockEntry?.source ?? { kind: 'local', path: pluginDir },
 831      commitHash: getCommitHash(pluginDir) ?? 'local',
 832      installedAt: lockEntry?.installedAt ?? new Date().toISOString(),
 833      updatedAt: new Date().toISOString(),
 834    });
 835    writeLockFile(lock);
 836  }
 837  
 838  /** Install sub-plugins from a monorepo. */
 839  function installMonorepo(
 840    cloneDir: string,
 841    cloneUrl: string,
 842    repoName: string,
 843    manifest: PluginManifest,
 844    subPlugin?: string,
 845  ): string[] {
 846    const monoreposDir = getMonoreposDir();
 847    const repoDir = path.join(monoreposDir, repoName);
 848    const repoAlreadyInstalled = fs.existsSync(repoDir);
 849    const repoRoot = repoAlreadyInstalled ? repoDir : cloneDir;
 850    const effectiveManifest = repoAlreadyInstalled ? readPluginManifest(repoDir) : manifest;
 851  
 852    if (!effectiveManifest || !isMonorepo(effectiveManifest)) {
 853      throw new PluginError(`Monorepo manifest missing or invalid at ${repoRoot}`);
 854    }
 855  
 856    let pluginsToInstall = getEnabledPlugins(effectiveManifest);
 857  
 858    // If a specific sub-plugin was requested, filter to just that one
 859    if (subPlugin) {
 860      pluginsToInstall = pluginsToInstall.filter((p) => p.name === subPlugin);
 861      if (pluginsToInstall.length === 0) {
 862        // Check if it exists but is disabled
 863        const disabled = effectiveManifest.plugins?.[subPlugin];
 864        if (disabled) {
 865          throw new PluginError(`Sub-plugin "${subPlugin}" is disabled in the manifest.`);
 866        }
 867        throw new PluginError(
 868          `Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(effectiveManifest.plugins ?? {}).join(', ')}`
 869        );
 870      }
 871    }
 872  
 873    const installedNames: string[] = [];
 874    const lock = readLockFile();
 875    const eligiblePlugins: Array<{ name: string; entry: typeof pluginsToInstall[number]['entry'] }> = [];
 876  
 877    fs.mkdirSync(PLUGINS_DIR, { recursive: true });
 878  
 879    for (const { name, entry } of pluginsToInstall) {
 880      // Check sub-plugin level compatibility (overrides top-level)
 881      if (entry.opencli && !checkCompatibility(entry.opencli)) {
 882        log.warn(`Skipping "${name}": requires opencli ${entry.opencli}`);
 883        continue;
 884      }
 885  
 886      let subDir: string;
 887      try {
 888        subDir = resolveRepoContainedPath(repoRoot, entry.path);
 889      } catch {
 890        log.warn(`Skipping "${name}": path "${entry.path}" escapes repo root.`);
 891        continue;
 892      }
 893      if (!fs.existsSync(subDir)) {
 894        log.warn(`Skipping "${name}": path "${entry.path}" not found in repo.`);
 895        continue;
 896      }
 897  
 898      const validation = validatePluginStructure(subDir);
 899      if (!validation.valid) {
 900        log.warn(`Skipping "${name}": invalid structure — ${validation.errors.join(', ')}`);
 901        continue;
 902      }
 903  
 904      const linkPath = path.join(PLUGINS_DIR, name);
 905      if (fs.existsSync(linkPath)) {
 906        log.warn(`Skipping "${name}": already installed at ${linkPath}`);
 907        continue;
 908      }
 909  
 910      eligiblePlugins.push({ name, entry });
 911    }
 912  
 913    if (eligiblePlugins.length === 0) {
 914      return installedNames;
 915    }
 916  
 917    const publishPlugins = eligiblePlugins.map(({ name, entry }) => ({ name, subPath: entry.path }));
 918  
 919    if (repoAlreadyInstalled) {
 920      postInstallMonorepoLifecycle(
 921        repoDir,
 922        eligiblePlugins.map((p) => resolveRepoContainedPath(repoDir, p.entry.path)),
 923      );
 924    } else {
 925      postInstallMonorepoLifecycle(
 926        cloneDir,
 927        eligiblePlugins.map((p) => resolveRepoContainedPath(cloneDir, p.entry.path)),
 928      );
 929    }
 930  
 931    publishMonorepoPlugins(
 932      repoDir,
 933      PLUGINS_DIR,
 934      publishPlugins,
 935      repoAlreadyInstalled ? undefined : { stagingDir: cloneDir, parentDir: monoreposDir },
 936      (commitHash) => {
 937        for (const { name, entry } of eligiblePlugins) {
 938          if (commitHash) {
 939            upsertLockEntry(lock, name, {
 940              source: {
 941                kind: 'monorepo',
 942                url: cloneUrl,
 943                repoName,
 944                subPath: entry.path,
 945              },
 946              commitHash,
 947            });
 948          }
 949          installedNames.push(name);
 950        }
 951        writeLockFile(lock);
 952      },
 953    );
 954  
 955    return installedNames;
 956  }
 957  
 958  function collectUpdatedMonorepoPlugins(
 959    monoName: string,
 960    lock: Record<string, LockEntry>,
 961    manifest: PluginManifest,
 962    cloneUrl: string,
 963    tmpCloneDir: string,
 964  ): Array<{
 965    name: string;
 966    lockEntry: LockEntry;
 967    manifestEntry: NonNullable<PluginManifest['plugins']>[string];
 968  }> {
 969    const updatedPlugins: Array<{
 970      name: string;
 971      lockEntry: LockEntry;
 972      manifestEntry: NonNullable<PluginManifest['plugins']>[string];
 973    }> = [];
 974  
 975    for (const [pluginName, entry] of Object.entries(lock)) {
 976      if (entry.source.kind !== 'monorepo' || entry.source.repoName !== monoName) continue;
 977      const manifestEntry = manifest.plugins?.[pluginName];
 978      if (!manifestEntry || manifestEntry.disabled) {
 979        throw new Error(`Installed sub-plugin "${pluginName}" no longer exists in ${cloneUrl}`);
 980      }
 981      if (manifestEntry.opencli && !checkCompatibility(manifestEntry.opencli)) {
 982        throw new Error(`Sub-plugin "${pluginName}" requires opencli ${manifestEntry.opencli}`);
 983      }
 984  
 985      const subDir = resolveRepoContainedPath(tmpCloneDir, manifestEntry.path);
 986      const validation = validatePluginStructure(subDir);
 987      if (!validation.valid) {
 988        throw new Error(`Updated sub-plugin "${pluginName}" is invalid:\n- ${validation.errors.join('\n- ')}`);
 989      }
 990      updatedPlugins.push({ name: pluginName, lockEntry: entry, manifestEntry });
 991    }
 992  
 993    return updatedPlugins;
 994  }
 995  
 996  function updateMonorepoLockEntries(
 997    lock: Record<string, LockEntry>,
 998    plugins: Array<{
 999      name: string;
1000      lockEntry: LockEntry;
1001      manifestEntry: NonNullable<PluginManifest['plugins']>[string];
1002    }>,
1003    cloneUrl: string,
1004    monoName: string,
1005    commitHash: string | undefined,
1006  ): void {
1007    for (const plugin of plugins) {
1008      if (!commitHash) continue;
1009      upsertLockEntry(lock, plugin.name, {
1010        ...plugin.lockEntry,
1011        source: {
1012          kind: 'monorepo',
1013          url: cloneUrl,
1014          repoName: monoName,
1015          subPath: plugin.manifestEntry.path,
1016        },
1017        commitHash,
1018        updatedAt: new Date().toISOString(),
1019      });
1020    }
1021  }
1022  
1023  function updateStandaloneLockEntry(
1024    lock: Record<string, LockEntry>,
1025    name: string,
1026    cloneUrl: string,
1027    existing: LockEntry | undefined,
1028    commitHash: string | undefined,
1029  ): void {
1030    if (!commitHash) return;
1031  
1032    upsertLockEntry(lock, name, {
1033      source: { kind: 'git', url: cloneUrl },
1034      commitHash,
1035      installedAt: existing?.installedAt ?? new Date().toISOString(),
1036      updatedAt: new Date().toISOString(),
1037    });
1038  }
1039  
1040  /**
1041   * Uninstall a plugin by name.
1042   * For monorepo sub-plugins: removes symlink and cleans up the monorepo
1043   * directory when no more sub-plugins reference it.
1044   */
1045  export function uninstallPlugin(name: string): void {
1046    const targetDir = path.join(PLUGINS_DIR, name);
1047    if (!fs.existsSync(targetDir)) {
1048      throw new Error(`Plugin "${name}" is not installed.`);
1049    }
1050  
1051    const lock = readLockFile();
1052    const lockEntry = lock[name];
1053  
1054    // Check if this is a symlink (monorepo sub-plugin)
1055    const isSymlink = isSymlinkSync(targetDir);
1056  
1057    if (isSymlink) {
1058      // Remove symlink only (not the actual directory)
1059      fs.unlinkSync(targetDir);
1060    } else {
1061      fs.rmSync(targetDir, { recursive: true, force: true });
1062    }
1063  
1064    // Clean up monorepo directory if no more sub-plugins reference it
1065    if (lockEntry?.source.kind === 'monorepo') {
1066      delete lock[name];
1067      const monoName = lockEntry.source.repoName;
1068      const stillReferenced = Object.values(lock).some(
1069        (entry) => entry.source.kind === 'monorepo' && entry.source.repoName === monoName,
1070      );
1071      if (!stillReferenced) {
1072        const monoDir = path.join(getMonoreposDir(), monoName);
1073        try { fs.rmSync(monoDir, { recursive: true, force: true }); } catch {}
1074      }
1075    } else if (lock[name]) {
1076      delete lock[name];
1077    }
1078  
1079    writeLockFile(lock);
1080  }
1081  
1082  /** Synchronous check if a path is a symlink. */
1083  function isSymlinkSync(p: string): boolean {
1084    try {
1085      return fs.lstatSync(p).isSymbolicLink();
1086    } catch {
1087      return false;
1088    }
1089  }
1090  
1091  /**
1092   * Update a plugin by name (git pull + re-install lifecycle).
1093   * For monorepo sub-plugins: pulls the monorepo root and re-runs lifecycle
1094   * for all sub-plugins from the same monorepo.
1095   */
1096  export function updatePlugin(name: string): void {
1097    const targetDir = path.join(PLUGINS_DIR, name);
1098    if (!fs.existsSync(targetDir)) {
1099      throw new Error(`Plugin "${name}" is not installed.`);
1100    }
1101  
1102    const lock = readLockFile();
1103    const lockEntry = lock[name];
1104    const source = resolvePluginSource(lockEntry, targetDir);
1105  
1106    if (source?.kind === 'local') {
1107      updateLocalPlugin(name, targetDir, lock, lockEntry);
1108      return;
1109    }
1110  
1111    if (source?.kind === 'monorepo') {
1112      const monoDir = path.join(getMonoreposDir(), source.repoName);
1113      const monoName = source.repoName;
1114      const cloneUrl = source.url;
1115      withTempClone(cloneUrl, (tmpCloneDir) => {
1116        const manifest = readPluginManifest(tmpCloneDir);
1117        if (!manifest || !isMonorepo(manifest)) {
1118          throw new Error(`Updated source is no longer a monorepo: ${cloneUrl}`);
1119        }
1120  
1121        if (manifest.opencli && !checkCompatibility(manifest.opencli)) {
1122          throw new Error(
1123            `Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
1124          );
1125        }
1126  
1127        const updatedPlugins = collectUpdatedMonorepoPlugins(
1128          monoName,
1129          lock,
1130          manifest,
1131          cloneUrl,
1132          tmpCloneDir,
1133        );
1134  
1135        if (updatedPlugins.length > 0) {
1136          postInstallMonorepoLifecycle(
1137            tmpCloneDir,
1138            updatedPlugins.map((plugin) => resolveRepoContainedPath(tmpCloneDir, plugin.manifestEntry.path)),
1139          );
1140        }
1141  
1142        publishMonorepoPlugins(
1143          monoDir,
1144          PLUGINS_DIR,
1145          updatedPlugins.map((plugin) => ({ name: plugin.name, subPath: plugin.manifestEntry.path })),
1146          { stagingDir: tmpCloneDir, parentDir: path.dirname(monoDir) },
1147          (commitHash) => {
1148            updateMonorepoLockEntries(lock, updatedPlugins, cloneUrl, monoName, commitHash);
1149            writeLockFile(lock);
1150          },
1151        );
1152      });
1153      return;
1154    }
1155  
1156    const cloneUrl = resolveRemotePluginSource(lockEntry, targetDir);
1157    withTempClone(cloneUrl, (tmpCloneDir) => {
1158      const manifest = readPluginManifest(tmpCloneDir);
1159      if (manifest && isMonorepo(manifest)) {
1160        throw new Error(`Updated source is now a monorepo: ${cloneUrl}`);
1161      }
1162  
1163      if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
1164        throw new Error(
1165          `Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
1166        );
1167      }
1168  
1169      ensureStandalonePluginReady(tmpCloneDir);
1170      publishStandalonePlugin(tmpCloneDir, targetDir, (commitHash) => {
1171        updateStandaloneLockEntry(lock, name, cloneUrl, lock[name], commitHash);
1172        if (commitHash) {
1173          writeLockFile(lock);
1174        }
1175      });
1176    });
1177  }
1178  
1179  export interface UpdateResult {
1180    name: string;
1181    success: boolean;
1182    error?: string;
1183  }
1184  
1185  /**
1186   * Update all installed plugins.
1187   * Continues even if individual plugin updates fail.
1188   */
1189  export function updateAllPlugins(): UpdateResult[] {
1190    return listPlugins().map((plugin): UpdateResult => {
1191      try {
1192        updatePlugin(plugin.name);
1193        return { name: plugin.name, success: true };
1194      } catch (err) {
1195        return {
1196          name: plugin.name,
1197          success: false,
1198          error: getErrorMessage(err),
1199        };
1200      }
1201    });
1202  }
1203  
1204  /**
1205   * List all installed plugins.
1206   * Reads opencli-plugin.json for description/version when available.
1207   */
1208  export function listPlugins(): PluginInfo[] {
1209    if (!fs.existsSync(PLUGINS_DIR)) return [];
1210  
1211    const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
1212    const lock = readLockFile();
1213    const plugins: PluginInfo[] = [];
1214  
1215    for (const entry of entries) {
1216      // Accept both real directories and symlinks (monorepo sub-plugins)
1217      const pluginDir = path.join(PLUGINS_DIR, entry.name);
1218      const isDir = entry.isDirectory() || isSymlinkSync(pluginDir);
1219      if (!isDir) continue;
1220  
1221      const commands = scanPluginCommands(pluginDir);
1222      const lockEntry = lock[entry.name];
1223  
1224      // Try to read manifest for metadata
1225      const manifest = readPluginManifest(pluginDir);
1226      // For monorepo sub-plugins, also check the monorepo root manifest
1227      let description = manifest?.description;
1228      let version = manifest?.version;
1229      if (lockEntry?.source.kind === 'monorepo' && !description) {
1230        const monoDir = path.join(getMonoreposDir(), lockEntry.source.repoName);
1231        const monoManifest = readPluginManifest(monoDir);
1232        const subEntry = monoManifest?.plugins?.[entry.name];
1233        if (subEntry) {
1234          description = description ?? subEntry.description;
1235          version = version ?? subEntry.version;
1236        }
1237      }
1238  
1239      const source = resolveStoredPluginSource(lockEntry, pluginDir);
1240  
1241      plugins.push({
1242        name: entry.name,
1243        path: pluginDir,
1244        commands,
1245        source,
1246        version: version ?? lockEntry?.commitHash?.slice(0, 7),
1247        installedAt: lockEntry?.installedAt,
1248        monorepoName: lockEntry?.source.kind === 'monorepo' ? lockEntry.source.repoName : undefined,
1249        description,
1250      });
1251    }
1252  
1253    return plugins;
1254  }
1255  
1256  /** Scan a plugin directory for command files */
1257  function scanPluginCommands(dir: string): string[] {
1258    try {
1259      const files = fs.readdirSync(dir);
1260      const names = new Set(
1261        files
1262          .filter(f =>
1263            (f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts')) ||
1264            (f.endsWith('.js') && !f.endsWith('.d.js'))
1265          )
1266          .map(f => path.basename(f, path.extname(f)))
1267      );
1268      return [...names];
1269    } catch {
1270      return [];
1271    }
1272  }
1273  
1274  /** Get git remote origin URL */
1275  function getPluginSource(dir: string): string | undefined {
1276    try {
1277      return execFileSync('git', ['config', '--get', 'remote.origin.url'], {
1278        cwd: dir,
1279        encoding: 'utf-8',
1280        stdio: ['pipe', 'pipe', 'pipe'],
1281      }).trim();
1282    } catch {
1283      return undefined;
1284    }
1285  }
1286  
1287  /** Parse a plugin source string into clone URL, repo name, and optional sub-plugin. */
1288  function parseSource(
1289    source: string,
1290  ): ParsedSource | null {
1291    if (source.startsWith('file://')) {
1292      try {
1293        const localPath = path.resolve(fileURLToPath(source));
1294        return {
1295          type: 'local',
1296          localPath,
1297          name: path.basename(localPath).replace(/^opencli-plugin-/, ''),
1298        };
1299      } catch {
1300        return null;
1301      }
1302    }
1303  
1304    if (path.isAbsolute(source)) {
1305      const localPath = path.resolve(source);
1306      return {
1307        type: 'local',
1308        localPath,
1309        name: path.basename(localPath).replace(/^opencli-plugin-/, ''),
1310      };
1311    }
1312  
1313    // github:user/repo/subplugin  (monorepo specific sub-plugin)
1314    const githubSubMatch = source.match(
1315      /^github:([\w.-]+)\/([\w.-]+)\/([\w.-]+)$/,
1316    );
1317    if (githubSubMatch) {
1318      const [, user, repo, sub] = githubSubMatch;
1319      const name = repo.replace(/^opencli-plugin-/, '');
1320      return {
1321        type: 'git',
1322        cloneUrl: `https://github.com/${user}/${repo}.git`,
1323        name,
1324        subPlugin: sub,
1325      };
1326    }
1327  
1328    // github:user/repo
1329    const githubMatch = source.match(/^github:([\w.-]+)\/([\w.-]+)$/);
1330    if (githubMatch) {
1331      const [, user, repo] = githubMatch;
1332      const name = repo.replace(/^opencli-plugin-/, '');
1333      return {
1334        type: 'git',
1335        cloneUrl: `https://github.com/${user}/${repo}.git`,
1336        name,
1337      };
1338    }
1339  
1340    // https://github.com/user/repo (or .git)
1341    const urlMatch = source.match(
1342      /^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?$/,
1343    );
1344    if (urlMatch) {
1345      const [, user, repo] = urlMatch;
1346      const name = repo.replace(/^opencli-plugin-/, '');
1347      return {
1348        type: 'git',
1349        cloneUrl: `https://github.com/${user}/${repo}.git`,
1350        name,
1351      };
1352    }
1353  
1354    // ── Generic git URL support ─────────────────────────────────────────────
1355  
1356    // ssh://git@host/path/to/repo.git
1357    const sshUrlMatch = source.match(/^ssh:\/\/[^/]+\/(.*?)(?:\.git)?$/);
1358    if (sshUrlMatch) {
1359      const pathPart = sshUrlMatch[1];
1360      const segments = pathPart.split('/');
1361      const repoSegment = segments.pop()!;
1362      const name = repoSegment.replace(/^opencli-plugin-/, '');
1363      return { type: 'git', cloneUrl: source, name };
1364    }
1365  
1366    // git@host:user/repo.git (SCP-style)
1367    const scpMatch = source.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
1368    if (scpMatch) {
1369      const pathPart = scpMatch[1];
1370      const segments = pathPart.split('/');
1371      const repoSegment = segments.pop()!;
1372      const name = repoSegment.replace(/^opencli-plugin-/, '');
1373      return { type: 'git', cloneUrl: source, name };
1374    }
1375  
1376    // Generic https/http git URL (non-GitHub hosts)
1377    const genericHttpMatch = source.match(
1378      /^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/,
1379    );
1380    if (genericHttpMatch) {
1381      const pathPart = genericHttpMatch[1];
1382      const segments = pathPart.split('/');
1383      const repoSegment = segments.pop()!;
1384      const name = repoSegment.replace(/^opencli-plugin-/, '');
1385      // Ensure clone URL ends with .git
1386      const cloneUrl = source.endsWith('.git') ? source : `${source}.git`;
1387      return { type: 'git', cloneUrl, name };
1388    }
1389  
1390    return null;
1391  }
1392  
1393  /**
1394   * Symlink the host opencli package into a plugin's node_modules.
1395   * This ensures TS plugins resolve '@jackwener/opencli/registry' against
1396   * the running host installation rather than a stale npm-published version.
1397   */
1398  function linkHostOpencli(pluginDir: string): void {
1399    try {
1400      const hostRoot = resolveHostOpencliRoot();
1401  
1402      const targetLink = path.join(pluginDir, 'node_modules', '@jackwener', 'opencli');
1403  
1404      // Remove existing (npm-installed copy or stale symlink)
1405      if (fs.existsSync(targetLink)) {
1406        fs.rmSync(targetLink, { recursive: true, force: true });
1407      }
1408  
1409      // Ensure parent directory exists
1410      fs.mkdirSync(path.dirname(targetLink), { recursive: true });
1411  
1412      // Use 'junction' on Windows (doesn't require admin privileges),
1413      // 'dir' symlink on other platforms.
1414      const linkType = isWindows ? 'junction' : 'dir';
1415      fs.symlinkSync(hostRoot, targetLink, linkType);
1416      log.debug(`Linked host opencli into plugin: ${targetLink} → ${hostRoot}`);
1417    } catch (err) {
1418      log.warn(`Failed to link host opencli into plugin: ${getErrorMessage(err)}`);
1419    }
1420  }
1421  
1422  /**
1423   * Resolve the path to the esbuild CLI executable with fallback strategies.
1424   */
1425  export function resolveEsbuildBin(): string | null {
1426    const hostRoot = resolveHostOpencliRoot();
1427  
1428    // Strategy 1 (Windows): prefer the .cmd wrapper which is executable via shell
1429    if (isWindows) {
1430      const cmdPath = path.join(hostRoot, 'node_modules', '.bin', 'esbuild.cmd');
1431      if (fs.existsSync(cmdPath)) {
1432        return cmdPath;
1433      }
1434    }
1435  
1436    // Strategy 2: resolve esbuild binary via import.meta.resolve
1437    // (On Unix, shebang scripts are directly executable; on Windows they are not,
1438    //  so this strategy is skipped on Windows in favour of the .cmd wrapper above.)
1439    if (!isWindows) {
1440      try {
1441        const pkgUrl = import.meta.resolve('esbuild/package.json');
1442        if (pkgUrl.startsWith('file://')) {
1443          const pkgPath = fileURLToPath(pkgUrl);
1444          const pkgRaw = fs.readFileSync(pkgPath, 'utf8');
1445          const pkg = JSON.parse(pkgRaw);
1446          if (pkg.bin && typeof pkg.bin === 'object' && pkg.bin.esbuild) {
1447            const binPath = path.resolve(path.dirname(pkgPath), pkg.bin.esbuild);
1448            if (fs.existsSync(binPath)) return binPath;
1449          } else if (typeof pkg.bin === 'string') {
1450            const binPath = path.resolve(path.dirname(pkgPath), pkg.bin);
1451            if (fs.existsSync(binPath)) return binPath;
1452          }
1453        }
1454      } catch {
1455        // ignore package resolution failures
1456      }
1457    }
1458  
1459    // Strategy 3: fallback to node_modules/.bin/esbuild (Unix)
1460    const binFallback = path.join(hostRoot, 'node_modules', '.bin', 'esbuild');
1461    if (fs.existsSync(binFallback)) {
1462      return binFallback;
1463    }
1464  
1465    // Strategy 4: global esbuild in PATH
1466    try {
1467      const lookupCmd = isWindows ? 'where esbuild' : 'which esbuild';
1468      // `where` on Windows may return multiple lines; take only the first match.
1469      const globalBin = execSync(lookupCmd, { encoding: 'utf-8', stdio: 'pipe' }).trim().split('\n')[0].trim();
1470      if (globalBin && fs.existsSync(globalBin)) {
1471        return globalBin;
1472      }
1473    } catch {
1474      // ignore PATH lookup failures
1475    }
1476  
1477    return null;
1478  }
1479  
1480  function resolveHostOpencliRoot(startFile = fileURLToPath(import.meta.url)): string {
1481    let dir = path.dirname(startFile);
1482  
1483    while (true) {
1484      const pkgPath = path.join(dir, 'package.json');
1485      if (fs.existsSync(pkgPath)) {
1486        try {
1487          const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
1488          if (pkg?.name === '@jackwener/opencli') {
1489            return dir;
1490          }
1491        } catch {
1492          // Keep walking; a malformed package.json should not hide an ancestor package root.
1493        }
1494      }
1495  
1496      const parent = path.dirname(dir);
1497      if (parent === dir) break;
1498      dir = parent;
1499    }
1500  
1501    return path.resolve(path.dirname(startFile), '..');
1502  }
1503  
1504  /**
1505   * Transpile TS plugin files to JS so they work in production mode.
1506   * Uses esbuild from the host opencli's node_modules for fast single-file transpilation.
1507   */
1508  function transpilePluginTs(pluginDir: string): void {
1509    try {
1510      const esbuildBin = resolveEsbuildBin();
1511  
1512      if (!esbuildBin) {
1513        log.warn(
1514          'esbuild not found. TS plugin files will not be transpiled and may fail to load. ' +
1515          'Install esbuild (`npm i -g esbuild`) or ensure it is available in the opencli host node_modules.'
1516        );
1517        return;
1518      }
1519  
1520      const files = fs.readdirSync(pluginDir);
1521      const tsFiles = files.filter(f =>
1522        f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts')
1523      );
1524  
1525      for (const tsFile of tsFiles) {
1526        const jsFile = tsFile.replace(/\.ts$/, '.js');
1527        const jsPath = path.join(pluginDir, jsFile);
1528  
1529        // Skip if .js already exists (plugin may ship pre-compiled)
1530        if (fs.existsSync(jsPath)) continue;
1531  
1532        try {
1533          execFileSync(esbuildBin, [tsFile, `--outfile=${jsFile}`, '--format=esm', '--platform=node'], {
1534            cwd: pluginDir,
1535            encoding: 'utf-8',
1536            stdio: ['pipe', 'pipe', 'pipe'],
1537            ...(isWindows && { shell: true }),
1538          });
1539          log.debug(`Transpiled plugin file: ${tsFile} → ${jsFile}`);
1540        } catch (err) {
1541          log.warn(`Failed to transpile ${tsFile}: ${getErrorMessage(err)}`);
1542        }
1543      }
1544    } catch (err) {
1545      log.warn(`TS transpilation setup failed: ${getErrorMessage(err)}`);
1546    }
1547  }
1548  
1549  export {
1550    resolveHostOpencliRoot as _resolveHostOpencliRoot,
1551    resolveEsbuildBin as _resolveEsbuildBin,
1552    getCommitHash as _getCommitHash,
1553    installDependencies as _installDependencies,
1554    parseSource as _parseSource,
1555    postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle,
1556    readLockFile as _readLockFile,
1557    readLockFileWithWriter as _readLockFileWithWriter,
1558    updateAllPlugins as _updateAllPlugins,
1559    validatePluginStructure as _validatePluginStructure,
1560    writeLockFile as _writeLockFile,
1561    writeLockFileWithFs as _writeLockFileWithFs,
1562    isSymlinkSync as _isSymlinkSync,
1563    getMonoreposDir as _getMonoreposDir,
1564    installLocalPlugin as _installLocalPlugin,
1565    isLocalPluginSource as _isLocalPluginSource,
1566    moveDir as _moveDir,
1567    resolvePluginSource as _resolvePluginSource,
1568    resolveStoredPluginSource as _resolveStoredPluginSource,
1569    toStoredPluginSource as _toStoredPluginSource,
1570    toLocalPluginSource as _toLocalPluginSource,
1571  };