From 8d9dba1a0aa1f473c3eb63853a4e2647f1b27a3c Mon Sep 17 00:00:00 2001 From: voita Date: Mon, 13 Apr 2026 22:02:16 +0800 Subject: [PATCH] fix: install unresolved monorepo plugin deps --- src/plugin.test.ts | 20 ++++++++++++++++++++ src/plugin.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/plugin.test.ts b/src/plugin.test.ts index ad7a2361c..047d2f468 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -650,6 +650,26 @@ describe('postInstallMonorepoLifecycle', () => { expect(npmCalls[0][2]).toMatchObject({ cwd: repoDir }); expect(npmCalls.some(([, , opts]) => opts?.cwd === subDir)).toBe(false); }); + + it('falls back to plugin-local install when sub-plugin dependencies are unresolved from the repo root', () => { + fs.writeFileSync(path.join(subDir, 'package.json'), JSON.stringify({ + name: 'alpha-plugin', + type: 'module', + dependencies: { + undici: '^7.0.0', + }, + })); + + _postInstallMonorepoLifecycle(repoDir, [subDir]); + + const npmCalls = mockExecFileSync.mock.calls.filter( + ([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install', + ); + + expect(npmCalls).toHaveLength(2); + expect(npmCalls[0][2]).toMatchObject({ cwd: repoDir }); + expect(npmCalls[1][2]).toMatchObject({ cwd: subDir }); + }); }); describe('updateAllPlugins', () => { diff --git a/src/plugin.ts b/src/plugin.ts index 99c22594f..af2b070cd 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -11,6 +11,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { execSync, execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { PLUGINS_DIR } from './discovery.js'; import { getErrorMessage, PluginError } from './errors.js'; @@ -589,6 +590,44 @@ function installDependencies(dir: string): void { } } +function getMissingRuntimeDependencies(pluginDir: string): string[] { + const pkgJsonPath = path.join(pluginDir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) return []; + + let pkg: Record; + try { + pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as Record; + } catch { + return []; + } + + const declaredDeps = [ + ...Object.keys((pkg.dependencies as Record | undefined) ?? {}), + ...Object.keys((pkg.optionalDependencies as Record | undefined) ?? {}), + ]; + if (declaredDeps.length === 0) return []; + + const requireFromPlugin = createRequire(path.join(pluginDir, '__opencli_plugin__.cjs')); + return declaredDeps.filter((dep) => { + try { + requireFromPlugin.resolve(dep); + return false; + } catch { + return true; + } + }); +} + +function ensurePluginRuntimeDependencies(pluginDir: string): void { + const missing = getMissingRuntimeDependencies(pluginDir); + if (missing.length === 0) return; + + log.debug( + `Installing plugin-local dependencies in ${pluginDir} because root install did not resolve: ${missing.join(', ')}` + ); + installDependencies(pluginDir); +} + function finalizePluginRuntime(pluginDir: string): void { // Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry' // against the running host, not a stale npm-published version. @@ -612,6 +651,7 @@ function postInstallLifecycle(pluginDir: string): void { function postInstallMonorepoLifecycle(repoDir: string, pluginDirs: string[]): void { installDependencies(repoDir); for (const pluginDir of pluginDirs) { + ensurePluginRuntimeDependencies(pluginDir); finalizePluginRuntime(pluginDir); } }