Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): expand meaning of plugins array to allow installing non-runtime plugins and inter-opt with installations property #22108

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
13 changes: 7 additions & 6 deletions docs/generated/devkit/ExpandedPluginConfiguration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@

#### Type declaration

| Name | Type |
| :--------- | :--------- |
| `exclude?` | `string`[] |
| `include?` | `string`[] |
| `options?` | `T` |
| `plugin` | `string` |
| Name | Type | Description |
| :--------- | :--------- | :--------------------------------------------------------------------------------------------------------------- |
| `exclude?` | `string`[] | Glob patterns to exclude paths from the plugin's createNodes function. |
| `include?` | `string`[] | Glob patterns to limit which paths the plugin's createNodes function will run on. |
| `options?` | `T` | Configuration options for the loaded plugin |
| `plugin` | `string` | The plugin module that should be loaded by Nx |
| `version?` | `string` | The version of the plugin to be installed by the Nx wrapper. If Nx is invoked from node_modules, this is unused. |
17 changes: 17 additions & 0 deletions docs/shared/recipes/installation/install-non-javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,20 @@ nx graph

{% /tab %}
{% /tabs %}

## 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.
12 changes: 12 additions & 0 deletions e2e/nx/src/misc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions packages/nx/src/command-line/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (options.verbose) {
Expand Down Expand Up @@ -62,15 +63,22 @@ async function installPackage(
})
);
} else {
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('--help', { silent: true });
} 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { readFileSync } from 'fs';
import { sanitizeWrapperScript } from './add-nx-scripts';
import { join } from 'path';

describe('sanitizeWrapperScript', () => {
it('should remove any comments starting with //#', () => {
Expand All @@ -19,6 +21,191 @@ 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
// nxw.ts file are as expected. It will need to be updated if the contents of
// nxw.ts change.
//
// NOTE: this doesn't match the written nxw in consumer repos as its still
// typescript, but its close enough for a sanity check.
it('should match expected contents', () => {
const contents = readFileSync(join(__dirname, 'nxw.ts'), 'utf-8');
const stripped = sanitizeWrapperScript(contents);
expect(stripped).toMatchInlineSnapshot(`
"// This file should be committed to your repository! It wraps Nx and ensures
// that your local installation matches nx.json.
//
// 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');

import type { NxJsonConfiguration } from '../../../../config/nx-json';
import type { PackageJson } from '../../../../utils/package-json';

const installationPath = path.join(__dirname, 'installation', 'package.json');

function matchesCurrentNxInstall(
currentInstallation: PackageJson,
nxJson: NxJsonConfiguration
) {
if (
!currentInstallation.devDependencies ||
!Object.keys(currentInstallation.devDependencies).length
) {
return false;
}

try {
if (
currentInstallation.devDependencies['nx'] !==
nxJson.installation.version ||
require(path.join(
path.dirname(installationPath),
'node_modules',
'nx',
'package.json'
)).version !== nxJson.installation.version
) {
return false;
}
for (const [plugin, desiredVersion] of getDesiredPackageVersions(nxJson)) {
if (currentInstallation.devDependencies[plugin] !== desiredVersion) {
return false;
}
}
return true;
} catch {
return false;
}
}

function ensureDir(p: string) {
if (!fs.existsSync(p)) {
fs.mkdirSync(p, { recursive: true });
}
}

function getCurrentInstallation(): PackageJson {
try {
return require(installationPath);
} catch {
return {
name: 'nx-installation',
version: '0.0.0',
devDependencies: {},
};
}
}

function performInstallation(
currentInstallation: PackageJson,
nxJson: NxJsonConfiguration
) {
fs.writeFileSync(
installationPath,
JSON.stringify({
name: 'nx-installation',
devDependencies: Object.fromEntries(getDesiredPackageVersions(nxJson)),
})
);

try {
cp.execSync('npm i', {
cwd: path.dirname(installationPath),
stdio: 'inherit',
});
} catch (e) {
// revert possible changes to the current installation
fs.writeFileSync(installationPath, JSON.stringify(currentInstallation));
// rethrow
throw e;
}
}

let WARNED_DEPRECATED_INSTALLATIONS_PLUGIN_PROPERTY = false;
function getDesiredPackageVersions(nxJson: NxJsonConfiguration) {
const packages: Record<string, string> = {
nx: nxJson.installation.version,
};
for (const [plugin, version] of Object.entries(
nxJson?.installation?.plugins ?? {}
)) {
packages[plugin] = version;
if (!WARNED_DEPRECATED_INSTALLATIONS_PLUGIN_PROPERTY) {
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_DEPRECATED_INSTALLATIONS_PLUGIN_PROPERTY = true;
}
}

for (const plugin of nxJson.plugins ?? []) {
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('/');
}
return name.split('/')[0];
}

function ensureUpToDateInstallation() {
const nxJsonPath = path.join(__dirname, '..', 'nx.json');
let nxJson: NxJsonConfiguration;

try {
nxJson = require(nxJsonPath);
if (!nxJson.installation) {
console.error(
'[NX]: The "installation" entry in the "nx.json" file is required when running the nx wrapper. See https://nx.dev/recipes/installation/install-non-javascript'
);
process.exit(1);
}
} catch {
console.error(
'[NX]: The "nx.json" file is required when running the nx wrapper. See https://nx.dev/recipes/installation/install-non-javascript'
);
process.exit(1);
}

try {
ensureDir(path.join(__dirname, 'installation'));
const currentInstallation = getCurrentInstallation();
if (!matchesCurrentNxInstall(currentInstallation, nxJson)) {
performInstallation(currentInstallation, nxJson);
}
} catch (e: unknown) {
const messageLines = [
'[NX]: Nx wrapper failed to synchronize installation.',
];
if (e instanceof Error) {
messageLines.push('');
messageLines.push(e.message);
messageLines.push(e.stack);
} else {
messageLines.push(e.toString());
}
console.error(messageLines.join('\\n'));
process.exit(1);
}
}

if (!process.env.NX_WRAPPER_SKIP_INSTALL) {
ensureUpToDateInstallation();
}
require('./installation/node_modules/nx/bin/nx');
"
`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,44 @@ 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+\\/\\/# .*',
// Comments that are empty (often used for newlines between internal comments)
'\\s*\\/\\/\\s*',
'\\s+\\/\\/\\s*$',
// Comments that disable an eslint rule.
'\\/\\/ eslint-disable-next-line.*',
'(^|\\s?)\\/\\/ ?eslint-disable.*$',
];
const regex = `(${linesToRemove.join('|')})$`;
return input.replace(new RegExp(regex, 'gm'), '');

const replacements = [
...removals.map((r) => [r, '']),
// Remove the sourceMapUrl comment
['^\\/\\/# sourceMappingURL.*$', ''],
// Keep empty comments if ! is present
['^\\/\\/!$', '//'],
];

const replaced = replacements.reduce(
(result, [pattern, replacement]) =>
result.replace(new RegExp(pattern, 'gm'), replacement),
input
);

// Reduce multiple newlines to a single newline
const lines = replaced.split('\n');
const sanitized = lines
.reduce((result, line, idx) => {
const trimmed = line.trim();
const prevTrimmed = lines[idx - 1]?.trim();

// current line or previous line has non-whitespace
if (trimmed.length || prevTrimmed?.length) {
result.push(line);
}

return result;
}, [] as string[])
.join('\n');

return sanitized;
}
Loading