nx/packages/devkit/src/utils/add-plugin.ts
Emily Xiong 1d7465b02e
feat(core): not exit when one plugin installation failed (#28684)
<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
it tries to install all plugins at once. if failed, it will stop the
remaining plugins from being installed.

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
it will continue if one plugin failed.

success message:
<img width="301" alt="Screenshot 2024-12-11 at 11 36 14 AM"
src="https://github.com/user-attachments/assets/2bc389f0-4fda-4959-afab-57594c9d600b">
failed message:
<img width="894" alt="Screenshot 2024-12-12 at 2 58 17 PM"
src="https://github.com/user-attachments/assets/7b51d5e9-f308-48c3-9b7d-bc4219802acb"
/>


## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
2024-12-16 14:38:22 -05:00

445 lines
12 KiB
TypeScript

import type { PackageJson } from 'nx/src/utils/package-json';
import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils';
import * as yargs from 'yargs-parser';
import {
CreateNodes,
CreateNodesV2,
ProjectConfiguration,
ProjectGraph,
readJson,
readNxJson,
Tree,
updateNxJson,
writeJson,
} from 'nx/src/devkit-exports';
import {
isProjectConfigurationsError,
isProjectsWithNoNameError,
LoadedNxPlugin,
retrieveProjectConfigurations,
} from 'nx/src/devkit-internals';
/**
* Iterates through various forms of plugin options to find the one which does not conflict with the current graph
*/
export async function addPlugin<PluginOptions>(
tree: Tree,
graph: ProjectGraph,
pluginName: string,
createNodesTuple: CreateNodesV2<PluginOptions>,
options: Partial<
Record<keyof PluginOptions, PluginOptions[keyof PluginOptions][]>
>,
shouldUpdatePackageJsonScripts: boolean
): Promise<void> {
return _addPluginInternal(
tree,
graph,
pluginName,
(pluginOptions) =>
new LoadedNxPlugin(
{
name: pluginName,
createNodesV2: createNodesTuple,
},
{
plugin: pluginName,
options: pluginOptions,
}
),
options,
shouldUpdatePackageJsonScripts
);
}
/**
* @deprecated Use `addPlugin` instead
* Iterates through various forms of plugin options to find the one which does not conflict with the current graph
*/
export async function addPluginV1<PluginOptions>(
tree: Tree,
graph: ProjectGraph,
pluginName: string,
createNodesTuple: CreateNodes<PluginOptions>,
options: Partial<
Record<keyof PluginOptions, PluginOptions[keyof PluginOptions][]>
>,
shouldUpdatePackageJsonScripts: boolean
): Promise<void> {
return _addPluginInternal(
tree,
graph,
pluginName,
(pluginOptions) =>
new LoadedNxPlugin(
{
name: pluginName,
createNodes: createNodesTuple,
},
{
plugin: pluginName,
options: pluginOptions,
}
),
options,
shouldUpdatePackageJsonScripts
);
}
async function _addPluginInternal<PluginOptions>(
tree: Tree,
graph: ProjectGraph,
pluginName: string,
pluginFactory: (pluginOptions: PluginOptions) => LoadedNxPlugin,
options: Partial<
Record<keyof PluginOptions, PluginOptions[keyof PluginOptions][]>
>,
shouldUpdatePackageJsonScripts: boolean
) {
const graphNodes = Object.values(graph.nodes);
const nxJson = readNxJson(tree);
let pluginOptions: PluginOptions;
let projConfigs: ConfigurationResult;
if (Object.keys(options).length > 0) {
const combinations = generateCombinations(options);
optionsLoop: for (const _pluginOptions of combinations) {
pluginOptions = _pluginOptions as PluginOptions;
nxJson.plugins ??= [];
if (
nxJson.plugins.some((p) =>
typeof p === 'string'
? p === pluginName
: p.plugin === pluginName && !p.include
)
) {
// Plugin has already been added
return;
}
global.NX_GRAPH_CREATION = true;
try {
projConfigs = await retrieveProjectConfigurations(
[pluginFactory(pluginOptions)],
tree.root,
nxJson
);
} catch (e) {
// Errors are okay for this because we're only running 1 plugin
if (isProjectConfigurationsError(e)) {
projConfigs = e.partialProjectConfigurationsResult;
// ignore errors from projects with no name
if (!e.errors.every(isProjectsWithNoNameError)) {
throw e;
}
} else {
throw e;
}
}
global.NX_GRAPH_CREATION = false;
for (const projConfig of Object.values(projConfigs.projects)) {
const node = graphNodes.find(
(node) => node.data.root === projConfig.root
);
if (!node) {
continue;
}
for (const targetName in projConfig.targets) {
if (node.data.targets[targetName]) {
// Conflicting Target Name, check the next one
pluginOptions = null;
continue optionsLoop;
}
}
}
break;
}
} else {
// If the plugin does not take in options, we add the plugin with empty options.
nxJson.plugins ??= [];
pluginOptions = {} as unknown as PluginOptions;
global.NX_GRAPH_CREATION = true;
try {
projConfigs = await retrieveProjectConfigurations(
[pluginFactory(pluginOptions)],
tree.root,
nxJson
);
} catch (e) {
// Errors are okay for this because we're only running 1 plugin
if (isProjectConfigurationsError(e)) {
projConfigs = e.partialProjectConfigurationsResult;
// ignore errors from projects with no name
if (!e.errors.every(isProjectsWithNoNameError)) {
throw e;
}
} else {
throw e;
}
}
global.NX_GRAPH_CREATION = false;
}
if (!pluginOptions) {
throw new Error(
'Could not add the plugin in a way which does not conflict with existing targets. Please report this error at: https://github.com/nrwl/nx/issues/new/choose'
);
}
nxJson.plugins.push({
plugin: pluginName,
options: pluginOptions,
});
updateNxJson(tree, nxJson);
if (shouldUpdatePackageJsonScripts) {
updatePackageScripts(tree, projConfigs);
}
}
type TargetCommand = {
command: string;
target: string;
configuration?: string;
};
function updatePackageScripts(
tree: Tree,
projectConfigurations: ConfigurationResult
) {
for (const projectConfig of Object.values(projectConfigurations.projects)) {
const projectRoot = projectConfig.root;
processProject(tree, projectRoot, projectConfig);
}
}
function processProject(
tree: Tree,
projectRoot: string,
projectConfiguration: ProjectConfiguration
) {
const packageJsonPath = `${projectRoot}/package.json`;
if (!tree.exists(packageJsonPath)) {
return;
}
const packageJson = readJson<PackageJson>(tree, packageJsonPath);
if (!packageJson.scripts || !Object.keys(packageJson.scripts).length) {
return;
}
const targetCommands = getInferredTargetCommands(projectConfiguration);
if (!targetCommands.length) {
return;
}
let hasChanges = false;
for (const targetCommand of targetCommands) {
const { command, target, configuration } = targetCommand;
const targetCommandRegex = new RegExp(
`(?<=^|&)((?: )*(?:[^&\\r\\n\\s]+ )*)(${command})((?: [^&\\r\\n\\s]+)*(?: )*)(?=$|&)`,
'g'
);
for (const scriptName of Object.keys(packageJson.scripts)) {
const script = packageJson.scripts[scriptName];
// quick check for exact match within the script
if (targetCommandRegex.test(script)) {
packageJson.scripts[scriptName] = script.replace(
targetCommandRegex,
configuration
? `$1nx ${target} --configuration=${configuration}$3`
: `$1nx ${target}$3`
);
hasChanges = true;
} else {
/**
* Parse script and command to handle the following:
* - if command doesn't match script => don't replace
* - if command has more args => don't replace
* - if command has same args, regardless of order => replace removing args
* - if command has less args or with different value => replace leaving args
*/
const parsedCommand = yargs(command, {
configuration: { 'strip-dashed': true },
});
// this assumes there are no positional args in the command, everything is a command or subcommand
const commandCommand = parsedCommand._.join(' ');
const commandRegex = new RegExp(
`(?<=^|&)((?: )*(?:[^&\\r\\n\\s]+ )*)(${commandCommand})((?: [^&\\r\\n\\s]+)*( )*)(?=$|&)`,
'g'
);
const matches = script.match(commandRegex);
if (!matches) {
// the command doesn't match the script, don't replace
continue;
}
for (const match of matches) {
// parse the matched command within the script
const parsedScript = yargs(match, {
configuration: { 'strip-dashed': true },
});
let hasArgsWithDifferentValues = false;
let scriptHasExtraArgs = false;
let commandHasExtraArgs = false;
for (const [key, value] of Object.entries(parsedCommand)) {
if (key === '_') {
continue;
}
if (parsedScript[key] === undefined) {
commandHasExtraArgs = true;
break;
}
if (parsedScript[key] !== value) {
hasArgsWithDifferentValues = true;
}
}
if (commandHasExtraArgs) {
// the command has extra args, don't replace
continue;
}
for (const key of Object.keys(parsedScript)) {
if (key === '_') {
continue;
}
if (!parsedCommand[key]) {
scriptHasExtraArgs = true;
break;
}
}
if (!hasArgsWithDifferentValues && !scriptHasExtraArgs) {
// they are the same, replace with the command removing the args
packageJson.scripts[scriptName] = packageJson.scripts[
scriptName
].replace(
match,
match.replace(
commandRegex,
configuration
? `$1nx ${target} --configuration=${configuration}$4`
: `$1nx ${target}$4`
)
);
hasChanges = true;
} else {
// there are different args or the script has extra args, replace with the command leaving the args
packageJson.scripts[scriptName] = packageJson.scripts[
scriptName
].replace(
match,
match.replace(
commandRegex,
configuration
? `$1nx ${target} --configuration=${configuration}$3`
: `$1nx ${target}$3`
)
);
hasChanges = true;
}
}
}
}
}
if (hasChanges) {
writeJson(tree, packageJsonPath, packageJson);
}
}
function getInferredTargetCommands(
project: ProjectConfiguration
): TargetCommand[] {
const targetCommands: TargetCommand[] = [];
for (const [targetName, target] of Object.entries(project.targets ?? {})) {
if (target.command) {
targetCommands.push({ command: target.command, target: targetName });
} else if (
target.executor === 'nx:run-commands' &&
target.options?.command
) {
targetCommands.push({
command: target.options.command,
target: targetName,
});
}
if (!target.configurations) {
continue;
}
for (const [configurationName, configuration] of Object.entries(
target.configurations
)) {
if (configuration.command) {
targetCommands.push({
command: configuration.command,
target: targetName,
configuration: configurationName,
});
} else if (
target.executor === 'nx:run-commands' &&
configuration.options?.command
) {
targetCommands.push({
command: configuration.options.command,
target: targetName,
configuration: configurationName,
});
}
}
}
return targetCommands;
}
export function generateCombinations<T>(
input: Record<string, T[]>
): Record<string, T>[] {
// This is reversed so that combinations have the first defined property updated first
const keys = Object.keys(input).reverse();
return _generateCombinations(Object.values(input).reverse()).map(
(combination) => {
const result = {};
combination.reverse().forEach((combo, i) => {
result[keys[keys.length - i - 1]] = combo;
});
return result;
}
);
}
/**
* Generate all possible combinations of a 2-dimensional array.
*
* Useful for generating all possible combinations of options for a plugin
*/
function _generateCombinations<T>(input: T[][]): T[][] {
if (input.length === 0) {
return [[]];
} else {
const [first, ...rest] = input;
const partialCombinations = _generateCombinations(rest);
return first.flatMap((value) =>
partialCombinations.map((combination) => [value, ...combination])
);
}
}