Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions packages/core/src/lib/plugin-loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ describe('plugin-loader', () => {
});

describe('auto-installation', () => {
it('should attempt auto-installation for missing @nx-plugin-openapi packages after node_modules check', async () => {
it('should attempt auto-installation for missing @nx-plugin-openapi packages when skipPrompt is true', async () => {
const mockPlugin = {
name: 'plugin-test',
generate: jest.fn(),
Expand All @@ -331,7 +331,10 @@ describe('plugin-loader', () => {
isInstalled = true;
});

const result = await loadPlugin('@nx-plugin-openapi/plugin-test');
// Use skipPrompt: true to bypass the interactive prompt in tests
const result = await loadPlugin('@nx-plugin-openapi/plugin-test', {
skipPrompt: true,
});

expect(autoInstaller.installPackages).toHaveBeenCalledWith(
['@nx-plugin-openapi/plugin-test'],
Expand All @@ -340,6 +343,27 @@ describe('plugin-loader', () => {
expect(result).toBe(mockPlugin);
});

it('should skip auto-installation in non-interactive environments without skipPrompt', async () => {
// In test environment, process.stdin.isTTY is false, so prompt should be skipped
// and installation should not proceed
jest.doMock(
'@nx-plugin-openapi/plugin-non-tty-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-non-tty-test')
).rejects.toThrow(PluginNotFoundError);

// Installation should NOT be called because prompt returned false
expect(autoInstaller.installPackages).not.toHaveBeenCalled();
});

it('should not attempt auto-installation in CI environment', async () => {
(autoInstaller.detectCi as jest.Mock).mockReturnValue(true);

Expand Down Expand Up @@ -395,7 +419,7 @@ describe('plugin-loader', () => {
});

await expect(
loadPlugin('@nx-plugin-openapi/plugin-fail-test')
loadPlugin('@nx-plugin-openapi/plugin-fail-test', { skipPrompt: true })
).rejects.toThrow(PluginNotFoundError);

expect(autoInstaller.installPackages).toHaveBeenCalledWith(
Expand Down Expand Up @@ -430,7 +454,7 @@ describe('plugin-loader', () => {
isInstalled = true;
});

const result = await loadPlugin('hey-api');
const result = await loadPlugin('hey-api', { skipPrompt: true });

expect(autoInstaller.installPackages).toHaveBeenCalledWith(
['@nx-plugin-openapi/plugin-hey-api'],
Expand All @@ -439,6 +463,43 @@ describe('plugin-loader', () => {
expect(result).toBe(mockPlugin);
});

it('should log the detected package manager when installing', async () => {
const mockPlugin = {
name: 'plugin-pm-log',
generate: jest.fn(),
};

let isInstalled = false;
jest.doMock(
'@nx-plugin-openapi/plugin-pm-log',
() => {
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 }
);

(autoInstaller.installPackages as jest.Mock).mockImplementation(() => {
isInstalled = true;
});

// Mock detectPackageManager to return a specific value
(autoInstaller.detectPackageManager as jest.Mock).mockReturnValue(
'pnpm'
);

await loadPlugin('@nx-plugin-openapi/plugin-pm-log', {
skipPrompt: true,
});

// Verify detectPackageManager was called
expect(autoInstaller.detectPackageManager).toHaveBeenCalled();
});

it('should check node_modules before attempting auto-installation', async () => {
const mockPlugin = {
name: 'already-installed',
Expand Down
112 changes: 85 additions & 27 deletions packages/core/src/lib/plugin-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ 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';
import {
detectCi,
detectPackageManager,
installPackages,
} from './auto-installer';
import { isLocalDev } from './utils/is-local-dev';
import * as readline from 'node:readline';

const BUILTIN_PLUGIN_MAP: Record<string, string> = {
'openapi-tools': '@nx-plugin-openapi/plugin-openapi',
Expand All @@ -30,9 +35,51 @@ function shouldTryAutoInstall(error: unknown, packageName: string): boolean {
);
}

/**
* Extracts a clean error message without stack trace for user-facing logs.
* Full error details are logged via logger.debug for verbose mode.
*/
function getCleanErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

/**
* Prompts the user for confirmation before auto-installing a package.
* Returns true if user confirms (y/Y/yes), false otherwise.
* In non-interactive environments, returns false.
*/
async function promptForInstall(packageName: string): Promise<boolean> {
// Check if stdin is a TTY (interactive terminal)
if (!process.stdin.isTTY) {
logger.debug('Non-interactive environment detected, skipping prompt');
return false;
}

const pm = detectPackageManager();

return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

rl.question(
`\nPlugin '${packageName}' is not installed.\nWould you like to install it using ${pm}? (y/n) `,
(answer) => {
rl.close();
const normalizedAnswer = answer.trim().toLowerCase();
resolve(normalizedAnswer === 'y' || normalizedAnswer === 'yes');
}
);
});
}

export async function loadPlugin(
name: string,
opts?: { root?: string }
opts?: { root?: string; skipPrompt?: boolean }
): Promise<GeneratorPlugin> {
logger.debug(`Loading plugin: ${name}`);

Expand Down Expand Up @@ -110,29 +157,40 @@ export async function loadPlugin(
const isModuleNotFound =
code === 'ERR_MODULE_NOT_FOUND' || /Cannot find module/.test(msg);

// Log clean message for users, full details in debug mode
logger.debug(`Failed to load plugin from node_modules: ${e}`);

// 2. If module not found and auto-install conditions are met, try auto-installation
if (isModuleNotFound && shouldTryAutoInstall(e, pkg)) {
logger.info(
`Plugin ${pkg} not found in node_modules. Attempting to auto-install...`
);
try {
installPackages([pkg], { dev: true });
logger.info(`Successfully installed ${pkg}, loading plugin...`);

// Retry the import after installation
const retryMod = await import(pkg);
const plugin = await loadFromModule(retryMod);

logger.info(
`Successfully loaded plugin after auto-installation: ${name}`
);
cache.set(name, plugin);
return plugin;
} catch (installError) {
logger.warn(`Auto-installation failed for ${pkg}: ${installError}`);
// Continue to fallback paths for local development
// Prompt user for confirmation (unless skipped via option)
const shouldInstall = opts?.skipPrompt
? true
: await promptForInstall(pkg);

if (shouldInstall) {
const pm = detectPackageManager();
logger.info(`Installing ${pkg} using ${pm}...`);
try {
installPackages([pkg], { dev: true });
logger.info(`Successfully installed ${pkg}`);

// Retry the import after installation
const retryMod = await import(pkg);
const plugin = await loadFromModule(retryMod);

logger.info(`Successfully loaded plugin: ${name}`);
cache.set(name, plugin);
return plugin;
} catch (installError) {
// Show clean message to user, full details in debug
logger.debug(`Full installation error: ${installError}`);
logger.warn(
`Auto-installation failed for ${pkg}: ${getCleanErrorMessage(installError)}`
);
// Continue to fallback paths for local development
}
} else {
logger.info(`Skipping installation of ${pkg}`);
}
}

Expand Down Expand Up @@ -179,15 +237,15 @@ export async function loadPlugin(

// 4. If all attempts failed, throw appropriate error
if (isModuleNotFound) {
logger.error(
`Plugin not found: ${name}. Searched paths: ${JSON.stringify(
searchPaths
)}`
);
// User-friendly message without technical details
logger.error(`Plugin not found: ${name}`);
logger.debug(`Searched paths: ${JSON.stringify(searchPaths)}`);
throw new PluginNotFoundError(name, searchPaths);
}

logger.error(`Failed to load plugin: ${name}. Error: ${e}`);
// User-friendly error, full details in debug
logger.error(`Failed to load plugin: ${name}. ${getCleanErrorMessage(e)}`);
logger.debug(`Full error details: ${e}`);
throw new PluginLoadError(name, e);
}
}