diff --git a/e2e/nx/src/misc.test.ts b/e2e/nx/src/misc.test.ts index c7421184b8..6f1dbdce53 100644 --- a/e2e/nx/src/misc.test.ts +++ b/e2e/nx/src/misc.test.ts @@ -543,6 +543,48 @@ describe('Nx Commands', () => { ); } }, 120000); + + it('should list plugins as JSON with --json flag', () => { + const jsonOutput = runCLI('list --json'); + const parsed = JSON.parse(jsonOutput); + + expect(parsed.installedPlugins).toBeDefined(); + expect(Array.isArray(parsed.installedPlugins)).toBe(true); + expect(parsed.localWorkspacePlugins).toBeDefined(); + expect(Array.isArray(parsed.localWorkspacePlugins)).toBe(true); + + const workspacePlugin = parsed.installedPlugins.find( + (p) => p.name === '@nx/workspace' + ); + expect(workspacePlugin).toBeDefined(); + expect(workspacePlugin.path).toBeDefined(); + expect(workspacePlugin.capabilities).toBeDefined(); + expect(Array.isArray(workspacePlugin.capabilities)).toBe(true); + }, 120000); + + it('should list plugin capabilities as JSON with --json flag', () => { + const jsonOutput = runCLI('list @nx/js --json'); + const parsed = JSON.parse(jsonOutput); + + expect(parsed.name).toBe('@nx/js'); + expect(parsed.path).toContain('node_modules/@nx/js'); + + // check generator values + const libGen = parsed.generators['library']; + expect(libGen).toBeDefined(); + expect(libGen.description).toEqual(expect.any(String)); + expect(libGen.path).toContain('node_modules/@nx/js'); + expect(libGen.schema).toContain('node_modules/@nx/js'); + expect(libGen.schema).toContain('schema.json'); + + // check executor values + const tscExec = parsed.executors['tsc']; + expect(tscExec).toBeDefined(); + expect(tscExec.description).toEqual(expect.any(String)); + expect(tscExec.path).toContain('node_modules/@nx/js'); + expect(tscExec.schema).toContain('node_modules/@nx/js'); + expect(tscExec.schema).toContain('schema.json'); + }, 120000); }); describe('format', () => { diff --git a/packages/nx/src/command-line/list/command-object.ts b/packages/nx/src/command-line/list/command-object.ts index 1205e13495..fd2ae88ede 100644 --- a/packages/nx/src/command-line/list/command-object.ts +++ b/packages/nx/src/command-line/list/command-object.ts @@ -5,10 +5,15 @@ export const yargsListCommand: CommandModule = { describe: 'Lists installed plugins, capabilities of installed plugins and other available plugins.', builder: (yargs) => - yargs.positional('plugin', { - type: 'string', - description: 'The name of an installed plugin to query.', - }), + yargs + .positional('plugin', { + type: 'string', + description: 'The name of an installed plugin to query.', + }) + .option('json', { + type: 'boolean', + description: 'Output JSON.', + }), handler: async (args: any) => { await (await import('./list')).listHandler(args); process.exit(0); diff --git a/packages/nx/src/command-line/list/list.ts b/packages/nx/src/command-line/list/list.ts index 038bbd9f4e..aae914ead0 100644 --- a/packages/nx/src/command-line/list/list.ts +++ b/packages/nx/src/command-line/list/list.ts @@ -12,11 +12,16 @@ import { listPlugins, } from '../../utils/plugins'; import { workspaceRoot } from '../../utils/workspace-root'; -import { listPowerpackPlugins } from '../../utils/plugins/output'; +import { + formatPluginsAsJson, + listPowerpackPlugins, +} from '../../utils/plugins/output'; export interface ListArgs { /** The name of an installed plugin to query */ plugin?: string | undefined; + /** Output as JSON */ + json?: boolean; } /** @@ -32,7 +37,7 @@ export async function listHandler(args: ListArgs): Promise { const projects = readProjectsConfigurationFromProjectGraph(projectGraph); if (args.plugin) { - await listPluginCapabilities(args.plugin, projects.projects); + await listPluginCapabilities(args.plugin, projects.projects, args.json); } else { const nxJson = readNxJson(); @@ -42,6 +47,17 @@ export async function listHandler(args: ListArgs): Promise { projects.projects ); + if (args.json) { + console.log( + JSON.stringify( + formatPluginsAsJson(localPlugins, installedPlugins), + null, + 2 + ) + ); + return; + } + if (localPlugins.size) { listPlugins(localPlugins, 'Local workspace plugins:'); } diff --git a/packages/nx/src/utils/plugins/output.spec.ts b/packages/nx/src/utils/plugins/output.spec.ts new file mode 100644 index 0000000000..5fea9439af --- /dev/null +++ b/packages/nx/src/utils/plugins/output.spec.ts @@ -0,0 +1,390 @@ +import { PluginCapabilities } from './plugin-capabilities'; +import { + formatPluginCapabilitiesAsJson, + formatPluginsAsJson, + listPluginCapabilities, +} from './output'; + +jest.mock('../workspace-root', () => ({ + workspaceRoot: '/workspace', +})); + +jest.mock('../output', () => ({ + output: { + log: jest.fn(), + warn: jest.fn(), + note: jest.fn(), + }, +})); + +jest.mock('../package-manager', () => ({ + getPackageManagerCommand: jest.fn().mockReturnValue({ + addDev: 'npm install -D', + exec: 'npx', + }), +})); + +const mockGetPluginCapabilities = jest.fn(); +jest.mock('./plugin-capabilities', () => ({ + getPluginCapabilities: (...args: unknown[]) => + mockGetPluginCapabilities(...args), +})); + +const { output } = require('../output'); + +describe('formatPluginCapabilitiesAsJson', () => { + it('should format a plugin with generators and executors', () => { + const plugin: PluginCapabilities = { + name: '@nx/workspace-plugin', + path: 'tools/workspace-plugin', + generators: { + 'my-generator': { + schema: './src/generators/my-generator/schema.json', + factory: './src/generators/my-generator/generator', + description: 'My generator description', + }, + }, + executors: { + 'my-executor': { + schema: './src/executors/my-executor/schema.json', + implementation: './src/executors/my-executor/executor', + description: 'My executor description', + }, + }, + projectGraphExtension: true, + projectInference: false, + }; + + const result = formatPluginCapabilitiesAsJson(plugin); + + expect(result).toEqual({ + name: '@nx/workspace-plugin', + path: 'tools/workspace-plugin', + generators: { + 'my-generator': { + description: 'My generator description', + path: 'tools/workspace-plugin/src/generators/my-generator/generator', + schema: + 'tools/workspace-plugin/src/generators/my-generator/schema.json', + }, + }, + executors: { + 'my-executor': { + description: 'My executor description', + path: 'tools/workspace-plugin/src/executors/my-executor/executor', + schema: + 'tools/workspace-plugin/src/executors/my-executor/schema.json', + }, + }, + projectGraphExtension: true, + projectInference: false, + }); + }); + + it('should handle generators with implementation instead of factory', () => { + const plugin: PluginCapabilities = { + name: '@nx/test', + path: 'node_modules/@nx/test', + generators: { + init: { + schema: './src/generators/init/schema.json', + implementation: './src/generators/init/init', + description: 'Initialize the plugin', + }, + }, + executors: {}, + }; + + const result = formatPluginCapabilitiesAsJson(plugin); + + expect(result.generators['init'].path).toBe( + 'node_modules/@nx/test/src/generators/init/init' + ); + }); + + it('should handle string-type executor entries', () => { + const plugin: PluginCapabilities = { + name: '@nx/test', + path: 'node_modules/@nx/test', + generators: {}, + executors: { + 'string-executor': './src/executors/string-executor', + }, + }; + + const result = formatPluginCapabilitiesAsJson(plugin); + + expect(result.executors['string-executor']).toEqual({ + description: '', + path: null, + schema: null, + }); + }); + + it('should handle empty generators and executors', () => { + const plugin: PluginCapabilities = { + name: '@nx/empty', + path: 'node_modules/@nx/empty', + generators: {}, + executors: {}, + projectGraphExtension: false, + projectInference: false, + }; + + const result = formatPluginCapabilitiesAsJson(plugin); + + expect(result).toEqual({ + name: '@nx/empty', + path: 'node_modules/@nx/empty', + generators: {}, + executors: {}, + projectGraphExtension: false, + projectInference: false, + }); + }); + + it('should handle undefined generators and executors', () => { + const plugin: PluginCapabilities = { + name: '@nx/minimal', + path: 'node_modules/@nx/minimal', + }; + + const result = formatPluginCapabilitiesAsJson(plugin); + + expect(result.generators).toEqual({}); + expect(result.executors).toEqual({}); + expect(result.path).toBe('node_modules/@nx/minimal'); + }); + + it('should handle undefined path', () => { + const plugin: PluginCapabilities = { + name: '@nx/no-path', + generators: { + gen: { + schema: './schema.json', + factory: './generator', + description: 'test', + }, + }, + }; + + const result = formatPluginCapabilitiesAsJson(plugin); + + expect(result.path).toBeNull(); + expect(result.generators['gen'].path).toBeNull(); + expect(result.generators['gen'].schema).toBeNull(); + }); +}); + +describe('listPluginCapabilities', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should output JSON when json flag is true', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + mockGetPluginCapabilities.mockResolvedValue({ + name: '@nx/test', + path: 'node_modules/@nx/test', + generators: { + init: { + schema: './src/generators/init/schema.json', + factory: './src/generators/init/init', + description: 'Initialize', + }, + }, + executors: {}, + projectGraphExtension: false, + projectInference: false, + }); + + await listPluginCapabilities('@nx/test', {}, true); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(consoleSpy.mock.calls[0][0]); + expect(parsed.name).toBe('@nx/test'); + expect(parsed.path).toBe('node_modules/@nx/test'); + expect(parsed.generators['init']).toBeDefined(); + expect(parsed.generators['init'].path).toBe( + 'node_modules/@nx/test/src/generators/init/init' + ); + + consoleSpy.mockRestore(); + }); + + it('should output JSON error when plugin is not installed and json flag is true', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + mockGetPluginCapabilities.mockResolvedValue(null); + + await listPluginCapabilities('@nx/missing', {}, true); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(consoleSpy.mock.calls[0][0]); + expect(parsed.error).toContain('not installed'); + + consoleSpy.mockRestore(); + }); + + it('should output JSON for plugin with no capabilities when json flag is true', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + mockGetPluginCapabilities.mockResolvedValue({ + name: '@nx/empty', + path: 'node_modules/@nx/empty', + generators: {}, + executors: {}, + projectGraphExtension: false, + projectInference: false, + }); + + await listPluginCapabilities('@nx/empty', {}, true); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(consoleSpy.mock.calls[0][0]); + expect(parsed.name).toBe('@nx/empty'); + expect(parsed.generators).toEqual({}); + expect(parsed.executors).toEqual({}); + + consoleSpy.mockRestore(); + }); + + it('should show plugin path in text output', async () => { + mockGetPluginCapabilities.mockResolvedValue({ + name: '@nx/test', + path: 'node_modules/@nx/test', + generators: { + init: { + schema: './schema.json', + factory: './init', + description: 'Initialize', + }, + }, + executors: {}, + }); + + await listPluginCapabilities('@nx/test', {}); + + expect(output.log).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Capabilities in @nx/test:', + bodyLines: expect.arrayContaining([ + expect.stringContaining('node_modules/@nx/test'), + ]), + }) + ); + }); + + it('should show not installed message in text mode', async () => { + mockGetPluginCapabilities.mockResolvedValue(null); + + await listPluginCapabilities('@nx/missing', {}); + + expect(output.note).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.stringContaining('not currently installed'), + }) + ); + }); + + it('should show warning for no capabilities in text mode', async () => { + mockGetPluginCapabilities.mockResolvedValue({ + name: '@nx/empty', + path: 'node_modules/@nx/empty', + generators: {}, + executors: {}, + projectGraphExtension: false, + projectInference: false, + }); + + await listPluginCapabilities('@nx/empty', {}); + + expect(output.warn).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.stringContaining('No capabilities found'), + }) + ); + }); +}); + +describe('formatPluginsAsJson', () => { + it('should format local and installed plugins', () => { + const localPlugins = new Map([ + [ + '@my/workspace-plugin', + { + name: '@my/workspace-plugin', + path: 'tools/workspace-plugin', + generators: { + 'my-gen': { + schema: './schema.json', + factory: './generator', + description: 'My generator', + }, + }, + executors: {}, + projectGraphExtension: false, + projectInference: true, + }, + ], + ]); + + const installedPlugins = new Map([ + [ + '@nx/js', + { + name: '@nx/js', + path: 'node_modules/@nx/js', + generators: { + init: { + schema: './schema.json', + factory: './init', + description: 'Init', + }, + }, + executors: { + build: { + schema: './schema.json', + implementation: './build', + description: 'Build', + }, + }, + projectGraphExtension: true, + projectInference: false, + }, + ], + ]); + + const result = formatPluginsAsJson(localPlugins, installedPlugins); + + expect(result).toEqual({ + localWorkspacePlugins: [ + { + name: '@my/workspace-plugin', + path: 'tools/workspace-plugin', + capabilities: ['generators', 'project-inference'], + }, + ], + installedPlugins: [ + { + name: '@nx/js', + path: 'node_modules/@nx/js', + capabilities: ['executors', 'generators', 'graph-extension'], + }, + ], + }); + }); + + it('should handle empty plugin maps', () => { + const result = formatPluginsAsJson( + new Map(), + new Map() + ); + + expect(result).toEqual({ + localWorkspacePlugins: [], + installedPlugins: [], + }); + }); +}); diff --git a/packages/nx/src/utils/plugins/output.ts b/packages/nx/src/utils/plugins/output.ts index a7850073a4..844e5e5829 100644 --- a/packages/nx/src/utils/plugins/output.ts +++ b/packages/nx/src/utils/plugins/output.ts @@ -1,4 +1,9 @@ +import { join } from 'path'; import * as pc from 'picocolors'; +import { + ExecutorsJsonEntry, + GeneratorsJsonEntry, +} from '../../config/misc-interfaces'; import { ProjectConfiguration } from '../../config/workspace-json-project-json'; import { output } from '../output'; import { getPackageManagerCommand } from '../package-manager'; @@ -70,7 +75,8 @@ export function listPowerpackPlugins(): void { export async function listPluginCapabilities( pluginName: string, - projects: Record + projects: Record, + json = false ) { const plugin = await getPluginCapabilities( workspaceRoot, @@ -79,6 +85,10 @@ export async function listPluginCapabilities( ); if (!plugin) { + if (json) { + console.log(JSON.stringify({ error: `${pluginName} is not installed` })); + return; + } const pmc = getPackageManagerCommand(); output.note({ title: `${pluginName} is not currently installed`, @@ -102,12 +112,37 @@ export async function listPluginCapabilities( !hasProjectGraphExtension && !hasProjectInference ) { + if (json) { + console.log( + JSON.stringify({ + name: plugin.name, + path: plugin.path, + generators: {}, + executors: {}, + projectGraphExtension: false, + projectInference: false, + }) + ); + return; + } output.warn({ title: `No capabilities found in ${pluginName}` }); return; } + if (json) { + console.log( + JSON.stringify(formatPluginCapabilitiesAsJson(plugin), null, 2) + ); + return; + } + const bodyLines = []; + if (plugin.path) { + bodyLines.push(`${pc.bold('Path:')} ${plugin.path}`); + bodyLines.push(''); + } + if (hasGenerators) { bodyLines.push(pc.bold(pc.green('GENERATORS'))); bodyLines.push(''); @@ -148,6 +183,93 @@ export async function listPluginCapabilities( }); } +export function formatPluginCapabilitiesAsJson(plugin: PluginCapabilities) { + const generators: Record< + string, + { description: string; path: string | null; schema: string | null } + > = {}; + for (const [name, entry] of Object.entries(plugin.generators ?? {})) { + generators[name] = { + description: entry.description ?? '', + path: resolveCapabilityPath( + plugin.path, + entry.factory ?? entry.implementation + ), + schema: resolveCapabilityPath(plugin.path, entry.schema), + }; + } + + const executors: Record< + string, + { description: string; path: string | null; schema: string | null } + > = {}; + for (const [name, entry] of Object.entries(plugin.executors ?? {})) { + if (typeof entry === 'string') { + executors[name] = { description: '', path: null, schema: null }; + } else { + executors[name] = { + description: entry.description ?? '', + path: resolveCapabilityPath(plugin.path, entry.implementation), + schema: resolveCapabilityPath(plugin.path, entry.schema), + }; + } + } + + return { + name: plugin.name, + path: plugin.path ?? null, + generators, + executors, + projectGraphExtension: !!plugin.projectGraphExtension, + projectInference: !!plugin.projectInference, + }; +} + +export function formatPluginsAsJson( + localPlugins: Map, + installedPlugins: Map +) { + function formatPluginSummary(plugin: PluginCapabilities) { + const capabilities: string[] = []; + if (hasElements(plugin.executors)) { + capabilities.push('executors'); + } + if (hasElements(plugin.generators)) { + capabilities.push('generators'); + } + if (plugin.projectGraphExtension) { + capabilities.push('graph-extension'); + } + if (plugin.projectInference) { + capabilities.push('project-inference'); + } + return { + name: plugin.name, + path: plugin.path ?? null, + capabilities, + }; + } + + return { + localWorkspacePlugins: Array.from(localPlugins.values()).map( + formatPluginSummary + ), + installedPlugins: Array.from(installedPlugins.values()).map( + formatPluginSummary + ), + }; +} + +function resolveCapabilityPath( + pluginPath: string | undefined, + relativePath: string | undefined +): string | null { + if (!pluginPath || !relativePath) { + return null; + } + return join(pluginPath, relativePath); +} + function hasElements(obj: any): boolean { return obj && Object.values(obj).length > 0; } diff --git a/packages/nx/src/utils/plugins/plugin-capabilities.ts b/packages/nx/src/utils/plugins/plugin-capabilities.ts index 43e253f084..787a37c38f 100644 --- a/packages/nx/src/utils/plugins/plugin-capabilities.ts +++ b/packages/nx/src/utils/plugins/plugin-capabilities.ts @@ -1,4 +1,4 @@ -import { dirname, join } from 'path'; +import { dirname, join, relative } from 'path'; import { ExecutorsJsonEntry, GeneratorsJsonEntry, @@ -13,6 +13,7 @@ import type { LoadedNxPlugin } from '../../project-graph/plugins/loaded-nx-plugi export interface PluginCapabilities { name: string; + path?: string; executors?: { [name: string]: ExecutorsJsonEntry }; generators?: { [name: string]: GeneratorsJsonEntry }; projectInference?: boolean; @@ -51,8 +52,10 @@ export async function getPluginCapabilities( const pluginModule = includeRuntimeCapabilities ? await tryGetModule(packageJson, workspaceRoot) : ({} as Record); + const pluginPath = relative(workspaceRoot, dirname(packageJsonPath)) || '.'; return { name: pluginName, + path: pluginPath, generators: { ...tryGetCollection( packageJsonPath,