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 };