diff --git a/packages/core/src/lib/plugin-loader.spec.ts b/packages/core/src/lib/plugin-loader.spec.ts index 847792c..d63ddae 100644 --- a/packages/core/src/lib/plugin-loader.spec.ts +++ b/packages/core/src/lib/plugin-loader.spec.ts @@ -2,6 +2,14 @@ import { GeneratorRegistry } from './registry'; import { PluginLoadError, PluginNotFoundError } from './errors'; import { GeneratorPlugin } from './interfaces'; import { loadPlugin } from './plugin-loader'; +import * as autoInstaller from './auto-installer'; + +// Mock the auto-installer module +jest.mock('./auto-installer', () => ({ + installPackages: jest.fn(), + detectCi: jest.fn().mockReturnValue(false), + detectPackageManager: jest.fn().mockReturnValue('npm'), +})); // Mock the dynamic imports jest.mock( @@ -26,6 +34,9 @@ describe('plugin-loader', () => { registry = GeneratorRegistry.instance(); jest.spyOn(registry, 'has'); jest.spyOn(registry, 'get'); + // Reset auto-installer mocks + (autoInstaller.detectCi as jest.Mock).mockReturnValue(false); + (autoInstaller.installPackages as jest.Mock).mockClear(); }); afterEach(() => { @@ -249,5 +260,141 @@ describe('plugin-loader', () => { PluginLoadError ); }); + + describe('auto-installation', () => { + it('should attempt auto-installation for missing @nx-plugin-openapi packages', async () => { + const mockPlugin = { + name: 'plugin-test', + generate: jest.fn(), + }; + + // The module mock will throw on first import, then succeed after "installation" + let isInstalled = false; + jest.doMock( + '@nx-plugin-openapi/plugin-test', + () => { + if (!isInstalled) { + const error = new Error('Cannot find module'); + (error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND'; + throw error; + } + return { default: mockPlugin }; + }, + { virtual: true } + ); + + // Mock successful installation that sets the flag + (autoInstaller.installPackages as jest.Mock).mockImplementation(() => { + isInstalled = true; + }); + + const result = await loadPlugin('@nx-plugin-openapi/plugin-test'); + + expect(autoInstaller.installPackages).toHaveBeenCalledWith( + ['@nx-plugin-openapi/plugin-test'], + { dev: true } + ); + expect(result).toBe(mockPlugin); + }); + + it('should not attempt auto-installation in CI environment', async () => { + (autoInstaller.detectCi as jest.Mock).mockReturnValue(true); + + jest.doMock( + '@nx-plugin-openapi/plugin-ci-test', + () => { + const error = new Error('Cannot find module'); + (error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND'; + throw error; + }, + { virtual: true } + ); + + await expect( + loadPlugin('@nx-plugin-openapi/plugin-ci-test') + ).rejects.toThrow(PluginNotFoundError); + + expect(autoInstaller.installPackages).not.toHaveBeenCalled(); + }); + + it('should not attempt auto-installation for non-nx-plugin-openapi packages', async () => { + jest.doMock( + 'external-plugin', + () => { + const error = new Error('Cannot find module'); + (error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND'; + throw error; + }, + { virtual: true } + ); + + await expect(loadPlugin('external-plugin')).rejects.toThrow( + PluginNotFoundError + ); + + expect(autoInstaller.installPackages).not.toHaveBeenCalled(); + }); + + it('should handle auto-installation failure gracefully', async () => { + jest.doMock( + '@nx-plugin-openapi/plugin-fail-test', + () => { + const error = new Error('Cannot find module'); + (error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND'; + throw error; + }, + { virtual: true } + ); + + // Mock installation failure + (autoInstaller.installPackages as jest.Mock).mockImplementation(() => { + throw new Error('Installation failed'); + }); + + await expect( + loadPlugin('@nx-plugin-openapi/plugin-fail-test') + ).rejects.toThrow(PluginNotFoundError); + + expect(autoInstaller.installPackages).toHaveBeenCalledWith( + ['@nx-plugin-openapi/plugin-fail-test'], + { dev: true } + ); + }); + + it('should use built-in mapping for auto-installation', async () => { + const mockPlugin = { + name: 'hey-openapi', + generate: jest.fn(), + }; + + // The module mock will throw on first import, then succeed after "installation" + let isInstalled = false; + jest.doMock( + '@nx-plugin-openapi/plugin-hey-openapi', + () => { + if (!isInstalled) { + const error = new Error('Cannot find module'); + (error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND'; + throw error; + } + return { default: mockPlugin }; + }, + { virtual: true } + ); + + // Mock successful installation that sets the flag + (autoInstaller.installPackages as jest.Mock).mockImplementation(() => { + isInstalled = true; + }); + + const result = await loadPlugin('hey-openapi'); + + expect(autoInstaller.installPackages).toHaveBeenCalledWith( + ['@nx-plugin-openapi/plugin-hey-openapi'], + { dev: true } + ); + expect(result).toBe(mockPlugin); + }); + }); }); }); diff --git a/packages/core/src/lib/plugin-loader.ts b/packages/core/src/lib/plugin-loader.ts index 6c81298..104c3e4 100644 --- a/packages/core/src/lib/plugin-loader.ts +++ b/packages/core/src/lib/plugin-loader.ts @@ -3,6 +3,7 @@ import { PluginLoadError, PluginNotFoundError } from './errors'; import { GeneratorRegistry } from './registry'; import { isGeneratorPlugin } from './type-guards'; import { logger } from '@nx/devkit'; +import { detectCi, installPackages } from './auto-installer'; const BUILTIN_PLUGIN_MAP: Record = { 'openapi-tools': '@nx-plugin-openapi/plugin-openapi', @@ -11,6 +12,23 @@ const BUILTIN_PLUGIN_MAP: Record = { const cache = new Map(); +/** + * Helper function to determine if auto-installation should be attempted + */ +function shouldTryAutoInstall(error: unknown, packageName: string): boolean { + const msg = String(error); + const code = (error as Record)?.['code']; + + return ( + // Only for module not found errors + (code === 'ERR_MODULE_NOT_FOUND' || /Cannot find module/.test(msg)) && + // Only for known plugin packages + packageName.startsWith('@nx-plugin-openapi/') && + // Not in CI environment + !detectCi() + ); +} + export async function loadPlugin( name: string, opts?: { root?: string } @@ -89,7 +107,9 @@ export async function loadPlugin( pkg === '@nx-plugin-openapi/plugin-openapi' || pkg === '@nx-plugin-openapi/plugin-hey-openapi' ) { - const pkgName = pkg.split('/').pop()!; // e.g., 'plugin-openapi' or 'plugin-hey-openapi' + const pkgName = pkg.split('/').pop() ?? ''; // e.g., 'plugin-openapi' or 'plugin-hey-openapi' + // TODO remove fallback paths as this is no scenario for published packages. + // for local development we should use another strategy const fallbackPaths = [ `${root}/dist/packages/${pkgName}/src/index.js`, `${root}/packages/${pkgName}/src/index.js`, @@ -117,6 +137,70 @@ export async function loadPlugin( } } + // Auto-installation logic + if (shouldTryAutoInstall(e, pkg)) { + logger.info(`Attempting to auto-install missing plugin: ${pkg}`); + try { + installPackages([pkg], { dev: true }); + logger.info(`Successfully installed ${pkg}, retrying import...`); + + // Retry the import after installation + // The module should now be available after installation + const retryMod = (await import(pkg)) as { + default?: unknown; + createPlugin?: unknown; + plugin?: unknown; + Plugin?: unknown; + }; + + let candidate: unknown = undefined; + + // Try different export patterns (same as above) + if (isGeneratorPlugin(retryMod.default)) { + logger.debug(`Found plugin as default export after installation`); + candidate = retryMod.default; + } else if (typeof retryMod.createPlugin === 'function') { + logger.debug( + `Found createPlugin factory function after installation` + ); + candidate = (retryMod.createPlugin as () => unknown)(); + } else if (isGeneratorPlugin(retryMod.plugin)) { + logger.debug( + `Found plugin as named export 'plugin' after installation` + ); + candidate = retryMod.plugin; + } else if (isGeneratorPlugin(retryMod.Plugin)) { + logger.debug( + `Found plugin as named export 'Plugin' after installation` + ); + candidate = retryMod.Plugin; + } + + if (!isGeneratorPlugin(candidate)) { + const availableExports = Object.keys(retryMod).filter( + (k) => k !== '__esModule' + ); + throw new PluginLoadError( + name, + new Error( + `Module does not export a valid plugin after installation. Available exports: ${availableExports.join( + ', ' + )}` + ) + ); + } + + logger.info( + `Successfully loaded plugin after auto-installation: ${name}` + ); + cache.set(name, candidate); + return candidate; + } catch (installError) { + logger.warn(`Auto-installation failed for ${pkg}: ${installError}`); + // Continue to existing error handling + } + } + // Determine appropriate error type const msg = String(e); const code = (e as Record)?.['code']; diff --git a/packages/plugin-hey-openapi/eslint.config.js b/packages/plugin-hey-openapi/eslint.config.js index 9d2af7a..65c6cf8 100644 --- a/packages/plugin-hey-openapi/eslint.config.js +++ b/packages/plugin-hey-openapi/eslint.config.js @@ -9,6 +9,7 @@ module.exports = [ 'error', { ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'], + ignoredDependencies: ['@hey-api/openapi-ts'], }, ], }, diff --git a/packages/plugin-openapi/eslint.config.js b/packages/plugin-openapi/eslint.config.js index 9d2af7a..543c05f 100644 --- a/packages/plugin-openapi/eslint.config.js +++ b/packages/plugin-openapi/eslint.config.js @@ -9,6 +9,7 @@ module.exports = [ 'error', { ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'], + ignoredDependencies: ['@openapitools/openapi-generator-cli'], }, ], },