diff --git a/e2e/nx-plugin/src/nx-plugin.test.ts b/e2e/nx-plugin/src/nx-plugin.test.ts index b7a1ad92565ee9..e85eb951d6a782 100644 --- a/e2e/nx-plugin/src/nx-plugin.test.ts +++ b/e2e/nx-plugin/src/nx-plugin.test.ts @@ -9,6 +9,8 @@ import { runCLI, runCLIAsync, uniq, + updateFile, + createFile, } from '@nrwl/e2e/utils'; describe('Nx Plugin', () => { @@ -183,11 +185,43 @@ describe('Nx Plugin', () => { `generate @nrwl/nx-plugin:generator ${generator} --project=${plugin}` ); + updateFile( + `libs/${plugin}/src/index.ts`, + ` +export function registerProjectTargets(f) { + if (f === 'my-project-file') { + return { + build: { + executor: "@nrwl/workspace:run-commands", + options: { + command: "echo 'custom registered target'" + } + } + } + } +} + +export const projectFilePatterns = ['my-project-file']; +` + ); + + updateFile(`nx.json`, (nxJson) => { + const nx = JSON.parse(nxJson); + nx.plugins = [`@${npmScope}/${plugin}`]; + return JSON.stringify(nx, null, 2); + }); + + const inferredProject = uniq('inferred'); + createFile(`libs/${inferredProject}/my-project-file`); + runCLI( `generate @${npmScope}/${plugin}:${generator} --name ${generatedProject}` ); expect(() => checkFilesExist(`libs/${generatedProject}`)).not.toThrow(); expect(() => runCLI(`build ${generatedProject}`)).not.toThrow(); + expect(runCLI(`build ${inferredProject}`)).toContain( + 'custom registered target' + ); }, 90000); describe('--directory', () => { diff --git a/packages/tao/src/shared/nx-plugin.ts b/packages/tao/src/shared/nx-plugin.ts index ea47a8e42b2a81..558d40a4ac16d3 100644 --- a/packages/tao/src/shared/nx-plugin.ts +++ b/packages/tao/src/shared/nx-plugin.ts @@ -1,10 +1,18 @@ +import { execSync } from 'child_process'; import { sync } from 'fast-glob'; import { existsSync } from 'fs'; -import { dirname, join } from 'path'; +import * as path from 'path'; + import { appRootPath } from '../utils/app-root'; import { readJsonFile } from '../utils/fileutils'; +import { PackageJson } from './package-json'; +import { getPackageManagerCommand } from './package-manager'; import { ProjectGraphProcessor } from './project-graph'; -import { TargetConfiguration } from './workspace'; +import { Workspaces } from './workspace'; +import { + TargetConfiguration, + WorkspaceJsonConfiguration, +} from './workspace.model'; export type ProjectTargetConfigurator = ( file: string @@ -25,30 +33,36 @@ export interface NxPlugin { projectFilePatterns?: string[]; } -function findPluginPackageJson(path: string, plugin: string) { - while (true) { - if (!path.startsWith(appRootPath)) { - throw new Error("Couldn't find a package.json for Nx plugin:" + plugin); - } - if (existsSync(join(path, 'package.json'))) { - return join(path, 'package.json'); - } - path = dirname(path); - } -} - +// Short lived cache (cleared between cmd runs) +// holding resolved nx plugin objects. +// Allows loadNxPlugins to be called multiple times w/o +// executing resolution mulitple times. let nxPluginCache: NxPlugin[] = null; -export function loadNxPlugins(plugins?: string[]): NxPlugin[] { +export function loadNxPlugins( + plugins?: string[], + paths = [appRootPath] +): NxPlugin[] { return plugins?.length ? nxPluginCache || - (nxPluginCache = plugins.map((path) => { - const pluginPath = require.resolve(path, { - paths: [appRootPath], - }); - - const { name } = readJsonFile( - findPluginPackageJson(pluginPath, path) - ); + (nxPluginCache = plugins.map((moduleName) => { + let pluginPath: string; + try { + pluginPath = require.resolve(moduleName, { + paths, + }); + } catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + pluginPath = resolveLocalNxPlugin(moduleName); + } else { + throw e; + } + } + const packageJsonPath = path.join(pluginPath, 'package.json'); + const { name } = + !['.ts', '.js'].some((x) => x === path.extname(pluginPath)) && // Not trying to point to a ts or js file + existsSync(packageJsonPath) // plugin has a package.json + ? readJsonFile(packageJsonPath) // read name from package.json + : { name: path.basename(pluginPath) }; // use the name of the file we point to const plugin = require(pluginPath) as NxPlugin; plugin.name = name; @@ -69,14 +83,124 @@ export function mergePluginTargetsWithNxTargets( } const projectFiles = sync(`+(${plugin.projectFilePatterns.join('|')})`, { - cwd: join(appRootPath, projectRoot), + cwd: path.join(appRootPath, projectRoot), }); for (const projectFile of projectFiles) { newTargets = { ...newTargets, - ...plugin.registerProjectTargets(join(projectRoot, projectFile)), + ...plugin.registerProjectTargets(path.join(projectRoot, projectFile)), }; } } return { ...newTargets, ...targets }; } + +export function readPluginPackageJson( + pluginName: string, + paths = [appRootPath] +): { + path: string; + json: PackageJson; +} { + let packageJsonPath: string; + try { + packageJsonPath = require.resolve(`${pluginName}/package.json`, { + paths, + }); + } catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + const localPluginPath = resolveLocalNxPlugin(pluginName); + if (localPluginPath) { + const localPluginPackageJson = path.join( + localPluginPath, + 'package.json' + ); + return { + path: localPluginPackageJson, + json: readJsonFile(localPluginPackageJson), + }; + } + } + throw e; + } + return { json: readJsonFile(packageJsonPath), path: packageJsonPath }; +} + +/** + * Builds a plugin package and returns the path to output + * @param importPath What is the import path that refers to a potential plugin? + * @returns The path to the built plugin, or null if it doesn't exist + */ +const localPluginCache = {}; +export function resolveLocalNxPlugin( + importPath: string, + root = appRootPath +): string | null { + localPluginCache[importPath] ??= buildLocalPlugin(importPath, root); + return localPluginCache[importPath]; +} + +function buildLocalPlugin(importPath: string, root = appRootPath): string { + const workspace = new Workspaces(root).readWorkspaceConfiguration(); + const plugin = findNxProjectForImportPath(importPath, workspace, root); + if (!plugin) { + return null; + } + + const projectConfig = workspace.projects[plugin]; + + /** + * todo(v14-v15) make this stuff async + use task scheduler + * + * Ideally, we wouldn't invoke nx via execSync here. We should be + * able to run an executor given a project and target programmatically. + * Currently, runExecutor is async and doesn't hit the task orchestrator. + * So to use it, we would have to make a bunch of this stuff async (a breaking change), + * and we would also not benefit from remote or local caches which would be much slower. + * Therefore, currently we use execSync here. We should work towards simplifying + * the task orchestrator API, while consolidating @nrwl/workspace and @nrwl/tao + * to make this something like `await TaskScheduler.runTaskNow({project, target: 'build'})`, + * but that API doesn't exist. + */ + execSync(`${getPackageManagerCommand().exec} nx build ${plugin}`, { + cwd: root, + }); + return path.join(root, projectConfig.targets?.build?.options?.outputPath); +} + +function findNxProjectForImportPath( + importPath: string, + workspace: WorkspaceJsonConfiguration, + root = appRootPath +): string | null { + const tsConfigPaths: Record = readJsonFile( + path.join(root, 'tsconfig.base.json') + )?.compilerOptions?.paths; + const possiblePaths = tsConfigPaths[importPath]?.map((p) => + path.resolve(root, p) + ); + if (tsConfigPaths[importPath]) { + const projectRootMappings = Object.entries(workspace.projects).reduce( + (m, [project, config]) => { + m[path.resolve(root, config.root)] = project; + return m; + }, + {} + ); + for (const root of Object.keys(projectRootMappings)) { + if (possiblePaths.some((p) => p.startsWith(root))) { + return projectRootMappings[root]; + } + } + if (process.env.NX_VERBOSE_LOGGING) { + console.log( + 'Unable to find local plugin', + possiblePaths, + projectRootMappings + ); + } + throw new Error( + 'Unable to resolve local plugin with import path ' + importPath + ); + } +} diff --git a/packages/tao/src/shared/workspace.model.ts b/packages/tao/src/shared/workspace.model.ts new file mode 100644 index 00000000000000..f39f828bef1d61 --- /dev/null +++ b/packages/tao/src/shared/workspace.model.ts @@ -0,0 +1,243 @@ +import { NxJsonConfiguration } from './nx'; +import { TaskGraph } from './tasks'; + +export interface Workspace + extends WorkspaceJsonConfiguration, + NxJsonConfiguration { + projects: Record; +} + +/** + * Workspace configuration + */ +export interface WorkspaceJsonConfiguration { + /** + * Version of the configuration format + */ + version: number; + /** + * Projects' projects + */ + projects: { + [projectName: string]: ProjectConfiguration; + }; +} + +export interface RawWorkspaceJsonConfiguration + extends Omit { + projects: { [projectName: string]: ProjectConfiguration | string }; +} + +/** + * Type of project supported + */ +export type ProjectType = 'library' | 'application'; + +/** + * Project configuration + */ +export interface ProjectConfiguration { + /** + * Project's name. Optional if specified in workspace.json + */ + name?: string; + + /** + * Project's targets + */ + targets?: { [targetName: string]: TargetConfiguration }; + + /** + * Project's location relative to the root of the workspace + */ + root: string; + + /** + * The location of project's sources relative to the root of the workspace + */ + sourceRoot?: string; + + /** + * Project type + */ + projectType?: ProjectType; + + /** + * List of default values used by generators. + * + * These defaults are project specific. + * + * Example: + * + * ``` + * { + * "@nrwl/react": { + * "library": { + * "style": "scss" + * } + * } + * } + * ``` + */ + generators?: { [collectionName: string]: { [generatorName: string]: any } }; + + /** + * List of projects which are added as a dependency + */ + implicitDependencies?: string[]; + + /** + * List of tags used by nx-enforce-module-boundaries / project graph + */ + tags?: string[]; +} + +export interface TargetDependencyConfig { + /** + * This the projects that the targets belong to + * + * 'self': This target depends on another target of the same project + * 'deps': This target depends on targets of the projects of it's deps. + */ + projects: 'self' | 'dependencies'; + + /** + * The name of the target + */ + target: string; +} + +/** + * Target's configuration + */ +export interface TargetConfiguration { + /** + * The executor/builder used to implement the target. + * + * Example: '@nrwl/web:rollup' + */ + executor: string; + + /** + * List of the target's outputs. The outputs will be cached by the Nx computation + * caching engine. + */ + outputs?: string[]; + + /** + * This describes other targets that a target depends on. + */ + dependsOn?: TargetDependencyConfig[]; + + /** + * Target's options. They are passed in to the executor. + */ + options?: any; + + /** + * Sets of options + */ + configurations?: { [config: string]: any }; + + /** + * A default named configuration to use when a target configuration is not provided. + */ + defaultConfiguration?: string; +} + +/** + * A callback function that is executed after changes are made to the file system + */ +export type GeneratorCallback = () => void | Promise; + +/** + * A function that schedules updates to the filesystem to be done atomically + */ +export type Generator = ( + tree, + schema: T +) => void | GeneratorCallback | Promise; + +export interface ExecutorConfig { + schema: any; + hasherFactory?: () => any; + implementationFactory: () => Executor; + batchImplementationFactory?: () => TaskGraphExecutor; +} + +/** + * Implementation of a target of a project + */ +export type Executor = ( + /** + * Options that users configure or pass via the command line + */ + options: T, + context: ExecutorContext +) => + | Promise<{ success: boolean }> + | AsyncIterableIterator<{ success: boolean }>; + +/** + * Implementation of a target of a project that handles multiple projects to be batched + */ +export type TaskGraphExecutor = ( + /** + * Graph of Tasks to be executed + */ + taskGraph: TaskGraph, + /** + * Map of Task IDs to options for the task + */ + options: Record, + /** + * Set of overrides for the overall execution + */ + overrides: T, + context: ExecutorContext +) => Promise>; + +/** + * Context that is passed into an executor + */ +export interface ExecutorContext { + /** + * The root of the workspace + */ + root: string; + + /** + * The name of the project being executed on + */ + projectName?: string; + + /** + * The name of the target being executed + */ + targetName?: string; + + /** + * The name of the configuration being executed + */ + configurationName?: string; + + /** + * The configuration of the target being executed + */ + target?: TargetConfiguration; + + /** + * The full workspace configuration + */ + workspace: WorkspaceJsonConfiguration & NxJsonConfiguration; + + /** + * The current working directory + */ + cwd: string; + + /** + * Enable verbose logging + */ + isVerbose: boolean; +} diff --git a/packages/tao/src/shared/workspace.ts b/packages/tao/src/shared/workspace.ts index 5ae6d60212bb86..6b9728a08b2e78 100644 --- a/packages/tao/src/shared/workspace.ts +++ b/packages/tao/src/shared/workspace.ts @@ -1,162 +1,25 @@ +import { sync as globSync } from 'fast-glob'; import { existsSync, readFileSync } from 'fs'; +import ignore, { Ignore } from 'ignore'; import * as path from 'path'; +import { performance } from 'perf_hooks'; + import { appRootPath } from '../utils/app-root'; import { readJsonFile } from '../utils/fileutils'; -import type { NxJsonConfiguration } from './nx'; -import { TaskGraph } from './tasks'; import { logger } from './logger'; -import { sync as globSync } from 'fast-glob'; -import ignore, { Ignore } from 'ignore'; -import { basename, dirname, join } from 'path'; -import { performance } from 'perf_hooks'; -import { loadNxPlugins } from './nx-plugin'; -import { PackageJson } from './package-json'; -import { execSync } from 'child_process'; -import { getPackageManagerCommand } from './package-manager'; - -export interface Workspace - extends WorkspaceJsonConfiguration, - NxJsonConfiguration { - projects: Record; -} - -/** - * Workspace configuration - */ -export interface WorkspaceJsonConfiguration { - /** - * Version of the configuration format - */ - version: number; - /** - * Projects' projects - */ - projects: { - [projectName: string]: ProjectConfiguration; - }; -} - -export interface RawWorkspaceJsonConfiguration - extends Omit { - projects: { [projectName: string]: ProjectConfiguration | string }; -} - -/** - * Type of project supported - */ -export type ProjectType = 'library' | 'application'; - -/** - * Project configuration - */ -export interface ProjectConfiguration { - /** - * Project's name. Optional if specified in workspace.json - */ - name?: string; - - /** - * Project's targets - */ - targets?: { [targetName: string]: TargetConfiguration }; - - /** - * Project's location relative to the root of the workspace - */ - root: string; - - /** - * The location of project's sources relative to the root of the workspace - */ - sourceRoot?: string; - - /** - * Project type - */ - projectType?: ProjectType; - - /** - * List of default values used by generators. - * - * These defaults are project specific. - * - * Example: - * - * ``` - * { - * "@nrwl/react": { - * "library": { - * "style": "scss" - * } - * } - * } - * ``` - */ - generators?: { [collectionName: string]: { [generatorName: string]: any } }; - - /** - * List of projects which are added as a dependency - */ - implicitDependencies?: string[]; - - /** - * List of tags used by nx-enforce-module-boundaries / project graph - */ - tags?: string[]; -} - -export interface TargetDependencyConfig { - /** - * This the projects that the targets belong to - * - * 'self': This target depends on another target of the same project - * 'deps': This target depends on targets of the projects of it's deps. - */ - projects: 'self' | 'dependencies'; - - /** - * The name of the target - */ - target: string; -} - -/** - * Target's configuration - */ -export interface TargetConfiguration { - /** - * The executor/builder used to implement the target. - * - * Example: '@nrwl/web:rollup' - */ - executor: string; - - /** - * List of the target's outputs. The outputs will be cached by the Nx computation - * caching engine. - */ - outputs?: string[]; - - /** - * This describes other targets that a target depends on. - */ - dependsOn?: TargetDependencyConfig[]; - - /** - * Target's options. They are passed in to the executor. - */ - options?: any; - - /** - * Sets of options - */ - configurations?: { [config: string]: any }; +import { loadNxPlugins, readPluginPackageJson } from './nx-plugin'; +import { + Executor, + ExecutorConfig, + Generator, + ProjectConfiguration, + TaskGraphExecutor, + WorkspaceJsonConfiguration, +} from './workspace.model'; - /** - * A default named configuration to use when a target configuration is not provided. - */ - defaultConfiguration?: string; -} +import type { NxJsonConfiguration } from './nx'; +import { basename, dirname, join } from 'path'; +export * from './workspace.model'; export function workspaceConfigName(root: string) { if (existsSync(path.join(root, 'angular.json'))) { @@ -168,103 +31,6 @@ export function workspaceConfigName(root: string) { } } -/** - * A callback function that is executed after changes are made to the file system - */ -export type GeneratorCallback = () => void | Promise; - -/** - * A function that schedules updates to the filesystem to be done atomically - */ -export type Generator = ( - tree, - schema: T -) => void | GeneratorCallback | Promise; - -export interface ExecutorConfig { - schema: any; - hasherFactory?: () => any; - implementationFactory: () => Executor; - batchImplementationFactory?: () => TaskGraphExecutor; -} - -/** - * Implementation of a target of a project - */ -export type Executor = ( - /** - * Options that users configure or pass via the command line - */ - options: T, - context: ExecutorContext -) => - | Promise<{ success: boolean }> - | AsyncIterableIterator<{ success: boolean }>; - -/** - * Implementation of a target of a project that handles multiple projects to be batched - */ -export type TaskGraphExecutor = ( - /** - * Graph of Tasks to be executed - */ - taskGraph: TaskGraph, - /** - * Map of Task IDs to options for the task - */ - options: Record, - /** - * Set of overrides for the overall execution - */ - overrides: T, - context: ExecutorContext -) => Promise>; - -/** - * Context that is passed into an executor - */ -export interface ExecutorContext { - /** - * The root of the workspace - */ - root: string; - - /** - * The name of the project being executed on - */ - projectName?: string; - - /** - * The name of the target being executed - */ - targetName?: string; - - /** - * The name of the configuration being executed - */ - configurationName?: string; - - /** - * The configuration of the target being executed - */ - target?: TargetConfiguration; - - /** - * The full workspace configuration - */ - workspace: WorkspaceJsonConfiguration & NxJsonConfiguration; - - /** - * The current working directory - */ - cwd: string; - - /** - * Enable verbose logging - */ - isVerbose: boolean; -} - export class Workspaces { constructor(private root: string) {} @@ -405,8 +171,10 @@ export class Workspaces { } private readExecutorsJson(nodeModule: string, executor: string) { - const { json: packageJson, path: packageJsonPath } = - this.readPluginPackageJson(nodeModule); + const { json: packageJson, path: packageJsonPath } = readPluginPackageJson( + nodeModule, + this.resolvePaths() + ); const executorsFile = packageJson.executors ?? packageJson.builders; if (!executorsFile) { @@ -442,7 +210,7 @@ export class Workspaces { }); } else { const { json: packageJson, path: packageJsonPath } = - this.readPluginPackageJson(collectionName); + readPluginPackageJson(collectionName, this.resolvePaths()); const generatorsFile = packageJson.generators ?? packageJson.schematics; if (!generatorsFile) { @@ -475,104 +243,6 @@ export class Workspaces { return { generatorsFilePath, generatorsJson, normalizedGeneratorName }; } - private readPluginPackageJson(pluginName: string): { - path: string; - json: PackageJson; - } { - let packageJsonPath: string; - try { - packageJsonPath = require.resolve(`${pluginName}/package.json`, { - paths: this.resolvePaths(), - }); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - const localPluginPackageJson = this.resolveLocalNxPlugin(pluginName); - if (localPluginPackageJson) { - return { - path: localPluginPackageJson, - json: readJsonFile(localPluginPackageJson), - }; - } - } - throw e; - } - return { json: readJsonFile(packageJsonPath), path: packageJsonPath }; - } - - /** - * Builds a plugin package and returns the path to its package.json - * @param importPath What is the import path that refers to a potential plugin? - * @returns The path to the built package.json file - */ - private resolveLocalNxPlugin(importPath: string): string | null { - const workspace = this.readWorkspaceConfiguration(); - const plugin = this.findNxProjectForImportPath(importPath, workspace); - if (!plugin) { - return null; - } - - const projectConfig = workspace.projects[plugin]; - - /** - * todo(v14-v15) make this stuff async + use task scheduler - * - * Ideally, we wouldn't invoke nx via execSync here. We should be - * able to run an executor given a project and target programmatically. - * Currently, runExecutor is async and doesn't hit the task orchestrator. - * So to use it, we would have to make a bunch of this stuff async (a breaking change), - * and we would also not benefit from remote or local caches which would be much slower. - * Therefore, currently we use execSync here. We should work towards simplifying - * the task orchestrator API, while consolidating @nrwl/workspace and @nrwl/tao - * to make this something like `await TaskScheduler.runTaskNow({project, target: 'build'})`, - * but that API doesn't exist. - */ - execSync(`${getPackageManagerCommand().exec} nx build ${plugin}`, { - cwd: this.root, - }); - const packageJsonPath = join( - this.root, - projectConfig.targets?.build?.options?.outputPath, - 'package.json' - ); - return packageJsonPath; - } - - private findNxProjectForImportPath( - importPath: string, - workspace: WorkspaceJsonConfiguration - ): string | null { - const tsConfigPaths: Record = readJsonFile( - join(this.root, 'tsconfig.base.json') - )?.compilerOptions?.paths; - const possiblePaths = tsConfigPaths[importPath]?.map((p) => - path.resolve(this.root, p) - ); - if (tsConfigPaths[importPath]) { - const projectRootMappings = Object.entries(workspace.projects).reduce( - (m, [project, config]) => { - m[path.resolve(this.root, config.root)] = project; - return m; - }, - {} - ); - for (const root of Object.keys(projectRootMappings)) { - if (possiblePaths.some((p) => p.startsWith(root))) { - return projectRootMappings[root]; - } - } - if (process.env.NX_VERBOSE_LOGGING) { - console.log( - 'Unable to find local plugin', - possiblePaths, - projectRootMappings - ); - } - throw new Error( - 'Unable to resolve local plugin with import path ' + importPath - ); - } - } - private resolvePaths() { return this.root ? [this.root, __dirname] : [__dirname]; }