Skip to content

Commit 69169e8

Browse files
michaelbe812claude
andcommitted
feat(core): improve logging for plugin auto-installation
- Add user confirmation prompt before auto-installing packages - Log detected package manager (npm/pnpm/yarn) when installing - Hide stack traces from user-facing logs, show in debug mode only - Add skipPrompt option to loadPlugin for programmatic usage Closes #73 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e631837 commit 69169e8

2 files changed

Lines changed: 150 additions & 31 deletions

File tree

packages/core/src/lib/plugin-loader.spec.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ describe('plugin-loader', () => {
305305
});
306306

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

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

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

346+
it('should skip auto-installation in non-interactive environments without skipPrompt', async () => {
347+
// In test environment, process.stdin.isTTY is false, so prompt should be skipped
348+
// and installation should not proceed
349+
jest.doMock(
350+
'@nx-plugin-openapi/plugin-non-tty-test',
351+
() => {
352+
const error = new Error('Cannot find module');
353+
(error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND';
354+
throw error;
355+
},
356+
{ virtual: true }
357+
);
358+
359+
await expect(
360+
loadPlugin('@nx-plugin-openapi/plugin-non-tty-test')
361+
).rejects.toThrow(PluginNotFoundError);
362+
363+
// Installation should NOT be called because prompt returned false
364+
expect(autoInstaller.installPackages).not.toHaveBeenCalled();
365+
});
366+
343367
it('should not attempt auto-installation in CI environment', async () => {
344368
(autoInstaller.detectCi as jest.Mock).mockReturnValue(true);
345369

@@ -395,7 +419,7 @@ describe('plugin-loader', () => {
395419
});
396420

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

401425
expect(autoInstaller.installPackages).toHaveBeenCalledWith(
@@ -430,7 +454,7 @@ describe('plugin-loader', () => {
430454
isInstalled = true;
431455
});
432456

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

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

466+
it('should log the detected package manager when installing', async () => {
467+
const mockPlugin = {
468+
name: 'plugin-pm-log',
469+
generate: jest.fn(),
470+
};
471+
472+
let isInstalled = false;
473+
jest.doMock(
474+
'@nx-plugin-openapi/plugin-pm-log',
475+
() => {
476+
if (!isInstalled) {
477+
const error = new Error('Cannot find module');
478+
(error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND';
479+
throw error;
480+
}
481+
return { default: mockPlugin };
482+
},
483+
{ virtual: true }
484+
);
485+
486+
(autoInstaller.installPackages as jest.Mock).mockImplementation(() => {
487+
isInstalled = true;
488+
});
489+
490+
// Mock detectPackageManager to return a specific value
491+
(autoInstaller.detectPackageManager as jest.Mock).mockReturnValue(
492+
'pnpm'
493+
);
494+
495+
await loadPlugin('@nx-plugin-openapi/plugin-pm-log', {
496+
skipPrompt: true,
497+
});
498+
499+
// Verify detectPackageManager was called
500+
expect(autoInstaller.detectPackageManager).toHaveBeenCalled();
501+
});
502+
442503
it('should check node_modules before attempting auto-installation', async () => {
443504
const mockPlugin = {
444505
name: 'already-installed',

packages/core/src/lib/plugin-loader.ts

Lines changed: 85 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import { PluginLoadError, PluginNotFoundError } from './errors';
33
import { GeneratorRegistry } from './registry';
44
import { isGeneratorPlugin } from './type-guards';
55
import { logger } from '@nx/devkit';
6-
import { detectCi, installPackages } from './auto-installer';
6+
import {
7+
detectCi,
8+
detectPackageManager,
9+
installPackages,
10+
} from './auto-installer';
711
import { isLocalDev } from './utils/is-local-dev';
12+
import * as readline from 'node:readline';
813

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

38+
/**
39+
* Extracts a clean error message without stack trace for user-facing logs.
40+
* Full error details are logged via logger.debug for verbose mode.
41+
*/
42+
function getCleanErrorMessage(error: unknown): string {
43+
if (error instanceof Error) {
44+
return error.message;
45+
}
46+
return String(error);
47+
}
48+
49+
/**
50+
* Prompts the user for confirmation before auto-installing a package.
51+
* Returns true if user confirms (y/Y/yes), false otherwise.
52+
* In non-interactive environments, returns false.
53+
*/
54+
async function promptForInstall(packageName: string): Promise<boolean> {
55+
// Check if stdin is a TTY (interactive terminal)
56+
if (!process.stdin.isTTY) {
57+
logger.debug('Non-interactive environment detected, skipping prompt');
58+
return false;
59+
}
60+
61+
const pm = detectPackageManager();
62+
63+
return new Promise((resolve) => {
64+
const rl = readline.createInterface({
65+
input: process.stdin,
66+
output: process.stdout,
67+
});
68+
69+
rl.question(
70+
`\nPlugin '${packageName}' is not installed.\nWould you like to install it using ${pm}? (y/n) `,
71+
(answer) => {
72+
rl.close();
73+
const normalizedAnswer = answer.trim().toLowerCase();
74+
resolve(normalizedAnswer === 'y' || normalizedAnswer === 'yes');
75+
}
76+
);
77+
});
78+
}
79+
3380
export async function loadPlugin(
3481
name: string,
35-
opts?: { root?: string }
82+
opts?: { root?: string; skipPrompt?: boolean }
3683
): Promise<GeneratorPlugin> {
3784
logger.debug(`Loading plugin: ${name}`);
3885

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

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

115163
// 2. If module not found and auto-install conditions are met, try auto-installation
116164
if (isModuleNotFound && shouldTryAutoInstall(e, pkg)) {
117-
logger.info(
118-
`Plugin ${pkg} not found in node_modules. Attempting to auto-install...`
119-
);
120-
try {
121-
installPackages([pkg], { dev: true });
122-
logger.info(`Successfully installed ${pkg}, loading plugin...`);
123-
124-
// Retry the import after installation
125-
const retryMod = await import(pkg);
126-
const plugin = await loadFromModule(retryMod);
127-
128-
logger.info(
129-
`Successfully loaded plugin after auto-installation: ${name}`
130-
);
131-
cache.set(name, plugin);
132-
return plugin;
133-
} catch (installError) {
134-
logger.warn(`Auto-installation failed for ${pkg}: ${installError}`);
135-
// Continue to fallback paths for local development
165+
// Prompt user for confirmation (unless skipped via option)
166+
const shouldInstall = opts?.skipPrompt
167+
? true
168+
: await promptForInstall(pkg);
169+
170+
if (shouldInstall) {
171+
const pm = detectPackageManager();
172+
logger.info(`Installing ${pkg} using ${pm}...`);
173+
try {
174+
installPackages([pkg], { dev: true });
175+
logger.info(`Successfully installed ${pkg}`);
176+
177+
// Retry the import after installation
178+
const retryMod = await import(pkg);
179+
const plugin = await loadFromModule(retryMod);
180+
181+
logger.info(`Successfully loaded plugin: ${name}`);
182+
cache.set(name, plugin);
183+
return plugin;
184+
} catch (installError) {
185+
// Show clean message to user, full details in debug
186+
logger.debug(`Full installation error: ${installError}`);
187+
logger.warn(
188+
`Auto-installation failed for ${pkg}: ${getCleanErrorMessage(installError)}`
189+
);
190+
// Continue to fallback paths for local development
191+
}
192+
} else {
193+
logger.info(`Skipping installation of ${pkg}`);
136194
}
137195
}
138196

@@ -179,15 +237,15 @@ export async function loadPlugin(
179237

180238
// 4. If all attempts failed, throw appropriate error
181239
if (isModuleNotFound) {
182-
logger.error(
183-
`Plugin not found: ${name}. Searched paths: ${JSON.stringify(
184-
searchPaths
185-
)}`
186-
);
240+
// User-friendly message without technical details
241+
logger.error(`Plugin not found: ${name}`);
242+
logger.debug(`Searched paths: ${JSON.stringify(searchPaths)}`);
187243
throw new PluginNotFoundError(name, searchPaths);
188244
}
189245

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

0 commit comments

Comments
 (0)