<!-- 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 #
445 lines
12 KiB
TypeScript
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])
|
|
);
|
|
}
|
|
}
|