From 182be24bb996cec589f0fb8423529e683d05c771 Mon Sep 17 00:00:00 2001 From: Mac Mini Date: Tue, 16 Sep 2025 07:04:48 +0200 Subject: [PATCH 1/3] feat(core): integrate auto-installer with plugin loader for seamless plugin installation - Add auto-installation logic to plugin loader when packages are missing - Auto-install only for @nx-plugin-openapi/* packages in development - Skip auto-installation in CI environments - Handle installation failures gracefully with proper logging - Add comprehensive tests for auto-installation scenarios Fixes #66 --- packages/core/src/lib/plugin-loader.spec.ts | 147 ++++++++++++++++++++ packages/core/src/lib/plugin-loader.ts | 76 +++++++++- 2 files changed, 222 insertions(+), 1 deletion(-) 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..fe4d280 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 { installPackages, detectCi } 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,7 @@ 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' const fallbackPaths = [ `${root}/dist/packages/${pkgName}/src/index.js`, `${root}/packages/${pkgName}/src/index.js`, @@ -117,6 +135,62 @@ 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']; From eb891b6056eadfabca2c76e7da8085a1bd787a6c Mon Sep 17 00:00:00 2001 From: Michael Berger Date: Wed, 17 Sep 2025 06:19:13 +0200 Subject: [PATCH 2/3] fix: ignore nx-deps-checks --- packages/plugin-hey-openapi/eslint.config.js | 1 + packages/plugin-openapi/eslint.config.js | 1 + 2 files changed, 2 insertions(+) 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'], }, ], }, From 963b5691b90a95ba20cf52a40369bb0fc6809079 Mon Sep 17 00:00:00 2001 From: Michael Berger Date: Wed, 17 Sep 2025 06:19:27 +0200 Subject: [PATCH 3/3] chore: add todo --- packages/core/src/lib/plugin-loader.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/core/src/lib/plugin-loader.ts b/packages/core/src/lib/plugin-loader.ts index fe4d280..104c3e4 100644 --- a/packages/core/src/lib/plugin-loader.ts +++ b/packages/core/src/lib/plugin-loader.ts @@ -3,7 +3,7 @@ import { PluginLoadError, PluginNotFoundError } from './errors'; import { GeneratorRegistry } from './registry'; import { isGeneratorPlugin } from './type-guards'; import { logger } from '@nx/devkit'; -import { installPackages, detectCi } from './auto-installer'; +import { detectCi, installPackages } from './auto-installer'; const BUILTIN_PLUGIN_MAP: Record = { 'openapi-tools': '@nx-plugin-openapi/plugin-openapi', @@ -18,7 +18,7 @@ const cache = new Map(); 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)) && @@ -108,6 +108,8 @@ export async function loadPlugin( pkg === '@nx-plugin-openapi/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`, @@ -141,7 +143,7 @@ export async function loadPlugin( 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 { @@ -158,13 +160,19 @@ export async function loadPlugin( 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`); + 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`); + 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`); + logger.debug( + `Found plugin as named export 'Plugin' after installation` + ); candidate = retryMod.Plugin; } @@ -182,7 +190,9 @@ export async function loadPlugin( ); } - logger.info(`Successfully loaded plugin after auto-installation: ${name}`); + logger.info( + `Successfully loaded plugin after auto-installation: ${name}` + ); cache.set(name, candidate); return candidate; } catch (installError) {