diff --git a/docs/shared/recipes/installation/install-non-javascript.md b/docs/shared/recipes/installation/install-non-javascript.md index 3964fa17457e4e..fd86f1cc83c4b7 100644 --- a/docs/shared/recipes/installation/install-non-javascript.md +++ b/docs/shared/recipes/installation/install-non-javascript.md @@ -43,3 +43,20 @@ When Nx is installed in `.nx`, you can run Nx via a global Nx installation or th Support for `--use-dot-nx-installation` was added in Nx v15.8.7. To ensure that it is available, specify the version of nx when running your command so that `npx` doesn't accept an older version that is in the cache. (e.g. `npx nx@latest init`) {% /callout %} + +## Installing Plugins + +When Nx is managing its own installation, you can install plugins with `nx add {pluginName}`. This will install the plugin in the `.nx` folder and add it to the `nx.json` file. To manually install a plugin, you can add the plugin to `nx.json` as shown below: + +```json {% fileName="nx.json" %} +{ + "plugins": [ + { + "plugin": "{pluginName}", + "version": "1.0.0" + } + ] +} +``` + +The next time you run Nx, the plugin will be installed and available for use. diff --git a/e2e/nx-misc/src/misc.test.ts b/e2e/nx-misc/src/misc.test.ts index b6fd217017bf1f..fed87b9b2e4578 100644 --- a/e2e/nx-misc/src/misc.test.ts +++ b/e2e/nx-misc/src/misc.test.ts @@ -423,6 +423,12 @@ describe('migrate', () => { 'migrate-child-package': '1.0.0', }, }; + j.plugins = [ + { + plugin: 'migrate-parent-package', + version: '1.0.0', + }, + ]; return j; }); runCLI( @@ -454,6 +460,12 @@ describe('migrate', () => { expect(nxJson.installation.plugins['migrate-child-package']).toEqual( '9.0.0' ); + const updatedPlugin = nxJson.plugins.find( + (p) => typeof p === 'object' && p.plugin === 'migrate-parent-package' + ); + expect( + typeof updatedPlugin === 'object' && updatedPlugin.version === '2.0.0' + ).toBeTruthy(); // should keep new line on package const packageContent = readFile('package.json'); expect(packageContent.charCodeAt(packageContent.length - 1)).toEqual(10); diff --git a/packages/nx/src/command-line/add/add.ts b/packages/nx/src/command-line/add/add.ts index 4bb53936015b27..3848e6689e1d7f 100644 --- a/packages/nx/src/command-line/add/add.ts +++ b/packages/nx/src/command-line/add/add.ts @@ -14,6 +14,7 @@ import { getPluginCapabilities } from '../../utils/plugins'; import { nxVersion } from '../../utils/versions'; import { workspaceRoot } from '../../utils/workspace-root'; import type { AddOptions } from './command-object'; +import { readDependenciesFromNxJson } from '../../utils/nx-installation'; export function addHandler(options: AddOptions): Promise { if (options.verbose) { @@ -58,15 +59,22 @@ async function installPackage(pkgName: string, version: string): Promise { ); } else { const nxJson = readNxJson(); - nxJson.installation.plugins ??= {}; - nxJson.installation.plugins[pkgName] = version; + const pluginDefinition = { + plugin: pkgName, + version, + }; + if (readDependenciesFromNxJson(nxJson)[pkgName] === undefined) { + nxJson.plugins ??= []; + nxJson.plugins.push(pluginDefinition); + } writeJsonFile('nx.json', nxJson); try { - await runNxAsync(''); + await runNxAsync('--version'); } catch (e) { // revert adding the plugin to nx.json - nxJson.installation.plugins[pkgName] = undefined; + const pluginIdx = nxJson.plugins.findIndex((p) => p === pluginDefinition); + nxJson.plugins.splice(pluginIdx, 1); writeJsonFile('nx.json', nxJson); spinner.fail(); diff --git a/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.spec.ts b/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.spec.ts index 709771ce7f00c8..0ae266da643f74 100644 --- a/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.spec.ts +++ b/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.spec.ts @@ -21,7 +21,7 @@ const variable = 3;`); it('should remove empty comments', () => { const stripped = sanitizeWrapperScript(`test; //`); - expect(stripped.length).toEqual(5); + expect(stripped).toMatchInlineSnapshot(`"test;"`); }); // This test serves as a final sanity check to ensure that the contents of the @@ -39,7 +39,6 @@ const variable = 3;`); // // You should not edit this file, as future updates to Nx may require changes to it. // See: https://nx.dev/recipes/installation/install-non-javascript for more info. - const fs: typeof import('fs') = require('fs'); const path: typeof import('path') = require('path'); const cp: typeof import('child_process') = require('child_process'); @@ -133,21 +132,29 @@ const variable = 3;`); function getDesiredPluginVersions(nxJson: NxJsonConfiguration) { const packages: Record = {}; + let warned = false; for (const [plugin, version] of Object.entries( nxJson?.installation?.plugins ?? {} )) { packages[plugin] = version; + if (!warned) { + console.warn( + '[Nx]: The "installation.plugins" entry in the "nx.json" file is deprecated. Use "plugins" instead. See https://nx.dev/recipes/installation/install-non-javascript' + ); + warned = true; + } } for (const plugin of nxJson.plugins ?? []) { - if (typeof plugin === 'object' && plugin.version) { - packages[getPackageName(plugin.plugin)] = plugin.version; + if (typeof plugin === 'object') { + if (plugin.version) { + packages[getPackageName(plugin.plugin)] = plugin.version; + } } } return Object.entries(packages); } - function getPackageName(name: string) { if (name.startsWith('@')) { return name.split('/').slice(0, 2).join('/'); @@ -199,7 +206,6 @@ const variable = 3;`); if (!process.env.NX_WRAPPER_SKIP_INSTALL) { ensureUpToDateInstallation(); } - require('./installation/node_modules/nx/bin/nx'); " `); diff --git a/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.ts b/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.ts index db4629870c4c7a..43e90a957318e2 100644 --- a/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.ts +++ b/packages/nx/src/command-line/init/implementation/dot-nx/add-nx-scripts.ts @@ -92,17 +92,17 @@ export function getNxWrapperContents() { // Remove any empty comments or comments that start with `//#: ` or eslint-disable comments. // This removes the sourceMapUrl since it is invalid, as well as any internal comments. export function sanitizeWrapperScript(input: string) { - const linesToRemove = [ + const removals = [ // Comments that start with //# - '\\s*\\/\\/# .*', + '\\s+\\/\\/# .*', // Comments that are empty (often used for newlines between internal comments) - '\\s*\\/\\/\\s*', + '\\s+\\/\\/\\s*$', // Comments that disable an eslint rule. - '\\s*\\/\\/ eslint-disable-next-line.*', + '(^|\\s?)\\/\\/ ?eslint-disable.*$', ]; const replacements = [ - [linesToRemove.map((line) => `^${line}$`).join('|'), ''], + ...removals.map((r) => [r, '']), // Remove the sourceMapUrl comment ['^\\/\\/# sourceMappingURL.*$', ''], // Keep empty comments if ! is present diff --git a/packages/nx/src/command-line/init/implementation/dot-nx/nxw.ts b/packages/nx/src/command-line/init/implementation/dot-nx/nxw.ts index 12021e1962c6a8..462131904b86b8 100644 --- a/packages/nx/src/command-line/init/implementation/dot-nx/nxw.ts +++ b/packages/nx/src/command-line/init/implementation/dot-nx/nxw.ts @@ -102,16 +102,31 @@ function performInstallation( function getDesiredPluginVersions(nxJson: NxJsonConfiguration) { const packages: Record = {}; + let warned = false; + //# adds support for "legacy" installation of plugins for (const [plugin, version] of Object.entries( nxJson?.installation?.plugins ?? {} )) { packages[plugin] = version; + if (!warned) { + console.warn( + '[Nx]: The "installation.plugins" entry in the "nx.json" file is deprecated. Use "plugins" instead. See https://nx.dev/recipes/installation/install-non-javascript' + ); + warned = true; + } } for (const plugin of nxJson.plugins ?? []) { - if (typeof plugin === 'object' && plugin.version) { - packages[getPackageName(plugin.plugin)] = plugin.version; + if (typeof plugin === 'object') { + if (plugin.version) { + packages[getPackageName(plugin.plugin)] = plugin.version; + } + //# } else if (!nxJson.installation?.plugins?.[pluginName]) { + //# // Ideally we'd like to warn here, but we don't know that + //# // the plugin isn't a local plugin at this point. As such, + //# // the warning may not be relevant, and I'd like to avoid that. + //# } } } @@ -123,6 +138,8 @@ function getDesiredPluginVersions(nxJson: NxJsonConfiguration) { //# - `@nx/workspace/plugin` -> `@nx/workspace` //# - `@nx/workspace/other` -> `@nx/workspace` //# - `nx/plugin` -> `nx` +//# This is a copy of a function in 'nx/src/utils/nx-installation'. +//# It's tested there, but can't be imported since Nx isn't installed yet. function getPackageName(name: string) { if (name.startsWith('@')) { return name.split('/').slice(0, 2).join('/'); diff --git a/packages/nx/src/command-line/migrate/migrate.ts b/packages/nx/src/command-line/migrate/migrate.ts index fff7f14eb056fa..7e43901b418764 100644 --- a/packages/nx/src/command-line/migrate/migrate.ts +++ b/packages/nx/src/command-line/migrate/migrate.ts @@ -64,6 +64,10 @@ import { createProjectGraphAsync, readProjectsConfigurationFromProjectGraph, } from '../../project-graph/project-graph'; +import { + readDependenciesFromNxJson, + updateDependenciesInNxJson, +} from '../../utils/nx-installation'; export interface ResolvedMigrationConfiguration extends MigrationsJson { packageGroup?: ArrayPackageGroup; @@ -115,7 +119,7 @@ function normalizeSlashes(packageName: string): string { export interface MigratorOptions { packageJson?: PackageJson; - nxInstallation?: NxJsonConfiguration['installation']; + nxJson?: NxJsonConfiguration; getInstalledPackageVersion: ( pkg: string, overrides?: Record @@ -141,12 +145,12 @@ export class Migrator { private readonly packageUpdates: Record = {}; private readonly collectedVersions: Record = {}; private readonly promptAnswers: Record = {}; - private readonly nxInstallation: NxJsonConfiguration['installation'] | null; + private readonly nxJson: NxJsonConfiguration | null; private minVersionWithSkippedUpdates: string | undefined; constructor(opts: MigratorOptions) { this.packageJson = opts.packageJson; - this.nxInstallation = opts.nxInstallation; + this.nxJson = opts.nxJson; this.getInstalledPackageVersion = opts.getInstalledPackageVersion; this.fetch = opts.fetch; this.installedPkgVersionOverrides = opts.from; @@ -423,10 +427,9 @@ export class Migrator { } const dependencies: Record = { - ...this.packageJson?.dependencies, ...this.packageJson?.devDependencies, - ...this.nxInstallation?.plugins, - ...(this.nxInstallation && { nx: this.nxInstallation.version }), + ...this.packageJson?.dependencies, + ...readDependenciesFromNxJson(this.nxJson), }; const filtered: Record = {}; @@ -1152,25 +1155,11 @@ async function updateInstallationDetails( const parseOptions: JsonReadOptions = {}; const nxJson = readJsonFile(nxJsonPath, parseOptions); - if (!nxJson.installation) { + if (!nxJson?.installation) { return; } - const nxVersion = updatedPackages.nx?.version; - if (nxVersion) { - nxJson.installation.version = nxVersion; - } - - if (nxJson.installation.plugins) { - for (const dep in nxJson.installation.plugins) { - const update = updatedPackages[dep]; - if (update) { - nxJson.installation.plugins[dep] = valid(update.version) - ? update.version - : await resolvePackageVersionUsingRegistry(dep, update.version); - } - } - } + await updateDependenciesInNxJson(nxJson, updatedPackages); writeJsonFile(nxJsonPath, nxJson, { appendNewLine: parseOptions.endsWithNewline, @@ -1237,7 +1226,7 @@ async function generateMigrationsJsonAndUpdatePackageJson( const migrator = new Migrator({ packageJson: originalPackageJson, - nxInstallation: originalNxJson.installation, + nxJson: originalNxJson, getInstalledPackageVersion: createInstalledPackageVersionsResolver(root), fetch: createFetcher(), from: opts.from, diff --git a/packages/nx/src/project-graph/plugins/messaging.ts b/packages/nx/src/project-graph/plugins/messaging.ts index fca8310bf6dcc7..47b3d5653eb704 100644 --- a/packages/nx/src/project-graph/plugins/messaging.ts +++ b/packages/nx/src/project-graph/plugins/messaging.ts @@ -118,8 +118,8 @@ type MaybePromise = T | Promise; // The handler can return a message to be sent back to the process from which the message originated type MessageHandlerReturn = T extends PluginWorkerResult - ? MaybePromise - : MaybePromise; + ? MaybePromise<(PluginWorkerMessage & { callback?: () => void }) | void> + : MaybePromise<(PluginWorkerResult & { callback?: () => void }) | void>; // Takes a message and a map of handlers and calls the appropriate handler // type safe and requires all handlers to be handled @@ -139,7 +139,8 @@ export async function consumeMessage< if (handler) { const response = await handler(message.payload); if (response) { - process.send!(createMessage(response)); + const { callback, ...message } = response; + process.send!(createMessage(message), callback); } } else { throw new Error(`Unhandled message type: ${message.type}`); diff --git a/packages/nx/src/project-graph/plugins/plugin-pool.ts b/packages/nx/src/project-graph/plugins/plugin-pool.ts index 03bc400612d4b8..97b22606674817 100644 --- a/packages/nx/src/project-graph/plugins/plugin-pool.ts +++ b/packages/nx/src/project-graph/plugins/plugin-pool.ts @@ -82,6 +82,7 @@ export async function shutdownPluginWorkers() { for (const p of pool) { p.kill('SIGINT'); + pool.delete(p); } // logger.verbose(`[plugin-pool] all workers killed`); @@ -115,7 +116,7 @@ function createWorkerHandler( pluginName = name; const pending = new Set(); pidMap.set(worker.pid, { name, pending }); - onload({ + const remotePlugin: RemotePlugin = { name, createNodes: createNodesPattern ? [ @@ -161,7 +162,18 @@ function createWorkerHandler( }); } : undefined, - }); + }; + onload(remotePlugin); + if ( + !( + remotePlugin.createDependencies || + remotePlugin.createNodes || + remotePlugin.processProjectGraph + ) + ) { + pidMap.delete(worker.pid); + pool.delete(worker); + } } else if (result.success === false) { onloadError(result.error); } @@ -201,8 +213,11 @@ function createWorkerHandler( } function createWorkerExitHandler(worker: ChildProcess) { - return () => { - if (!pluginWorkersShutdown) { + return (code: number) => { + // If a worker exits with code zero, its expected so we don't need to do anything. + pool.delete(worker); + + if (code !== 0 && !pluginWorkersShutdown) { pidMap.get(worker.pid)?.pending.forEach((tx) => { const { rejecter } = promiseBank.get(tx); rejecter( diff --git a/packages/nx/src/project-graph/plugins/plugin-worker.ts b/packages/nx/src/project-graph/plugins/plugin-worker.ts index 63c37e57ce2829..c1c05687d903db 100644 --- a/packages/nx/src/project-graph/plugins/plugin-worker.ts +++ b/packages/nx/src/project-graph/plugins/plugin-worker.ts @@ -35,6 +35,16 @@ process.on('message', async (message: string) => { hasProcessProjectGraph: 'processProjectGraph' in plugin && !!plugin.processProjectGraph, success: true, + // If the plugin doesn't have any runtime hooks, we can exit the worker + // after loading it to avoid running an empty worker process. + callback: + plugin.createNodes || + plugin.createDependencies || + plugin.processProjectGraph + ? undefined + : () => { + process.exit(0); + }, }, }; } catch (e) { diff --git a/packages/nx/src/utils/nx-installation.spec.ts b/packages/nx/src/utils/nx-installation.spec.ts new file mode 100644 index 00000000000000..dbdc6f74b9ded2 --- /dev/null +++ b/packages/nx/src/utils/nx-installation.spec.ts @@ -0,0 +1,12 @@ +import { getPackageName } from './nx-installation'; + +describe('get package name', () => { + it.each([ + ['plugin', 'plugin'], + ['plugin/other', 'plugin'], + ['@scope/plugin', '@scope/plugin'], + ['@scope/plugin/other', '@scope/plugin'], + ])('should read package name for %s: %s', (input, expected) => { + expect(getPackageName(input)).toEqual(expected); + }); +}); diff --git a/packages/nx/src/utils/nx-installation.ts b/packages/nx/src/utils/nx-installation.ts new file mode 100644 index 00000000000000..ae218595f462c1 --- /dev/null +++ b/packages/nx/src/utils/nx-installation.ts @@ -0,0 +1,74 @@ +import { valid } from 'semver'; +import { NxJsonConfiguration } from '../config/nx-json'; +import { PackageJson } from './package-json'; +import { PackageJsonUpdateForPackage } from '../config/misc-interfaces'; +import { resolvePackageVersionUsingRegistry } from './package-manager'; + +export async function updateDependenciesInNxJson( + nxJson: NxJsonConfiguration, + updates: Record +) { + const nxVersion = updates.nx?.version; + if (nxVersion) { + nxJson.installation.version = nxVersion; + } + + for (const plugin of nxJson.plugins ?? []) { + if (typeof plugin === 'object' && plugin.version) { + const packageName = getPackageName(plugin.plugin); + const update = updates[packageName]; + if (update) { + plugin.version = await normalizeVersionForNxJson( + plugin.plugin, + update.version + ); + } + } + } + + if (nxJson.installation.plugins) { + for (const dep in nxJson.installation.plugins) { + const update = updates[dep]; + if (update) { + nxJson.installation.plugins[dep] = await normalizeVersionForNxJson( + dep, + update.version + ); + } + } + } +} + +export function getPackageName(name: string) { + if (name.startsWith('@')) { + return name.split('/').slice(0, 2).join('/'); + } + return name.split('/')[0]; +} + +export function readDependenciesFromNxJson( + nxJson: NxJsonConfiguration +): Record { + const deps = {}; + if (nxJson?.installation?.version) { + deps['nx'] = nxJson.installation.version; + for (const dep in nxJson.installation ?? {}) { + deps[dep] = nxJson.installation.plugins[dep]; + } + for (const plugin of nxJson.plugins ?? []) { + if (typeof plugin === 'object' && plugin.version) { + deps[plugin.plugin] = plugin.version; + } + } + } + return deps; +} + +async function normalizeVersionForNxJson( + dep: string, + version: string +): Promise { + return valid(version) + ? version + : await resolvePackageVersionUsingRegistry(dep, version); +}