chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
@@ -0,0 +1,99 @@
import { parseTargetString, targetToTargetString } from './parse-target-string';
import * as splitTarget from 'nx/src/utils/split-target';
import {
ExecutorContext,
ProjectGraph,
readProjectsConfigurationFromProjectGraph,
} from 'nx/src/devkit-exports';
const cases = [
{ input: 'one:two', expected: { project: 'one', target: 'two' } },
{
input: 'one:two:three',
expected: { project: 'one', target: 'two', configuration: 'three' },
},
{
input: 'one:"two:two":three',
expected: { project: 'one', target: 'two:two', configuration: 'three' },
},
];
describe('parseTargetString', () => {
const graph: ProjectGraph = {
nodes: {
'my-project': {
type: 'lib',
name: 'my-project',
data: { root: '/packages/my-project' },
},
'other-project': {
type: 'lib',
name: 'other-project',
data: { root: '/packages/other-project' },
},
},
dependencies: {},
externalNodes: {},
version: '',
};
const mockContext: ExecutorContext = {
projectName: 'my-project',
cwd: '/virtual',
root: '/virtual',
isVerbose: false,
projectGraph: graph,
projectsConfigurations: readProjectsConfigurationFromProjectGraph(graph),
nxJsonConfiguration: {},
};
it.each(cases)('$input -> $expected', ({ input, expected }) => {
jest
.spyOn(splitTarget, 'splitTarget')
.mockReturnValueOnce(Object.values(expected) as [string]);
expect(parseTargetString(input, null)).toEqual(expected);
});
it('should support reading project from ExecutorContext', () => {
expect(parseTargetString('build', mockContext)).toEqual({
project: 'my-project',
target: 'build',
});
expect(parseTargetString('build:production', mockContext)).toEqual({
project: 'my-project',
target: 'build',
configuration: 'production',
});
expect(parseTargetString('other-project:build', mockContext)).toEqual({
project: 'other-project',
target: 'build',
});
});
// When running a converted executor, its possible that the context has a project name that
// isn't present within the project graph. In these cases, this function should still behave predictably.
it('should produce accurate results if the project graph doesnt contain the project', () => {
expect(
parseTargetString('foo:build', { ...mockContext, projectName: 'foo' })
).toEqual({
project: 'foo',
target: 'build',
});
expect(
parseTargetString('foo:build:production', {
...mockContext,
projectName: 'foo',
})
).toEqual({
project: 'foo',
target: 'build',
configuration: 'production',
});
});
});
describe('targetToTargetString', () => {
it.each(cases)('$expected -> $input', ({ input, expected }) => {
expect(targetToTargetString(expected)).toEqual(input);
});
});
@@ -0,0 +1,107 @@
import {
ExecutorContext,
ProjectGraph,
readCachedProjectGraph,
Target,
} from 'nx/src/devkit-exports';
import { splitByColons, splitTarget } from 'nx/src/devkit-internals';
/**
* Parses a target string into {project, target, configuration}
*
* Examples:
* ```typescript
* parseTargetString("proj:test", graph) // returns { project: "proj", target: "test" }
* parseTargetString("proj:test:production", graph) // returns { project: "proj", target: "test", configuration: "production" }
* ```
*
* @param targetString - target reference
*/
export function parseTargetString(
targetString: string,
projectGraph: ProjectGraph
): Target;
/**
* Parses a target string into {project, target, configuration}. Passing a full
* {@link ExecutorContext} enables the targetString to reference the current project.
*
* Examples:
* ```typescript
* parseTargetString("test", executorContext) // returns { project: "proj", target: "test" }
* parseTargetString("proj:test", executorContext) // returns { project: "proj", target: "test" }
* parseTargetString("proj:test:production", executorContext) // returns { project: "proj", target: "test", configuration: "production" }
* ```
*/
export function parseTargetString(
targetString: string,
ctx: ExecutorContext
): Target;
export function parseTargetString(
targetString: string,
projectGraphOrCtx?: ProjectGraph | ExecutorContext
): Target {
let projectGraph: ProjectGraph =
projectGraphOrCtx && 'projectGraph' in projectGraphOrCtx
? projectGraphOrCtx.projectGraph
: (projectGraphOrCtx as ProjectGraph);
if (!projectGraph) {
try {
projectGraph = readCachedProjectGraph();
} catch (e) {
projectGraph = { nodes: {} } as any;
}
}
const [maybeProject] = splitByColons(targetString);
if (
!projectGraph.nodes[maybeProject] &&
projectGraphOrCtx &&
'projectName' in projectGraphOrCtx &&
maybeProject !== projectGraphOrCtx.projectName
) {
targetString = `${projectGraphOrCtx.projectName}:${targetString}`;
}
const currentProject =
projectGraphOrCtx && 'projectName' in projectGraphOrCtx
? projectGraphOrCtx.projectName
: undefined;
const [project, target, configuration] = splitTarget(
targetString,
projectGraph,
{ currentProject }
);
if (!project || !target) {
throw new Error(`Invalid Target String: ${targetString}`);
}
return {
project,
target,
configuration,
};
}
/**
* Returns a string in the format "project:target[:configuration]" for the target
*
* @param target - target object
*
* Examples:
*
* ```typescript
* targetToTargetString({ project: "proj", target: "test" }) // returns "proj:test"
* targetToTargetString({ project: "proj", target: "test", configuration: "production" }) // returns "proj:test:production"
* ```
*/
export function targetToTargetString({
project,
target,
configuration,
}: Target): string {
return `${project}:${target.indexOf(':') > -1 ? `"${target}"` : target}${
configuration !== undefined ? ':' + configuration : ''
}`;
}
@@ -0,0 +1,58 @@
import { relative } from 'path';
import type { ExecutorContext, Target } from 'nx/src/devkit-exports';
import {
calculateDefaultProjectName,
combineOptionsForExecutor,
getExecutorInformation,
parseExecutor,
} from 'nx/src/devkit-internals';
/**
* Reads and combines options for a given target.
*
* Works as if you invoked the target yourself without passing any command line overrides.
*/
export function readTargetOptions<T = any>(
{ project, target, configuration }: Target,
context: ExecutorContext
): T {
const projectConfiguration = context.projectsConfigurations.projects[project];
if (!projectConfiguration) {
throw new Error(`Unable to find project ${project}`);
}
const targetConfiguration = projectConfiguration.targets[target];
if (!targetConfiguration) {
throw new Error(`Unable to find target ${target} for project ${project}`);
}
const [nodeModule, executorName] = parseExecutor(
targetConfiguration.executor
);
const { schema } = getExecutorInformation(
nodeModule,
executorName,
context.root,
context.projectsConfigurations?.projects
);
const defaultProject = calculateDefaultProjectName(
context.cwd,
context.root,
{ version: 2, projects: context.projectsConfigurations.projects },
context.nxJsonConfiguration
);
return combineOptionsForExecutor(
{},
configuration ?? targetConfiguration.defaultConfiguration ?? '',
targetConfiguration,
schema,
defaultProject,
relative(context.root, context.cwd)
) as T;
}
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`generateFiles should copy files from a directory into a tree 1`] = `
"file contents
"
`;
exports[`generateFiles should copy files from a directory into the tree 1`] = `
"file in directory contents
"
`;
exports[`generateFiles should overwrite files when option is overwrite 1`] = `
"file in directory contents
"
`;
exports[`generateFiles should remove ".template" from paths 1`] = `
"file with template suffix contents
"
`;
exports[`generateFiles should substitute properties in directory names 1`] = `
"file in directory foo bar contents
"
`;
exports[`generateFiles should substitute properties in paths 1`] = `
"file-with-property-foo-bar contents
"
`;
@@ -0,0 +1,313 @@
import { addProjectConfiguration } from 'nx/src/devkit-exports';
import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace';
import type { Tree } from 'nx/src/generators/tree';
import {
determineArtifactNameAndDirectoryOptions,
setCwd,
} from './artifact-name-and-directory-utils';
describe('determineArtifactNameAndDirectoryOptions', () => {
let tree: Tree;
let originalInitCwd;
function restoreCwd() {
if (originalInitCwd === undefined) {
delete process.env.INIT_CWD;
} else {
process.env.INIT_CWD = originalInitCwd;
}
}
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
setCwd('');
jest.clearAllMocks();
originalInitCwd = process.env.INIT_CWD;
});
it('should throw an error when the resolved directory is not under any project root', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
setCwd('some/path');
await expect(
determineArtifactNameAndDirectoryOptions(tree, {
path: 'myComponent',
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"The provided directory resolved relative to the current working directory "some/path" does not exist under any project root. Please make sure to navigate to a location or provide a directory that exists under a project root."`
);
restoreCwd();
});
it('should return the normalized options when there is a project at the cwd', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
setCwd('apps/app1');
const result = await determineArtifactNameAndDirectoryOptions(tree, {
path: 'myComponent',
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent',
filePath: 'apps/app1/myComponent.ts',
fileExtension: 'ts',
fileExtensionType: 'ts',
project: 'app1',
});
restoreCwd();
});
it('should not duplicate the cwd when the provided directory starts with the cwd', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
setCwd('apps/app1');
const result = await determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps/app1/myComponent',
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent',
filePath: 'apps/app1/myComponent.ts',
fileExtension: 'ts',
fileExtensionType: 'ts',
project: 'app1',
});
restoreCwd();
});
it(`should handle window's style paths correctly`, async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
const result = await determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps\\app1\\myComponent',
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent',
filePath: 'apps/app1/myComponent.ts',
fileExtension: 'ts',
fileExtensionType: 'ts',
project: 'app1',
});
});
it('should support receiving a suffix', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
const result = await determineArtifactNameAndDirectoryOptions(tree, {
suffix: 'component',
path: 'apps/app1/myComponent',
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent.component',
filePath: 'apps/app1/myComponent.component.ts',
fileExtension: 'ts',
fileExtensionType: 'ts',
project: 'app1',
});
});
it('should support receiving a suffix separator', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
const result = await determineArtifactNameAndDirectoryOptions(tree, {
suffix: 'component',
suffixSeparator: '-',
path: 'apps/app1/myComponent',
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent-component',
filePath: 'apps/app1/myComponent-component.ts',
fileExtension: 'ts',
fileExtensionType: 'ts',
project: 'app1',
});
});
it('should support receiving the full file path including the file extension', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
const result = await determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps/app1/myComponent.ts',
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent',
filePath: 'apps/app1/myComponent.ts',
fileExtension: 'ts',
fileExtensionType: 'ts',
project: 'app1',
});
});
it('should ignore specified suffix when receiving the full file path including the file extension', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
const result = await determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps/app1/myComponent.ts',
suffix: 'component',
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent',
filePath: 'apps/app1/myComponent.ts',
fileExtension: 'ts',
fileExtensionType: 'ts',
project: 'app1',
});
});
it('should support receiving a different file extension', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
const result = await determineArtifactNameAndDirectoryOptions(tree, {
fileExtension: 'tsx',
path: 'apps/app1/myComponent',
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent',
filePath: 'apps/app1/myComponent.tsx',
fileExtension: 'tsx',
fileExtensionType: 'ts',
project: 'app1',
});
});
it('should support receiving a file path with a non-default file extension', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
const result = await determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps/app1/myComponent.astro',
allowedFileExtensions: ['astro'],
});
expect(result).toStrictEqual({
artifactName: 'myComponent',
directory: 'apps/app1',
fileName: 'myComponent',
filePath: 'apps/app1/myComponent.astro',
fileExtension: 'astro',
fileExtensionType: 'other',
project: 'app1',
});
});
it('should throw an error when the file extension is not supported', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
await expect(
determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps/app1/myComponent.ts',
allowedFileExtensions: ['jsx', 'tsx'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"The provided file path has an extension (.ts) that is not supported by this generator.
The supported extensions are: .jsx, .tsx."
`);
});
it('should throw an error when having a TypeScript file extension and the --js option is used', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
await expect(
determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps/app1/myComponent.tsx',
js: true,
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"The provided file path has an extension (.tsx) that conflicts with the provided "--js" option."`
);
});
it('should throw an error when having a JavaScript file extension and the --js=false option is used', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
await expect(
determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps/app1/myComponent.jsx',
js: false,
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"The provided file path has an extension (.jsx) that conflicts with the provided "--js" option."`
);
});
it('should support customizing the --js option name', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
});
await expect(
determineArtifactNameAndDirectoryOptions(tree, {
path: 'apps/app1/myComponent.tsx',
js: true,
jsOptionName: 'language',
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"The provided file path has an extension (.tsx) that conflicts with the provided "--language" option."`
);
});
});
@@ -0,0 +1,245 @@
import {
getProjects,
joinPathFragments,
normalizePath,
workspaceRoot,
type ProjectConfiguration,
type Tree,
} from 'nx/src/devkit-exports';
import {
createProjectRootMappingsFromProjectConfigurations,
findProjectForPath,
} from 'nx/src/devkit-internals';
import { join, relative } from 'path';
const DEFAULT_ALLOWED_JS_FILE_EXTENSIONS = ['js', 'cjs', 'mjs', 'jsx'];
const DEFAULT_ALLOWED_TS_FILE_EXTENSIONS = ['ts', 'cts', 'mts', 'tsx'];
const DEFAULT_ALLOWED_FILE_EXTENSIONS = [
...DEFAULT_ALLOWED_JS_FILE_EXTENSIONS,
...DEFAULT_ALLOWED_TS_FILE_EXTENSIONS,
'vue',
];
export type ArtifactGenerationOptions = {
path: string;
name?: string;
fileExtension?: string;
suffix?: string;
suffixSeparator?: string;
allowedFileExtensions?: string[];
/**
* @deprecated Provide the full file path including the file extension in the `path` option. This option will be removed in Nx v21.
*/
js?: boolean;
/**
* @deprecated Provide the full file path including the file extension in the `path` option. This option will be removed in Nx v21.
*/
jsOptionName?: string;
};
export type FileExtensionType = 'js' | 'ts' | 'other';
export type NameAndDirectoryOptions = {
/**
* Normalized artifact name.
*/
artifactName: string;
/**
* Normalized directory path where the artifact will be generated.
*/
directory: string;
/**
* Normalized file name of the artifact without the extension.
*/
fileName: string;
/**
* Normalized file extension.
*/
fileExtension: string;
/**
* Normalized file extension type.
*/
fileExtensionType: FileExtensionType;
/**
* Normalized full file path of the artifact.
*/
filePath: string;
/**
* Project name where the artifact will be generated.
*/
project: string;
};
export async function determineArtifactNameAndDirectoryOptions(
tree: Tree,
options: ArtifactGenerationOptions
): Promise<NameAndDirectoryOptions> {
const normalizedOptions = getNameAndDirectoryOptions(tree, options);
validateResolvedProject(
normalizedOptions.project,
normalizedOptions.directory
);
return normalizedOptions;
}
function getNameAndDirectoryOptions(
tree: Tree,
options: ArtifactGenerationOptions
): NameAndDirectoryOptions {
const path = options.path
? normalizePath(options.path.replace(/^\.?\//, ''))
: undefined;
let { name: extractedName, directory } =
extractNameAndDirectoryFromPath(path);
const relativeCwd = getRelativeCwd();
// append the directory to the current working directory if it doesn't start with it
if (directory !== relativeCwd && !directory.startsWith(`${relativeCwd}/`)) {
directory = joinPathFragments(relativeCwd, directory);
}
const project = findProjectFromPath(tree, directory);
let fileName = extractedName;
let fileExtension: string = options.fileExtension ?? 'ts';
const allowedFileExtensions =
options.allowedFileExtensions ?? DEFAULT_ALLOWED_FILE_EXTENSIONS;
const fileExtensionRegex = new RegExp(
`\\.(${allowedFileExtensions.join('|')})$`
);
const fileExtensionMatch = fileName.match(fileExtensionRegex);
if (fileExtensionMatch) {
fileExtension = fileExtensionMatch[1];
fileName = fileName.replace(fileExtensionRegex, '');
extractedName = fileName;
} else if (options.suffix) {
fileName = `${fileName}${options.suffixSeparator ?? '.'}${options.suffix}`;
}
const filePath = joinPathFragments(directory, `${fileName}.${fileExtension}`);
const fileExtensionType = getFileExtensionType(fileExtension);
validateFileExtension(
fileExtension,
allowedFileExtensions,
options.js,
options.jsOptionName
);
return {
artifactName: options.name ?? extractedName,
directory,
fileName,
fileExtension,
fileExtensionType,
filePath,
project,
};
}
function validateResolvedProject(
project: string | undefined,
normalizedDirectory: string
): void {
if (project) {
return;
}
throw new Error(
`The provided directory resolved relative to the current working directory "${normalizedDirectory}" does not exist under any project root. ` +
`Please make sure to navigate to a location or provide a directory that exists under a project root.`
);
}
function findProjectFromPath(tree: Tree, path: string): string | null {
const projectConfigurations: Record<string, ProjectConfiguration> = {};
const projects = getProjects(tree);
for (const [projectName, project] of projects) {
projectConfigurations[projectName] = project;
}
const projectRootMappings =
createProjectRootMappingsFromProjectConfigurations(projectConfigurations);
return findProjectForPath(path, projectRootMappings);
}
export function getRelativeCwd(): string {
return normalizePath(relative(workspaceRoot, getCwd()));
}
/**
* Function for setting cwd during testing
*/
export function setCwd(path: string): void {
process.env.INIT_CWD = join(workspaceRoot, path);
}
function getCwd(): string {
return process.env.INIT_CWD?.startsWith(workspaceRoot)
? process.env.INIT_CWD
: process.cwd();
}
function extractNameAndDirectoryFromPath(path: string): {
name: string;
directory: string;
} {
// Remove trailing slash
path = path.replace(/\/$/, '');
const parsedPath = normalizePath(path).split('/');
const name = parsedPath.pop();
const directory = parsedPath.join('/');
return { name, directory };
}
function getFileExtensionType(fileExtension: string): FileExtensionType {
if (DEFAULT_ALLOWED_JS_FILE_EXTENSIONS.includes(fileExtension)) {
return 'js';
}
if (DEFAULT_ALLOWED_TS_FILE_EXTENSIONS.includes(fileExtension)) {
return 'ts';
}
return 'other';
}
function validateFileExtension(
fileExtension: string,
allowedFileExtensions: string[],
js: boolean | undefined,
jsOptionName: string | undefined
): FileExtensionType {
const fileExtensionType = getFileExtensionType(fileExtension);
if (!allowedFileExtensions.includes(fileExtension)) {
throw new Error(
`The provided file path has an extension (.${fileExtension}) that is not supported by this generator.
The supported extensions are: ${allowedFileExtensions
.map((ext) => `.${ext}`)
.join(', ')}.`
);
}
if (js !== undefined) {
jsOptionName = jsOptionName ?? 'js';
if (js && fileExtensionType === 'ts') {
throw new Error(
`The provided file path has an extension (.${fileExtension}) that conflicts with the provided "--${jsOptionName}" option.`
);
}
if (!js && fileExtensionType === 'js') {
throw new Error(
`The provided file path has an extension (.${fileExtension}) that conflicts with the provided "--${jsOptionName}" option.`
);
}
}
return fileExtensionType;
}
@@ -0,0 +1,317 @@
// Stub out the real @nx/vite/plugin so findPluginForConfigFile's dynamic
// `await import('@nx/vite/plugin')` doesn't pull @nx/vite and @nx/js source
// into this test. Only the createNodesV2 glob is consumed, and the include/
// exclude assertions depend on the nxJson registration, not the real plugin.
jest.mock(
'@nx/vite/plugin',
() => ({
createNodesV2: [
'**/{vite,vitest}.config.{js,ts,mjs,mts,cjs,cts}',
jest.fn(),
],
}),
{ virtual: true }
);
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
import { type Tree, readNxJson, updateNxJson } from 'nx/src/devkit-exports';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { getE2EWebServerInfo } from './e2e-web-server-info-utils';
describe('getE2EWebServerInfo', () => {
let tree: Tree;
let tempFs: TempFs;
beforeEach(() => {
tempFs = new TempFs('e2e-webserver-info');
tree = createTreeWithEmptyWorkspace();
tree.root = tempFs.tempDir;
tree.write(`app/vite.config.ts`, ``);
tempFs.createFileSync(`app/vite.config.ts`, ``);
});
afterEach(() => {
tempFs.cleanup();
jest.resetModules();
});
it('should use the default values when no plugin is registered and plugins are not being used', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getE2EWebServerInfo(
tree,
'app',
{
plugin: '@nx/vite/plugin',
configFilePath: 'app/vite.config.ts',
serveTargetName: 'serveTargetName',
serveStaticTargetName: 'previewTargetName',
},
{
defaultServeTargetName: 'serve',
defaultServeStaticTargetName: 'preview',
defaultE2EWebServerAddress: 'http://localhost:4200',
defaultE2ECiBaseUrl: 'http://localhost:4300',
defaultE2EPort: 4200,
},
false
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4300",
"e2eCiWebServerCommand": "npx nx run app:preview",
"e2eDevServerTarget": "app:serve",
"e2eWebServerAddress": "http://localhost:4200",
"e2eWebServerCommand": "npx nx run app:serve",
}
`);
});
it('should use the default values of the plugin when the plugin is just a string', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins = ['@nx/vite/plugin'];
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getE2EWebServerInfo(
tree,
'app',
{
plugin: '@nx/vite/plugin',
configFilePath: 'app/vite.config.ts',
serveTargetName: 'serveTargetName',
serveStaticTargetName: 'previewTargetName',
},
{
defaultServeTargetName: 'serve',
defaultServeStaticTargetName: 'preview',
defaultE2EWebServerAddress: 'http://localhost:4200',
defaultE2ECiBaseUrl: 'http://localhost:4300',
defaultE2EPort: 4200,
},
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4300",
"e2eCiWebServerCommand": "npx nx run app:preview",
"e2eDevServerTarget": "app:serve",
"e2eWebServerAddress": "http://localhost:4200",
"e2eWebServerCommand": "npx nx run app:serve",
}
`);
});
it('should use the values of the registered plugin when there is no includes or excludes defined', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/vite/plugin',
options: {
serveTargetName: 'vite:serve',
previewTargetName: 'vite:preview',
},
});
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getE2EWebServerInfo(
tree,
'app',
{
plugin: '@nx/vite/plugin',
configFilePath: 'app/vite.config.ts',
serveTargetName: 'serveTargetName',
serveStaticTargetName: 'previewTargetName',
},
{
defaultServeTargetName: 'serve',
defaultServeStaticTargetName: 'preview',
defaultE2EWebServerAddress: 'http://localhost:4200',
defaultE2ECiBaseUrl: 'http://localhost:4300',
defaultE2EPort: 4200,
},
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4300",
"e2eCiWebServerCommand": "npx nx run app:vite:preview",
"e2eDevServerTarget": "app:vite:serve",
"e2eWebServerAddress": "http://localhost:4200",
"e2eWebServerCommand": "npx nx run app:vite:serve",
}
`);
});
it('should handle targetDefaults', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/vite/plugin',
options: {
serveTargetName: 'vite:serve',
previewTargetName: 'vite:preview',
},
});
nxJson.targetDefaults ??= {};
nxJson.targetDefaults['vite:serve'] = {
options: {
port: 4400,
},
};
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getE2EWebServerInfo(
tree,
'app',
{
plugin: '@nx/vite/plugin',
configFilePath: 'app/vite.config.ts',
serveTargetName: 'serveTargetName',
serveStaticTargetName: 'previewTargetName',
},
{
defaultServeTargetName: 'serve',
defaultServeStaticTargetName: 'preview',
defaultE2EWebServerAddress: 'http://localhost:4200',
defaultE2ECiBaseUrl: 'http://localhost:4300',
defaultE2EPort: 4200,
},
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4300",
"e2eCiWebServerCommand": "npx nx run app:vite:preview",
"e2eDevServerTarget": "app:vite:serve",
"e2eWebServerAddress": "http://localhost:4400",
"e2eWebServerCommand": "npx nx run app:vite:serve",
}
`);
});
it('should handle array-shaped targetDefaults', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/vite/plugin',
options: {
serveTargetName: 'vite:serve',
previewTargetName: 'vite:preview',
},
});
nxJson.targetDefaults = {
'vite:serve': [
{
options: {
port: 4500,
},
},
],
};
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getE2EWebServerInfo(
tree,
'app',
{
plugin: '@nx/vite/plugin',
configFilePath: 'app/vite.config.ts',
serveTargetName: 'serveTargetName',
serveStaticTargetName: 'previewTargetName',
},
{
defaultServeTargetName: 'serve',
defaultServeStaticTargetName: 'preview',
defaultE2EWebServerAddress: 'http://localhost:4200',
defaultE2ECiBaseUrl: 'http://localhost:4300',
defaultE2EPort: 4200,
},
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4300",
"e2eCiWebServerCommand": "npx nx run app:vite:preview",
"e2eDevServerTarget": "app:vite:serve",
"e2eWebServerAddress": "http://localhost:4500",
"e2eWebServerCommand": "npx nx run app:vite:serve",
}
`);
});
it('should use the values of the correct registered plugin when there are includes or excludes defined', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/vite/plugin',
options: {
serveTargetName: 'vite:serve',
previewTargetName: 'vite:preview',
},
include: ['libs/**'],
});
nxJson.plugins.push({
plugin: '@nx/vite/plugin',
options: {
serveTargetName: 'vite-serve',
previewTargetName: 'vite-preview',
},
include: ['app/**'],
});
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getE2EWebServerInfo(
tree,
'app',
{
plugin: '@nx/vite/plugin',
configFilePath: 'app/vite.config.ts',
serveTargetName: 'serveTargetName',
serveStaticTargetName: 'previewTargetName',
},
{
defaultServeTargetName: 'serve',
defaultServeStaticTargetName: 'preview',
defaultE2EWebServerAddress: 'http://localhost:4200',
defaultE2ECiBaseUrl: 'http://localhost:4300',
defaultE2EPort: 4400,
},
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4300",
"e2eCiWebServerCommand": "npx nx run app:vite-preview",
"e2eDevServerTarget": "app:vite-serve",
"e2eWebServerAddress": "http://localhost:4400",
"e2eWebServerCommand": "npx nx run app:vite-serve",
}
`);
});
});
@@ -0,0 +1,112 @@
import {
type Tree,
detectPackageManager,
getPackageManagerCommand,
readNxJson,
} from 'nx/src/devkit-exports';
import type { PackageManagerCommands } from 'nx/src/utils/package-manager';
import { readTargetDefaultsForTarget } from './target-defaults-utils';
import { findPluginForConfigFile } from '../utils/find-plugin-for-config-file';
interface E2EWebServerDefaultValues {
defaultServeTargetName: string;
defaultServeStaticTargetName: string;
defaultE2EWebServerAddress: string;
defaultE2ECiBaseUrl: string;
defaultE2EPort: number;
}
interface E2EWebServerPluginOptions {
plugin: string;
configFilePath: string;
serveTargetName: string;
serveStaticTargetName: string;
}
export interface E2EWebServerDetails {
e2eWebServerAddress: string;
e2eWebServerCommand: string;
e2eCiWebServerCommand: string;
e2eCiBaseUrl: string;
e2eDevServerTarget: string;
}
export async function getE2EWebServerInfo(
tree: Tree,
projectName: string,
pluginOptions: E2EWebServerPluginOptions,
defaultValues: E2EWebServerDefaultValues,
isPluginBeingAdded: boolean
): Promise<E2EWebServerDetails> {
const pm = getPackageManagerCommand(detectPackageManager(tree.root));
if (isPluginBeingAdded) {
return await getE2EWebServerInfoForPlugin(
tree,
projectName,
pluginOptions,
defaultValues,
pm
);
} else {
return {
e2eWebServerAddress: defaultValues.defaultE2EWebServerAddress,
e2eWebServerCommand: `${pm.exec} nx run ${projectName}:${defaultValues.defaultServeTargetName}`,
e2eCiWebServerCommand: `${pm.exec} nx run ${projectName}:${defaultValues.defaultServeStaticTargetName}`,
e2eCiBaseUrl: defaultValues.defaultE2ECiBaseUrl,
e2eDevServerTarget: `${projectName}:${defaultValues.defaultServeTargetName}`,
};
}
}
async function getE2EWebServerInfoForPlugin(
tree: Tree,
projectName: string,
pluginOptions: E2EWebServerPluginOptions,
defaultValues: E2EWebServerDefaultValues,
pm: PackageManagerCommands
): Promise<E2EWebServerDetails> {
const foundPlugin = await findPluginForConfigFile(
tree,
pluginOptions.plugin,
pluginOptions.configFilePath
);
if (
!foundPlugin ||
typeof foundPlugin === 'string' ||
!foundPlugin?.options
) {
return {
e2eWebServerAddress: defaultValues.defaultE2EWebServerAddress,
e2eWebServerCommand: `${pm.exec} nx run ${projectName}:${defaultValues.defaultServeTargetName}`,
e2eCiWebServerCommand: `${pm.exec} nx run ${projectName}:${defaultValues.defaultServeStaticTargetName}`,
e2eCiBaseUrl: defaultValues.defaultE2ECiBaseUrl,
e2eDevServerTarget: `${projectName}:${defaultValues.defaultServeTargetName}`,
};
}
const nxJson = readNxJson(tree);
let e2ePort = defaultValues.defaultE2EPort ?? 4200;
const serveTargetName =
foundPlugin.options[pluginOptions.serveTargetName] ??
defaultValues.defaultServeTargetName;
e2ePort =
readTargetDefaultsForTarget(serveTargetName, nxJson.targetDefaults)?.options
?.port ?? e2ePort;
const e2eWebServerAddress = defaultValues.defaultE2EWebServerAddress.replace(
/:\d+/,
`:${e2ePort}`
);
return {
e2eWebServerAddress,
e2eWebServerCommand: `${pm.exec} nx run ${projectName}:${serveTargetName}`,
e2eCiWebServerCommand: `${pm.exec} nx run ${projectName}:${
foundPlugin.options[pluginOptions.serveStaticTargetName] ??
defaultValues.defaultServeStaticTargetName
}`,
e2eCiBaseUrl: defaultValues.defaultE2ECiBaseUrl,
e2eDevServerTarget: `${projectName}:${serveTargetName}`,
};
}
@@ -0,0 +1,87 @@
import { addProjectConfiguration, Tree } from 'nx/src/devkit-exports';
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
import { forEachExecutorOptions } from './executor-options-utils';
describe('forEachExecutorOptions', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
addProjectConfiguration(tree, 'proj1', {
root: 'proj1',
targets: {
a: {
executor: 'builder1',
options: {
builder1Option: 0,
},
configurations: {
production: {
builder1Option: 1,
},
},
},
},
});
addProjectConfiguration(tree, 'proj2', {
root: 'proj2',
targets: {
a: {
executor: 'builder2',
options: {
builder2Option: 0,
},
configurations: {
production: {
builder2Option: 1,
},
},
},
},
});
// configuration with no targets at all which is not valid
// but should not break the iteration over target configs
addProjectConfiguration(tree, 'proj3', {
root: 'proj3',
} as any);
});
it('should call a function for all options', () => {
const callback = jest.fn();
forEachExecutorOptions(tree, 'builder1', callback);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenCalledWith(
{
builder1Option: 0,
},
'proj1',
'a'
);
expect(callback).toHaveBeenCalledWith(
{
builder1Option: 1,
},
'proj1',
'a',
'production'
);
expect(callback).not.toHaveBeenCalledWith(
{
builder2Option: 0,
},
'proj2',
'a'
);
expect(callback).not.toHaveBeenCalledWith(
{
builder2Option: 1,
},
'proj2',
'a',
'production'
);
});
});
@@ -0,0 +1,70 @@
import {
getProjects,
ProjectConfiguration,
ProjectGraph,
Tree,
} from 'nx/src/devkit-exports';
type CallBack<T> = (
currentValue: T,
project: string,
target: string,
configuration?: string
) => void;
/**
* Calls a function for each different options that an executor is configured with
*/
export function forEachExecutorOptions<Options>(
tree: Tree,
/**
* Name of the executor to update options for
*/
executorName: string,
/**
* Callback that is called for each options configured for a builder
*/
callback: CallBack<Options>
): void {
forEachProjectConfig(getProjects(tree), executorName, callback);
}
/**
* Calls a function for each different options that an executor is configured with via the project graph
* this is helpful when you need to get the expaned configuration options from the nx.json
**/
export function forEachExecutorOptionsInGraph<Options>(
graph: ProjectGraph,
executorName: string,
callback: CallBack<Options>
): void {
const projects = new Map<string, ProjectConfiguration>();
Object.values(graph.nodes).forEach((p) => projects.set(p.name, p.data));
forEachProjectConfig<Options>(projects, executorName, callback);
}
function forEachProjectConfig<Options>(
projects: Map<string, ProjectConfiguration>,
executorName: string,
callback: CallBack<Options>
): void {
for (const [projectName, project] of projects) {
for (const [targetName, target] of Object.entries(project.targets || {})) {
if (executorName !== target.executor) {
continue;
}
callback(target.options ?? {}, projectName, targetName);
if (!target.configurations) {
continue;
}
Object.entries(target.configurations).forEach(([configName, options]) => {
callback(options, projectName, targetName, configName);
});
}
}
}
// TODO: add a method for updating options
@@ -0,0 +1,139 @@
import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace';
import type { Tree } from 'nx/src/generators/tree';
import { formatFiles } from './format-files';
describe('formatFiles', () => {
let tree: Tree;
const originalEnv = process.env;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
jest.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
describe('NX_SKIP_FORMAT', () => {
it('should skip Prettier formatting when NX_SKIP_FORMAT is true', async () => {
process.env.NX_SKIP_FORMAT = 'true';
// Create a file with intentionally bad formatting
const unformattedContent = 'const x = 1;';
tree.write('test.ts', unformattedContent);
await formatFiles(tree);
// File should remain unformatted
expect(tree.read('test.ts', 'utf-8')).toBe(unformattedContent);
});
it('should still sort tsconfig paths when NX_SKIP_FORMAT is true', async () => {
process.env.NX_SKIP_FORMAT = 'true';
process.env.NX_FORMAT_SORT_TSCONFIG_PATHS = 'true';
// Create a tsconfig with unsorted paths
tree.write(
'tsconfig.base.json',
JSON.stringify({
compilerOptions: {
paths: {
'@z/lib': ['libs/z/src/index.ts'],
'@a/lib': ['libs/a/src/index.ts'],
'@m/lib': ['libs/m/src/index.ts'],
},
},
})
);
await formatFiles(tree);
// Paths should be sorted alphabetically
const tsconfig = JSON.parse(tree.read('tsconfig.base.json', 'utf-8'));
const pathKeys = Object.keys(tsconfig.compilerOptions.paths);
expect(pathKeys).toEqual(['@a/lib', '@m/lib', '@z/lib']);
});
it('should not skip formatting when NX_SKIP_FORMAT is not set', async () => {
// Ensure NX_SKIP_FORMAT is not set
delete process.env.NX_SKIP_FORMAT;
// Create a prettierrc to indicate prettier is being used
tree.write('.prettierrc', JSON.stringify({ singleQuote: true }));
// Create a file that would be formatted
const unformattedContent = 'const x = 1';
tree.write('test.ts', unformattedContent);
// This test mainly verifies that the function doesn't early return
// when NX_SKIP_FORMAT is not set. Full formatting behavior depends
// on Prettier being available.
await formatFiles(tree);
// If Prettier is available, the file would be formatted
// If not, it remains unchanged - either way, no error should occur
expect(tree.exists('test.ts')).toBe(true);
});
it('should not skip formatting when NX_SKIP_FORMAT is set to something other than true', async () => {
process.env.NX_SKIP_FORMAT = 'false';
// Create a prettierrc to indicate prettier is being used
tree.write('.prettierrc', JSON.stringify({ singleQuote: true }));
const content = 'const x = 1';
tree.write('test.ts', content);
// Should not early return when NX_SKIP_FORMAT !== 'true'
await formatFiles(tree);
expect(tree.exists('test.ts')).toBe(true);
});
});
describe('sortRootTsconfigPaths', () => {
it('should sort tsconfig paths when sortRootTsconfigPaths option is true', async () => {
tree.write(
'tsconfig.base.json',
JSON.stringify({
compilerOptions: {
paths: {
'@z/lib': ['libs/z/src/index.ts'],
'@a/lib': ['libs/a/src/index.ts'],
},
},
})
);
await formatFiles(tree, { sortRootTsconfigPaths: true });
const tsconfig = JSON.parse(tree.read('tsconfig.base.json', 'utf-8'));
const pathKeys = Object.keys(tsconfig.compilerOptions.paths);
expect(pathKeys).toEqual(['@a/lib', '@z/lib']);
});
it('should sort tsconfig paths when NX_FORMAT_SORT_TSCONFIG_PATHS is true', async () => {
process.env.NX_FORMAT_SORT_TSCONFIG_PATHS = 'true';
tree.write(
'tsconfig.base.json',
JSON.stringify({
compilerOptions: {
paths: {
'@z/lib': ['libs/z/src/index.ts'],
'@a/lib': ['libs/a/src/index.ts'],
},
},
})
);
await formatFiles(tree);
const tsconfig = JSON.parse(tree.read('tsconfig.base.json', 'utf-8'));
const pathKeys = Object.keys(tsconfig.compilerOptions.paths);
expect(pathKeys).toEqual(['@a/lib', '@z/lib']);
});
});
});
@@ -0,0 +1,154 @@
import { readJson, Tree, writeJson } from 'nx/src/devkit-exports';
import {
isUsingPrettierInTree,
sortObjectByKeys,
} from 'nx/src/devkit-internals';
import * as path from 'path';
import type * as Prettier from 'prettier';
// Prettier v3 (ESM) exposes its API as named exports; v2 (CJS) exposes it under
// `.default` when loaded via `import()`. Return whichever carries the API, or
// null if prettier isn't installed.
async function importPrettier(): Promise<typeof Prettier | null> {
try {
const imported = await import('prettier');
return (
(imported as any).resolveConfig ? imported : (imported as any).default
) as typeof Prettier;
} catch {
return null;
}
}
/**
* Formats all the created or updated files using Prettier
* @param tree - the file system tree
* @param options - options for the formatFiles function
*
* @remarks
* Set the environment variable `NX_SKIP_FORMAT` to `true` to skip Prettier
* formatting. This is useful for repositories that use alternative formatters
* like Biome, dprint, or have custom formatting requirements.
*
* Note: `NX_SKIP_FORMAT` only skips Prettier formatting. TSConfig path sorting
* (controlled by `sortRootTsconfigPaths` option or `NX_FORMAT_SORT_TSCONFIG_PATHS`)
* will still occur.
*/
export async function formatFiles(
tree: Tree,
options: {
sortRootTsconfigPaths?: boolean;
} = {}
): Promise<void> {
options.sortRootTsconfigPaths ??=
process.env.NX_FORMAT_SORT_TSCONFIG_PATHS === 'true';
if (options.sortRootTsconfigPaths) {
sortTsConfig(tree);
}
// Skip Prettier formatting if NX_SKIP_FORMAT is set
// This is checked after tsconfig sorting since sorting is a separate concern
if (process.env.NX_SKIP_FORMAT === 'true') {
return;
}
let prettier: typeof Prettier | null;
try {
prettier = await importPrettier();
/**
* Even after we discovered prettier in node_modules, we need to be sure that the user is intentionally using prettier
* before proceeding to format with it.
*/
if (!isUsingPrettierInTree(tree)) {
return;
}
} catch {}
if (!prettier) return;
const files = new Set(
tree.listChanges().filter((file) => file.type !== 'DELETE')
);
const changedPrettierInTree = getChangedPrettierConfigInTree(tree);
await Promise.all(
Array.from(files).map(async (file) => {
try {
const systemPath = path.join(tree.root, file.path);
const resolvedOptions = await prettier.resolveConfig(systemPath, {
editorconfig: true,
});
const options: Prettier.Options = {
...resolvedOptions,
...changedPrettierInTree,
filepath: systemPath,
};
if (file.path.endsWith('.swcrc')) {
options.parser = 'json';
}
const support = await prettier.getFileInfo(systemPath, options as any);
if (support.ignored || !support.inferredParser) {
return;
}
tree.write(
file.path,
await prettier.format(file.content.toString('utf-8'), options)
);
} catch (e) {
console.warn(`Could not format ${file.path}. Error: "${e.message}"`);
}
})
);
}
function sortTsConfig(tree: Tree) {
try {
const tsConfigPath = getRootTsConfigPath(tree);
if (!tsConfigPath) {
return;
}
const tsconfig = readJson(tree, tsConfigPath);
if (!tsconfig.compilerOptions?.paths) {
// no paths to sort
return;
}
writeJson(tree, tsConfigPath, {
...tsconfig,
compilerOptions: {
...tsconfig.compilerOptions,
paths: sortObjectByKeys(tsconfig.compilerOptions.paths),
},
});
} catch (e) {
// catch noop
}
}
function getRootTsConfigPath(tree: Tree): string | null {
for (const path of ['tsconfig.base.json', 'tsconfig.json']) {
if (tree.exists(path)) {
return path;
}
}
return null;
}
function getChangedPrettierConfigInTree(tree: Tree): Prettier.Options | null {
if (tree.listChanges().find((file) => file.path === '.prettierrc')) {
try {
return readJson(tree, '.prettierrc');
} catch {
return null;
}
} else {
return null;
}
}
@@ -0,0 +1,153 @@
import * as FileType from 'file-type';
import { createTree } from 'nx/src/generators/testing-utils/create-tree';
import type { Tree } from 'nx/src/generators/tree';
import { join } from 'path';
import { OverwriteStrategy, generateFiles } from './generate-files';
describe('generateFiles', () => {
let tree: Tree;
beforeAll(() => {
tree = createTree();
generateFiles(tree, join(__dirname, './test-files'), '.', {
foo: 'bar',
name: 'my-project',
projectName: 'my-project-name',
dot: '.',
});
});
it('should copy files from a directory into a tree', () => {
expect(tree.exists('file.txt')).toBeTruthy();
expect(tree.read('file.txt', 'utf-8')).toMatchSnapshot();
});
it('should copy files from a directory into the tree', () => {
expect(tree.exists('directory/file-in-directory.txt')).toBeTruthy();
expect(
tree.read('directory/file-in-directory.txt', 'utf-8')
).toMatchSnapshot();
});
it('should remove ".template" from paths', () => {
expect(
tree.exists('file-with-template-suffix.txt.template')
).not.toBeTruthy();
expect(tree.exists('file-with-template-suffix.txt')).toBeTruthy();
expect(
tree.read('file-with-template-suffix.txt', 'utf-8')
).toMatchSnapshot();
});
it('should substitute properties in paths', () => {
expect(tree.exists('file-with-property-foo-__foo__.txt')).not.toBeTruthy();
expect(tree.exists('file-with-property-foo-bar.txt')).toBeTruthy();
expect(
tree.read('file-with-property-foo-bar.txt', 'utf-8')
).toMatchSnapshot();
});
it('should substitute properties in directory names', () => {
expect(
tree.exists('directory-foo-__foo__/file-in-directory-foo-__foo__.txt')
).not.toBeTruthy();
expect(
tree.exists('directory-foo-bar/file-in-directory-foo-bar.txt')
).toBeTruthy();
expect(
tree.read('directory-foo-bar/file-in-directory-foo-bar.txt', 'utf-8')
).toMatchSnapshot();
});
it('should generate files from template in a directory', () => {
expect(tree.exists(`src/common-util.ts`)).toBeTruthy();
expect(
tree.exists(`src/my-project-name/create-my-project.input.ts`)
).toBeTruthy();
expect(
tree.exists(
`src/my-project-name/my-project/my-project-name.my-project.model.ts`
)
).toBeTruthy();
expect(tree.exists(`src/my-project-name/output/.gitkeep`)).toBeTruthy();
expect(tree.exists(`src/my-project.module.ts`)).toBeTruthy();
});
it('should preserve image files', async () => {
expect(tree.exists('image.png')).toBeTruthy();
await expect(FileType.fromBuffer(tree.read('image.png'))).resolves.toEqual({
ext: 'png',
mime: 'image/png',
});
});
it('should throw if overwrite is treated as an error', async () => {
expect(() => {
generateFiles(
tree,
join(__dirname, './test-files'),
'.',
{
foo: 'bar',
name: 'my-project',
projectName: 'my-project-name',
dot: '.',
},
{ overwriteStrategy: OverwriteStrategy.ThrowIfExisting }
);
}).toThrow(
'Generated file already exists, not allowed by overwrite strategy in generator (directory/file-in-directory.txt)'
);
});
it('should overwrite files when option is overwrite', async () => {
// Write a custom file that will be overwritten
tree.write(
'directory/file-in-directory.txt',
'placeholder text to overwrite'
);
// Run generation again
generateFiles(
tree,
join(__dirname, './test-files'),
'.',
{
foo: 'bar',
name: 'my-project',
projectName: 'my-project-name',
dot: '.',
},
{ overwriteStrategy: OverwriteStrategy.Overwrite }
);
// File must have been overwritten
expect(
tree.read('directory/file-in-directory.txt', 'utf-8')
).toMatchSnapshot();
});
it('should keep files when option is to keep existing files', async () => {
// Write a custom file that will stay the same
const placeholder = 'placeholder text to keep';
tree.write('directory/file-in-directory.txt', placeholder);
// Run generation again
generateFiles(
tree,
join(__dirname, './test-files'),
'.',
{
foo: 'bar',
name: 'my-project',
projectName: 'my-project-name',
dot: '.',
},
{ overwriteStrategy: OverwriteStrategy.KeepExisting }
);
// File must have been kept
expect(tree.read('directory/file-in-directory.txt', 'utf-8')).toEqual(
placeholder
);
});
});
@@ -0,0 +1,144 @@
import { readdirSync, readFileSync, statSync } from 'fs';
import * as path from 'path';
import { isBinaryPath } from '../utils/binary-extensions';
import { logger, type Tree } from 'nx/src/devkit-exports';
/**
* Specify what should be done when a file is generated but already exists on the system
*/
export enum OverwriteStrategy {
Overwrite = 'overwrite',
KeepExisting = 'keepExisting',
ThrowIfExisting = 'throwIfExisting',
}
/**
* Options for the generateFiles function
*/
export interface GenerateFilesOptions {
/**
* Specify what should be done when a file is generated but already exists on the system
*/
overwriteStrategy?: OverwriteStrategy;
}
// TODO(v24): use the version from nx/src/generators/utils
/**
* Generates a folder of files based on provided templates.
*
* While doing so it performs two substitutions:
* - Substitutes segments of file names surrounded by __
* - Uses ejs to substitute values in templates
*
* Examples:
* ```typescript
* generateFiles(tree, path.join(__dirname , 'files'), './tools/scripts', {tmpl: '', name: 'myscript'})
* ```
* This command will take all the files from the `files` directory next to the place where the command is invoked from.
* It will replace all `__tmpl__` with '' and all `__name__` with 'myscript' in the file names, and will replace all
* `<%= name %>` with `myscript` in the files themselves.
* `tmpl: ''` is a common pattern. With it you can name files like this: `index.ts__tmpl__`, so your editor
* doesn't get confused about incorrect TypeScript files.
*
* @param tree - the file system tree
* @param srcFolder - the source folder of files (absolute path)
* @param target - the target folder (relative to the tree root)
* @param substitutions - an object of key-value pairs
* @param options - See {@link GenerateFilesOptions}
*/
export function generateFiles(
tree: Tree,
srcFolder: string,
target: string,
substitutions: { [k: string]: any },
options: GenerateFilesOptions = {
overwriteStrategy: OverwriteStrategy.Overwrite,
}
): void {
options ??= {};
options.overwriteStrategy ??= OverwriteStrategy.Overwrite;
const ejs = require('ejs');
const files = allFilesInDir(srcFolder);
if (files.length === 0) {
throw new Error(
`generateFiles: No files found in "${srcFolder}". Are you sure you specified the correct path?`
);
} else {
files.forEach((filePath) => {
let newContent: Buffer | string;
const computedPath = computePath(
srcFolder,
target,
filePath,
substitutions
);
if (tree.exists(computedPath)) {
if (options.overwriteStrategy === OverwriteStrategy.KeepExisting) {
return;
} else if (
options.overwriteStrategy === OverwriteStrategy.ThrowIfExisting
) {
throw new Error(
`Generated file already exists, not allowed by overwrite strategy in generator (${computedPath})`
);
}
// else: file should be overwritten, so just fall through to file generation
}
if (isBinaryPath(filePath)) {
newContent = readFileSync(filePath);
} else {
const template = readFileSync(filePath, 'utf-8');
try {
newContent = ejs.render(template, substitutions, {
filename: filePath,
});
} catch (e) {
logger.error(`Error in ${filePath.replace(`${tree.root}/`, '')}:`);
throw e;
}
}
tree.write(computedPath, newContent);
});
}
}
function computePath(
srcFolder: string,
target: string,
filePath: string,
substitutions: { [k: string]: any }
): string {
const relativeFromSrcFolder = path.relative(srcFolder, filePath);
let computedPath = path.join(target, relativeFromSrcFolder);
if (computedPath.endsWith('.template')) {
computedPath = computedPath.substring(0, computedPath.length - 9);
}
Object.entries(substitutions).forEach(([propertyName, value]) => {
computedPath = computedPath.split(`__${propertyName}__`).join(value);
});
return computedPath;
}
function allFilesInDir(parent: string): string[] {
let res: string[] = [];
try {
readdirSync(parent).forEach((c) => {
const child = path.join(parent, c);
try {
const s = statSync(child);
if (!s.isDirectory()) {
res.push(child);
} else if (s.isDirectory()) {
res = [...res, ...allFilesInDir(child)];
}
} catch {}
});
} catch {}
return res;
}
@@ -0,0 +1,89 @@
import { logger } from 'nx/src/devkit-exports';
import { AggregatedLog } from './aggregate-log-util';
describe(`aggregateLog utils`, () => {
it('should aggregate similar logs to single log listing the affected projects', () => {
// ARRANGE
let spyLog = '';
jest.spyOn(logger, 'warn').mockImplementation((log) => (spyLog = log));
const aggregatedLogs = new AggregatedLog();
aggregatedLogs.addLog({
executorName: '@nx/vite:serve',
log: `Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.`,
project: 'app',
});
aggregatedLogs.addLog({
executorName: '@nx/vite:serve',
log: `Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.`,
project: 'myapp',
});
aggregatedLogs.addLog({
executorName: '@nx/vite:serve',
log: `Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.`,
project: 'shop-app',
});
// ACT
aggregatedLogs.flushLogs();
// ASSERT
expect(logger.warn).toHaveBeenCalled();
expect(spyLog).toMatchInlineSnapshot(`
"Encountered the following while migrating '@nx/vite:serve':
 • Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.
Affected Projects
app
myapp
shop-app
"
`);
});
it('should aggregate similar logs to single log and output different logs correctly', () => {
// ARRANGE
let spyLog = '';
jest.spyOn(logger, 'warn').mockImplementation((log) => (spyLog = log));
const aggregatedLogs = new AggregatedLog();
aggregatedLogs.addLog({
executorName: '@nx/vite:serve',
log: `Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.`,
project: 'app',
});
aggregatedLogs.addLog({
executorName: '@nx/vite:build',
log: `Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.`,
project: 'myapp',
});
aggregatedLogs.addLog({
executorName: '@nx/vite:serve',
log: `Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.`,
project: 'shop-app',
});
aggregatedLogs.addLog({
executorName: '@nx/vite:serve',
log: `Encountered 'AnotherValue' in project.json. You will need to copy the contents of this file to the 'config.prop' property in your Vite config file.`,
project: 'shop-app',
});
// ACT
aggregatedLogs.flushLogs();
// ASSERT
expect(logger.warn).toHaveBeenCalled();
expect(spyLog).toMatchInlineSnapshot(`
"Encountered the following while migrating '@nx/vite:serve':
 • Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.
Affected Projects
app
shop-app
• Encountered 'AnotherValue' in project.json. You will need to copy the contents of this file to the 'config.prop' property in your Vite config file.
Affected Projects
shop-app
Encountered the following while migrating '@nx/vite:build':
 • Encountered 'proxyConfig' in project.json. You will need to copy the contents of this file to the 'server.proxy' property in your Vite config file.
Affected Projects
myapp
"
`);
});
});
@@ -0,0 +1,70 @@
import { output, logger } from 'nx/src/devkit-exports';
interface AggregateLogOptions {
project: string;
log: string;
executorName: string;
}
interface AggregateLogItem {
log: string;
projects: Set<string>;
}
/**
* @example
* // Instantiate a new object
* const migrationLogs = new AggregatedLog();
*
* // Add logs
* migrationLogs.addLog({executorName: '@nx/vite:build', project: 'app1', log: 'Migrate X manually'});
*
* // Flush all logs
* migrationLogs.flushLogs()
*/
export class AggregatedLog {
logs: Map<string, Map<string, AggregateLogItem>> = new Map();
addLog({ project, log, executorName }: AggregateLogOptions): void {
if (!this.logs.has(executorName)) {
this.logs.set(executorName, new Map());
}
const executorLogs = this.logs.get(executorName);
if (!executorLogs.has(log)) {
executorLogs.set(log, { log, projects: new Set([project]) });
} else {
const logItem = executorLogs.get(log);
logItem.projects.add(project);
}
}
reset(): void {
this.logs.clear();
}
flushLogs(): void {
if (this.logs.size === 0) {
return;
}
let fullLog = '';
for (const executorName of this.logs.keys()) {
fullLog = `${fullLog}${output.bold(
`Encountered the following while migrating '${executorName}':\r\n`
)}`;
for (const logItem of this.logs.get(executorName).values()) {
fullLog = `${fullLog}${logItem.log}\r\n`;
fullLog = `${fullLog} ${output.bold(`Affected Projects`)}\r\n`;
fullLog = `${fullLog} ${Array.from(logItem.projects.values()).join(
`\r\n `
)}`;
fullLog = `${fullLog}\r\n`;
}
}
logger.warn(fullLog);
this.reset();
}
}
@@ -0,0 +1,59 @@
import type { TargetDefaults } from 'nx/src/devkit-exports';
import { readTargetDefaultsForExecutor } from './executor-to-plugin-migrator';
describe('readTargetDefaultsForExecutor', () => {
it('reads the exact executor-keyed default from legacy record-shaped targetDefaults', () => {
const targetDefaults: TargetDefaults = {
'@nx/example:build': {
cache: true,
dependsOn: ['^build'],
},
build: {
executor: '@nx/example:build',
cache: false,
},
};
expect(
readTargetDefaultsForExecutor('@nx/example:build', targetDefaults)
).toEqual({
cache: true,
dependsOn: ['^build'],
});
});
it('reads the unfiltered executor entry from an executor-keyed default', () => {
const targetDefaults: TargetDefaults = {
'@nx/example:test': {
inputs: ['default', '^default'],
},
};
expect(
readTargetDefaultsForExecutor('@nx/example:test', targetDefaults)
).toEqual({
inputs: ['default', '^default'],
});
});
it('does not broaden to target-scoped or filtered entries', () => {
const targetDefaults: TargetDefaults = {
build: [
{
filter: { executor: '@nx/example:build' },
cache: false,
},
],
'@nx/example:build': [
{
filter: { projects: ['app'] },
cache: true,
},
],
};
expect(
readTargetDefaultsForExecutor('@nx/example:build', targetDefaults)
).toBeUndefined();
});
});
@@ -0,0 +1,685 @@
import { minimatch } from 'minimatch';
import { deepStrictEqual } from 'node:assert';
import { join } from 'node:path/posix';
import type {
InputDefinition,
ProjectConfiguration,
} from 'nx/src/config/workspace-json-project-json';
import {
readNxJson,
readProjectConfiguration,
updateNxJson,
updateProjectConfiguration,
type CreateNodes,
type ExpandedPluginConfiguration,
type NxJsonConfiguration,
type ProjectGraph,
type TargetConfiguration,
type Tree,
} from 'nx/src/devkit-exports';
import {
LoadedNxPlugin,
ProjectConfigurationsError,
mergeTargetConfigurations,
retrieveProjectConfigurations,
globalSpinner,
} from 'nx/src/devkit-internals';
import type { RunCommandsOptions } from 'nx/src/executors/run-commands/run-commands.impl';
import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils';
import { forEachExecutorOptions } from '../executor-options-utils';
import { findTargetDefault } from '../target-defaults-utils';
import { deleteMatchingProperties } from './plugin-migration-utils';
import { logger as devkitLogger } from 'nx/src/devkit-exports';
export type InferredTargetConfiguration = TargetConfiguration & {
name: string;
};
type PluginOptionsBuilder<T> = (targetName: string) => T;
type PostTargetTransformer = (
targetConfiguration: TargetConfiguration,
tree: Tree,
projectDetails: { projectName: string; root: string },
inferredTargetConfiguration: InferredTargetConfiguration
) => TargetConfiguration | Promise<TargetConfiguration>;
type SkipTargetFilter = (
targetOptions: Record<string, unknown>,
projectConfiguration: ProjectConfiguration
) => false | string;
type SkipProjectFilter = (
projectConfiguration: ProjectConfiguration
) => false | string;
class ExecutorToPluginMigrator<T> {
readonly tree: Tree;
readonly #projectGraph: ProjectGraph;
readonly #executor: string;
readonly #pluginPath: string;
readonly #pluginOptionsBuilder: PluginOptionsBuilder<T>;
readonly #postTargetTransformer: PostTargetTransformer;
readonly #skipTargetFilter: SkipTargetFilter;
readonly #skipProjectFilter: SkipProjectFilter;
readonly #specificProjectToMigrate: string;
readonly #logger: typeof devkitLogger;
#nxJson: NxJsonConfiguration;
#targetDefaultsForExecutor: Partial<TargetConfiguration>;
#targetAndProjectsToMigrate: Map<string, Set<string>>;
#createNodes?: CreateNodes<T>;
#createNodesV2?: CreateNodes<T>;
#createNodesResultsForTargets: Map<string, ConfigurationResult>;
#skippedProjects: Set<string>;
constructor(
tree: Tree,
projectGraph: ProjectGraph,
executor: string,
pluginPath: string,
pluginOptionsBuilder: PluginOptionsBuilder<T>,
postTargetTransformer: PostTargetTransformer,
createNodes?: CreateNodes<T>,
createNodesV2?: CreateNodes<T>,
specificProjectToMigrate?: string,
filters?: {
skipProjectFilter?: SkipProjectFilter;
skipTargetFilter?: SkipTargetFilter;
},
logger?: typeof devkitLogger
) {
this.tree = tree;
this.#projectGraph = projectGraph;
this.#executor = executor;
this.#pluginPath = pluginPath;
this.#pluginOptionsBuilder = pluginOptionsBuilder;
this.#postTargetTransformer = postTargetTransformer;
this.#createNodes = createNodes;
this.#createNodesV2 = createNodesV2;
this.#specificProjectToMigrate = specificProjectToMigrate;
this.#skipProjectFilter =
filters?.skipProjectFilter ?? ((...args) => false);
this.#skipTargetFilter = filters?.skipTargetFilter ?? ((...args) => false);
this.#logger = logger ?? devkitLogger;
}
async run(): Promise<Map<string, Set<string>>> {
await this.#init();
if (this.#targetAndProjectsToMigrate.size > 0) {
for (const targetName of this.#targetAndProjectsToMigrate.keys()) {
await this.#migrateTarget(targetName);
}
}
return this.#targetAndProjectsToMigrate;
}
async #init() {
const nxJson = readNxJson(this.tree);
nxJson.plugins ??= [];
this.#nxJson = nxJson;
this.#targetAndProjectsToMigrate = new Map();
this.#createNodesResultsForTargets = new Map();
this.#skippedProjects = new Set();
this.#getTargetDefaultsForExecutor();
this.#getTargetAndProjectsToMigrate();
await this.#getCreateNodesResults();
}
async #migrateTarget(targetName: string) {
for (const projectName of this.#targetAndProjectsToMigrate.get(
targetName
)) {
await this.#migrateProject(projectName, targetName);
}
}
async #migrateProject(projectName: string, targetName: string) {
const projectFromGraph = this.#projectGraph.nodes[projectName];
const projectConfig = readProjectConfiguration(this.tree, projectName);
const createdTarget = this.#getCreatedTargetForProjectRoot(
targetName,
projectFromGraph.data.root
);
let projectTarget = projectConfig.targets[targetName];
projectTarget = mergeTargetConfigurations(
projectTarget,
this.#targetDefaultsForExecutor
);
delete projectTarget.executor;
deleteMatchingProperties(projectTarget, createdTarget);
if (projectTarget.inputs && createdTarget.inputs) {
this.#mergeInputs(projectTarget, createdTarget);
}
projectTarget = await this.#postTargetTransformer(
projectTarget,
this.tree,
{ projectName, root: projectFromGraph.data.root },
{ ...createdTarget, name: targetName }
);
if (
projectTarget.options &&
Object.keys(projectTarget.options).length === 0
) {
delete projectTarget.options;
}
if (Object.keys(projectTarget).length > 0) {
projectConfig.targets[targetName] = projectTarget;
} else {
delete projectConfig.targets[targetName];
}
if (!projectConfig['// targets']) {
projectConfig['// targets'] =
`to see all targets run: nx show project ${projectName} --web`;
}
updateProjectConfiguration(this.tree, projectName, projectConfig);
}
#mergeInputs(
target: TargetConfiguration,
inferredTarget: TargetConfiguration
) {
const isInputInferred = (input: string | InputDefinition) => {
return inferredTarget.inputs.some((inferredInput) => {
try {
deepStrictEqual(input, inferredInput);
return true;
} catch {
return false;
}
});
};
if (target.inputs.every(isInputInferred)) {
delete target.inputs;
return;
}
const inferredTargetExternalDependencyInput = inferredTarget.inputs.find(
(i): i is { externalDependencies: string[] } =>
typeof i !== 'string' && 'externalDependencies' in i
);
if (!inferredTargetExternalDependencyInput) {
// plugins should normally have an externalDependencies input, but if it
// doesn't, there's nothing to merge
return;
}
const targetExternalDependencyInput = target.inputs.find(
(i): i is { externalDependencies: string[] } =>
typeof i !== 'string' && 'externalDependencies' in i
);
if (!targetExternalDependencyInput) {
// the target doesn't have an externalDependencies input, so we can just
// add the inferred one
target.inputs.push(inferredTargetExternalDependencyInput);
} else {
// the target has an externalDependencies input, so we need to merge them
targetExternalDependencyInput.externalDependencies = Array.from(
new Set([
...targetExternalDependencyInput.externalDependencies,
...inferredTargetExternalDependencyInput.externalDependencies,
])
);
}
}
#getTargetAndProjectsToMigrate() {
forEachExecutorOptions(
this.tree,
this.#executor,
(
options: Record<string, unknown>,
projectName,
targetName,
configurationName
) => {
if (this.#skippedProjects.has(projectName) || configurationName) {
return;
}
if (
this.#specificProjectToMigrate &&
projectName !== this.#specificProjectToMigrate
) {
return;
}
const skipProjectReason = this.#skipProjectFilter(
this.#projectGraph.nodes[projectName].data
);
if (skipProjectReason) {
this.#skippedProjects.add(projectName);
const errorMsg = `The "${projectName}" project cannot be migrated. ${skipProjectReason}`;
if (this.#specificProjectToMigrate) {
throw new Error(errorMsg);
}
this.#logger.warn(errorMsg);
return;
}
const skipTargetReason = this.#skipTargetFilter(
options,
this.#projectGraph.nodes[projectName].data
);
if (skipTargetReason) {
const errorMsg = `The ${targetName} target on project "${projectName}" cannot be migrated. ${skipTargetReason}`;
if (this.#specificProjectToMigrate) {
throw new Error(errorMsg);
} else {
this.#logger.warn(errorMsg);
}
return;
}
if (this.#targetAndProjectsToMigrate.has(targetName)) {
this.#targetAndProjectsToMigrate.get(targetName).add(projectName);
} else {
this.#targetAndProjectsToMigrate.set(
targetName,
new Set([projectName])
);
}
}
);
}
#getTargetDefaultsForExecutor() {
this.#targetDefaultsForExecutor = structuredClone(
readTargetDefaultsForExecutor(
this.#executor,
this.#nxJson.targetDefaults
) ?? {}
);
}
#getCreatedTargetForProjectRoot(targetName: string, projectRoot: string) {
const entry = Object.entries(
this.#createNodesResultsForTargets.get(targetName)?.projects ?? {}
).find(([root]) => root === projectRoot);
if (!entry) {
throw new Error(
`The nx plugin did not find a project inside ${projectRoot}. File an issue at https://github.com/nrwl/nx with information about your project structure.`
);
}
const createdProject = entry[1];
const createdTarget: TargetConfiguration<RunCommandsOptions> =
structuredClone(createdProject.targets[targetName]);
delete createdTarget.command;
delete createdTarget.options?.cwd;
return createdTarget;
}
async #getCreateNodesResults() {
if (this.#targetAndProjectsToMigrate.size === 0) {
return;
}
global.NX_GRAPH_CREATION = true;
try {
for (const targetName of this.#targetAndProjectsToMigrate.keys()) {
const result = await getCreateNodesResultsForPlugin(
this.tree,
{
plugin: this.#pluginPath,
options: this.#pluginOptionsBuilder(targetName),
},
this.#pluginPath,
this.#createNodes,
this.#createNodesV2,
this.#nxJson
);
this.#createNodesResultsForTargets.set(targetName, result);
}
} finally {
global.NX_GRAPH_CREATION = false;
}
}
}
export class NoTargetsToMigrateError extends Error {
constructor() {
super('Could not find any targets to migrate.');
}
}
export function readTargetDefaultsForExecutor(
executor: string,
targetDefaults: NxJsonConfiguration['targetDefaults'] | undefined
): Partial<TargetConfiguration> | undefined {
// Preserve the legacy record-shape semantics this migrator used before
// array support: only an unfiltered default keyed directly by executor
// applies here. Target-scoped or filtered array entries remain opt-in
// behaviors for callers that can evaluate them in project context.
const entry = findTargetDefault(targetDefaults, { executor });
if (!entry) {
return undefined;
}
const config = { ...entry };
delete config.target;
delete config.executor;
delete config.projects;
delete config.plugin;
return config;
}
export async function migrateProjectExecutorsToPlugin<T>(
tree: Tree,
projectGraph: ProjectGraph,
pluginPath: string,
createNodesV2: CreateNodes<T>,
defaultPluginOptions: T,
migrations: Array<{
executors: string[];
targetPluginOptionMapper: (targetName: string) => Partial<T>;
postTargetTransformer: PostTargetTransformer;
skipProjectFilter?: SkipProjectFilter;
skipTargetFilter?: SkipTargetFilter;
}>,
specificProjectToMigrate?: string,
logger?: typeof devkitLogger
): Promise<Map<string, Record<string, string>>> {
const projects = await migrateProjects(
tree,
projectGraph,
pluginPath,
undefined,
createNodesV2,
defaultPluginOptions,
migrations,
specificProjectToMigrate,
logger
);
return projects;
}
export async function migrateProjectExecutorsToPluginV1<T>(
tree: Tree,
projectGraph: ProjectGraph,
pluginPath: string,
createNodes: CreateNodes<T>,
defaultPluginOptions: T,
migrations: Array<{
executors: string[];
targetPluginOptionMapper: (targetName: string) => Partial<T>;
postTargetTransformer: PostTargetTransformer;
skipProjectFilter?: SkipProjectFilter;
skipTargetFilter?: SkipTargetFilter;
}>,
specificProjectToMigrate?: string
): Promise<Map<string, Record<string, string>>> {
const projects = await migrateProjects(
tree,
projectGraph,
pluginPath,
createNodes,
undefined,
defaultPluginOptions,
migrations,
specificProjectToMigrate
);
return projects;
}
async function migrateProjects<T>(
tree: Tree,
projectGraph: ProjectGraph,
pluginPath: string,
createNodes: CreateNodes<T>,
createNodesV2: CreateNodes<T>,
defaultPluginOptions: T,
migrations: Array<{
executors: string[];
targetPluginOptionMapper: (targetName: string) => Partial<T>;
postTargetTransformer: PostTargetTransformer;
skipProjectFilter?: SkipProjectFilter;
skipTargetFilter?: SkipTargetFilter;
}>,
specificProjectToMigrate?: string,
logger?: typeof devkitLogger
): Promise<Map<string, Record<string, string>>> {
const projects = new Map<string, Record<string, string>>();
const spinner = globalSpinner.start(
`Calculating migration scope...`,
pluginPath
);
for (const migration of migrations) {
for (const executor of migration.executors) {
const migrator = new ExecutorToPluginMigrator(
tree,
projectGraph,
executor,
pluginPath,
migration.targetPluginOptionMapper,
migration.postTargetTransformer,
createNodes,
createNodesV2,
specificProjectToMigrate,
{
skipProjectFilter: migration.skipProjectFilter,
skipTargetFilter: migration.skipTargetFilter,
},
logger
);
const result = await migrator.run();
// invert the result to have a map of projects to their targets
for (const [target, projectList] of result.entries()) {
for (const project of projectList) {
if (!projects.has(project)) {
projects.set(project, {});
}
projects.set(project, {
...projects.get(project),
...migration.targetPluginOptionMapper(target),
});
}
}
}
}
// apply default options
for (const [project, pluginOptions] of projects.entries()) {
projects.set(project, {
...defaultPluginOptions,
...pluginOptions,
});
}
await addPluginRegistrations(
tree,
projects,
pluginPath,
createNodes,
createNodesV2,
defaultPluginOptions,
projectGraph,
spinner
);
spinner.succeed(`Migrated configuration for ${projects.size} project(s).\n`);
return projects;
}
async function addPluginRegistrations<T>(
tree: Tree,
projects: Map<string, Record<string, string>>,
pluginPath: string,
createNodes: CreateNodes | undefined,
createNodesV2: CreateNodes | undefined,
defaultPluginOptions: T,
projectGraph: ProjectGraph,
spinner: typeof globalSpinner
) {
const nxJson = readNxJson(tree);
// collect createNodes results for each project before adding the plugins
const createNodesResults = new Map<string, ConfigurationResult>();
global.NX_GRAPH_CREATION = true;
try {
let index = 0;
for (const [project, options] of projects.entries()) {
index++;
spinner.updateText(
`${index}/${projects.size} - Parsing "${project}" configuration...`
);
const projectConfigs = await getCreateNodesResultsForPlugin(
tree,
{ plugin: pluginPath, options },
pluginPath,
createNodes,
createNodesV2,
nxJson
);
createNodesResults.set(project, projectConfigs);
}
} finally {
global.NX_GRAPH_CREATION = false;
}
const arePluginIncludesRequired = async (
project: string,
pluginConfiguration: ExpandedPluginConfiguration
): Promise<boolean> => {
global.NX_GRAPH_CREATION = true;
let result: ConfigurationResult;
try {
result = await getCreateNodesResultsForPlugin(
tree,
pluginConfiguration,
pluginPath,
createNodes,
createNodesV2,
nxJson
);
} finally {
global.NX_GRAPH_CREATION = false;
}
const originalResults = createNodesResults.get(project);
return !deepEqual(originalResults, result);
};
let index = 0;
for (const [project, options] of projects.entries()) {
index++;
spinner.updateText(
`${index}/${projects.size} - Applying "${project}" configuration...`
);
const existingPlugin = nxJson.plugins?.find(
(plugin): plugin is ExpandedPluginConfiguration =>
typeof plugin !== 'string' &&
plugin.plugin === pluginPath &&
Object.keys(options).every(
(key) =>
plugin.options[key] === options[key] ||
(plugin.options[key] === undefined &&
options[key] === defaultPluginOptions[key])
)
);
const projectIncludeGlob =
projectGraph.nodes[project].data.root === '.'
? '*'
: join(projectGraph.nodes[project].data.root, '**/*');
if (!existingPlugin) {
nxJson.plugins ??= [];
const plugin: ExpandedPluginConfiguration = {
plugin: pluginPath,
options,
include: [projectIncludeGlob],
};
if (!(await arePluginIncludesRequired(project, plugin))) {
delete plugin.include;
}
nxJson.plugins.push(plugin);
} else if (existingPlugin.include) {
if (
!existingPlugin.include.some((include) =>
minimatch(projectIncludeGlob, include, { dot: true })
)
) {
existingPlugin.include.push(projectIncludeGlob);
if (!(await arePluginIncludesRequired(project, existingPlugin))) {
delete existingPlugin.include;
}
}
}
}
spinner.updateText(`Migrations done`);
updateNxJson(tree, nxJson);
}
async function getCreateNodesResultsForPlugin(
tree: Tree,
pluginConfiguration: ExpandedPluginConfiguration,
pluginPath: string,
createNodes: CreateNodes | undefined,
createNodesV2: CreateNodes | undefined,
nxJson: NxJsonConfiguration
): Promise<ConfigurationResult> {
let projectConfigs: ConfigurationResult;
try {
const plugin = new LoadedNxPlugin(
{ createNodes, createNodesV2, name: pluginPath },
pluginConfiguration
);
projectConfigs = await retrieveProjectConfigurations(
{ specifiedPlugins: [plugin], defaultPlugins: [] },
tree.root,
nxJson
);
} catch (e) {
if (e instanceof ProjectConfigurationsError) {
projectConfigs = e.partialProjectConfigurationsResult;
} else {
throw e;
}
}
return projectConfigs;
}
// Checks if two objects are structurely equal, without caring
// about the order of the keys.
function deepEqual<T extends Object>(a: T, b: T, logKey = ''): boolean {
const aKeys = Object.keys(a);
const bKeys = new Set(Object.keys(b));
if (aKeys.length !== bKeys.size) {
return false;
}
for (const key of aKeys) {
if (!bKeys.has(key)) {
return false;
}
if (typeof a[key] === 'object' && typeof b[key] === 'object') {
if (!deepEqual(a[key], b[key], logKey + '.' + key)) {
return false;
}
} else if (a[key] !== b[key]) {
return false;
}
}
return true;
}
@@ -0,0 +1,271 @@
import {
deleteMatchingProperties,
processTargetOutputs,
} from './plugin-migration-utils';
describe('Plugin Migration Utils', () => {
describe('deleteMatchingProperties', () => {
it('should delete properties that are identical between two different objects, leaving an empty object', () => {
// ARRANGE
const activeObject = {
foo: 1,
bar: 'myval',
baz: {
nested: {
key: 'val',
},
},
arr: ['string', 1],
};
const comparableObject = {
foo: 1,
bar: 'myval',
baz: {
nested: {
key: 'val',
},
},
arr: ['string', 1],
};
// ACT
deleteMatchingProperties(activeObject, comparableObject);
// ASSERT
expect(activeObject).toMatchInlineSnapshot(`{}`);
});
it('should delete properties that are identical between two different objects, leaving an object containing only the differences', () => {
// ARRANGE
const activeObject = {
foo: 1,
bar: 'myval',
baz: {
nested: {
key: 'differentValue',
},
},
arr: ['string', 2],
};
const comparableObject = {
foo: 1,
bar: 'myval',
baz: {
nested: {
key: 'val',
},
},
arr: ['string', 1],
};
// ACT
deleteMatchingProperties(activeObject, comparableObject);
// ASSERT
expect(activeObject).toMatchInlineSnapshot(`
{
"arr": [
"string",
2,
],
"baz": {
"nested": {
"key": "differentValue",
},
},
}
`);
});
});
describe('processTargetOutputs', () => {
it('should delete the target outputs when they all match the inferred task outputs', () => {
const target = {
outputs: [
'{workspaceRoot}/{options.outputFile}',
'{workspaceRoot}/{options.outputDir}',
],
};
const inferredTarget = {
outputs: [
'{projectRoot}/{options.outputFile}',
'{projectRoot}/{options.outputDir}',
],
};
processTargetOutputs(target, [], inferredTarget, {
projectName: 'proj1',
projectRoot: 'proj1',
});
expect(target.outputs).toBeUndefined();
});
it('should delete the target outputs when they are a subset of those of the inferred task', () => {
const target = {
outputs: ['{workspaceRoot}/{options.outputDir}'],
};
const inferredTarget = {
outputs: [
'{projectRoot}/{options.outputFile}',
'{projectRoot}/{options.outputDir}',
],
};
processTargetOutputs(target, [], inferredTarget, {
projectName: 'proj1',
projectRoot: 'proj1',
});
expect(target.outputs).toBeUndefined();
});
it('should update target outputs when it has any output that is not inferred', () => {
const target = {
outputs: [
'{workspaceRoot}/{options.outputFile}',
'{workspaceRoot}/{options.outputDir}',
],
};
const inferredTarget = {
outputs: ['{projectRoot}/{options.outputFile}'],
};
processTargetOutputs(target, [], inferredTarget, {
projectName: 'proj1',
projectRoot: 'proj1',
});
expect(target.outputs).toStrictEqual([
'{projectRoot}/{options.outputFile}',
'{projectRoot}/{options.outputDir}',
]);
});
it('should merge extra inferred outputs when it has any output that is not inferred', () => {
const target = {
outputs: [
'{workspaceRoot}/{options.outputDir}',
'{workspaceRoot}/some/other/output',
],
};
const inferredTarget = {
outputs: [
'{projectRoot}/{options.outputFile}',
'{projectRoot}/{options.outputDir}',
],
};
processTargetOutputs(target, [], inferredTarget, {
projectName: 'proj1',
projectRoot: 'proj1',
});
expect(target.outputs).toStrictEqual([
'{projectRoot}/{options.outputDir}',
'{workspaceRoot}/some/other/output',
'{projectRoot}/{options.outputFile}',
]);
});
describe('with renamed output options', () => {
it('should delete the target outputs when they all match the inferred task outputs', () => {
const target = {
outputs: [
'{workspaceRoot}/{options.outputFile}',
'{workspaceRoot}/{options.outputPath}',
],
};
const inferredTarget = {
outputs: [
'{projectRoot}/{options.outputFile}',
'{projectRoot}/{options.outputDir}',
],
};
processTargetOutputs(
target,
[{ newName: 'outputDir', oldName: 'outputPath' }],
inferredTarget,
{ projectName: 'proj1', projectRoot: 'proj1' }
);
expect(target.outputs).toBeUndefined();
});
it('should delete the target outputs when they are a subset of those of the inferred task', () => {
const target = {
outputs: ['{workspaceRoot}/{options.outputPath}'],
};
const inferredTarget = {
outputs: [
'{projectRoot}/{options.outputFile}',
'{projectRoot}/{options.outputDir}',
],
};
processTargetOutputs(
target,
[{ newName: 'outputDir', oldName: 'outputPath' }],
inferredTarget,
{ projectName: 'proj1', projectRoot: 'proj1' }
);
expect(target.outputs).toBeUndefined();
});
it('should update target outputs when it has any output that is not inferred', () => {
const target = {
outputs: [
'{workspaceRoot}/{options.outputFile}',
'{workspaceRoot}/{options.outputPath}',
],
};
const inferredTarget = {
outputs: ['{projectRoot}/{options.outputFile}'],
};
processTargetOutputs(
target,
[{ newName: 'outputDir', oldName: 'outputPath' }],
inferredTarget,
{ projectName: 'proj1', projectRoot: 'proj1' }
);
expect(target.outputs).toStrictEqual([
'{projectRoot}/{options.outputFile}',
'{projectRoot}/{options.outputDir}',
]);
});
it('should merge extra inferred outputs when it has any output that is not inferred', () => {
const target = {
outputs: [
'{workspaceRoot}/{options.outputPath}',
'{workspaceRoot}/some/other/output',
],
};
const inferredTarget = {
outputs: [
'{projectRoot}/{options.outputFile}',
'{projectRoot}/{options.outputDir}',
],
};
processTargetOutputs(
target,
[{ newName: 'outputDir', oldName: 'outputPath' }],
inferredTarget,
{ projectName: 'proj1', projectRoot: 'proj1' }
);
expect(target.outputs).toStrictEqual([
'{projectRoot}/{options.outputDir}',
'{workspaceRoot}/some/other/output',
'{projectRoot}/{options.outputFile}',
]);
});
});
});
});
@@ -0,0 +1,208 @@
import { relative, resolve } from 'node:path/posix';
import { workspaceRoot, type TargetConfiguration } from 'nx/src/devkit-exports';
import { interpolate } from 'nx/src/devkit-internals';
/**
* Iterate through the current target in the project.json and its options comparing it to the target created by the Plugin itself
* Delete matching properties from current target.
*
* _Note: Deletes by reference_
*
* @example
* // Run the plugin to get all the projects
* const { projects } = await createNodes[1](
* playwrightConfigPath,
* { targetName, ciTargetName: 'e2e-ci' },
* { workspaceRoot: tree.root, nxJsonConfiguration, configFiles }
* );
*
* // Find the project that matches the one that is being migrated
* const createdProject = Object.entries(projects ?? {}).find(
* ([root]) => root === projectFromGraph.data.root
* )[1];
*
* // Get the created TargetConfiguration for the target being migrated
* const createdTarget: TargetConfiguration<RunCommandsOptions> =
* createdProject.targets[targetName];
*
* // Delete specific run-commands options
* delete createdTarget.command;
* delete createdTarget.options?.cwd;
*
* // Get the TargetConfiguration for the target being migrated from project.json
* const projectConfig = readProjectConfiguration(tree, projectName);
* let targetToMigrate = projectConfig.targets[targetName];
*
* // Merge the target defaults for the executor to the target being migrated
* target = mergeTargetConfigurations(targetToMigrate, targetDefaultsForExecutor);
*
* // Delete executor and any additional options that are no longer necessary
* delete target.executor;
* delete target.options?.config;
*
* // Run deleteMatchingProperties to delete further options that match what the plugin creates
* deleteMatchingProperties(target, createdTarget);
*
* // Delete the target if it is now empty, otherwise, set it to the updated TargetConfiguration
* if (Object.keys(target).length > 0) {
* projectConfig.targets[targetName] = target;
* } else {
* delete projectConfig.targets[targetName];
* }
*
* updateProjectConfiguration(tree, projectName, projectConfig);
*
* @param targetToMigrate The target from project.json
* @param createdTarget The target created by the Plugin
*/
export function deleteMatchingProperties(
targetToMigrate: object,
createdTarget: object
): void {
for (const key in targetToMigrate) {
if (Array.isArray(targetToMigrate[key])) {
if (
targetToMigrate[key].every((v) => createdTarget[key]?.includes(v)) &&
targetToMigrate[key].length === createdTarget[key]?.length
) {
delete targetToMigrate[key];
}
} else if (
typeof targetToMigrate[key] === 'object' &&
typeof createdTarget[key] === 'object'
) {
deleteMatchingProperties(targetToMigrate[key], createdTarget[key]);
} else if (targetToMigrate[key] === createdTarget[key]) {
delete targetToMigrate[key];
}
if (
typeof targetToMigrate[key] === 'object' &&
Object.keys(targetToMigrate[key]).length === 0
) {
delete targetToMigrate[key];
}
}
}
export function processTargetOutputs(
target: TargetConfiguration,
renamedOutputOptions: Array<{ newName: string; oldName: string }>,
inferredTarget: TargetConfiguration,
projectDetails: { projectName: string; projectRoot: string }
): void {
const interpolatedInferredOutputs = (inferredTarget.outputs ?? []).map(
(output) =>
interpolate(output, {
workspaceRoot: '',
projectRoot: projectDetails.projectRoot,
projectName: projectDetails.projectName,
})
);
const targetOutputs = (target.outputs ?? []).map((output) =>
updateOutput(output, renamedOutputOptions)
);
const interpolatedOutputs = targetOutputs.map((output) =>
interpolate(output, {
workspaceRoot: '',
projectRoot: projectDetails.projectRoot,
projectName: projectDetails.projectName,
})
);
const shouldDelete = interpolatedOutputs.every((output) =>
interpolatedInferredOutputs.includes(output)
);
if (shouldDelete) {
// all existing outputs are already inferred
delete target.outputs;
return;
}
// move extra inferred outputs to the target outputs
for (let i = 0; i < interpolatedInferredOutputs.length; i++) {
if (!interpolatedOutputs.includes(interpolatedInferredOutputs[i])) {
targetOutputs.push(inferredTarget.outputs[i]);
interpolatedOutputs.push(interpolatedInferredOutputs[i]);
}
}
target.outputs = targetOutputs;
}
export function toProjectRelativePath(
path: string,
projectRoot: string
): string {
if (projectRoot === '.') {
// workspace and project root are the same, we add a leading './' which is
// required by some tools (e.g. Jest)
return path.startsWith('.') ? path : `./${path}`;
}
const relativePath = relative(
resolve(workspaceRoot, projectRoot),
resolve(workspaceRoot, path)
);
return relativePath.startsWith('.') ? relativePath : `./${relativePath}`;
}
function updateOutputRenamingOption(
output: string,
option: string,
previousName: string
): string {
const newOptionToken = `{options.${option}}`;
const oldOptionToken = `{options.${previousName}}`;
if (
!output.startsWith('{workspaceRoot}') &&
!output.startsWith('{projectRoot}')
) {
return `{projectRoot}/${output.replace(oldOptionToken, newOptionToken)}`;
}
if (
output.startsWith('{workspaceRoot}') &&
!output.startsWith('{workspaceRoot}/{projectRoot}')
) {
return output
.replace('{workspaceRoot}', '{projectRoot}')
.replace(oldOptionToken, newOptionToken);
}
return output.replace(oldOptionToken, newOptionToken);
}
function updateOutput(
output: string,
renamedOutputOptions: Array<{ newName: string; oldName: string }>
): string {
if (!/{options\..*}/.test(output)) {
// output does not contain any option tokens
return output;
}
for (const { newName, oldName } of renamedOutputOptions) {
const optionToken = `{options.${oldName}}`;
if (output.includes(optionToken)) {
return updateOutputRenamingOption(output, newName, oldName);
}
}
if (
!output.startsWith('{workspaceRoot}') &&
!output.startsWith('{projectRoot}')
) {
return `{projectRoot}/${output}`;
}
if (
output.startsWith('{workspaceRoot}') &&
!output.startsWith('{workspaceRoot}/{projectRoot}')
) {
return output.replace('{workspaceRoot}', '{projectRoot}');
}
return output;
}
@@ -0,0 +1,307 @@
import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace';
import type { Tree } from 'nx/src/generators/tree';
import { updateJson } from 'nx/src/generators/utils/json';
import { workspaceRoot } from 'nx/src/utils/workspace-root';
import { join } from 'path';
import { setCwd } from '../../internal-testing-utils';
import { determineProjectNameAndRootOptions } from './project-name-and-root-utils';
describe('determineProjectNameAndRootOptions', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
setCwd('');
jest.clearAllMocks();
});
it('should return the last part of the directory as name', async () => {
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'shared/lib-name',
projectType: 'library',
});
expect(result).toStrictEqual({
projectName: 'lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@proj/lib-name',
projectRoot: 'shared/lib-name',
});
});
it('should use "@" scoped directory as the project name and import path', async () => {
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'packages/@scope/lib-name',
projectType: 'library',
});
expect(result).toEqual({
projectName: '@scope/lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@scope/lib-name',
projectRoot: 'packages/@scope/lib-name',
});
});
it('should use "@" scoped directory as the project name and import path in deeply nested directory', async () => {
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'packages/shared/@scope/lib-name',
projectType: 'library',
});
expect(result).toEqual({
projectName: '@scope/lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@scope/lib-name',
projectRoot: 'packages/shared/@scope/lib-name',
});
});
it('should handle Windows path correctly', async () => {
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'shared\\lib-name',
projectType: 'library',
});
expect(result).toStrictEqual({
projectName: 'lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@proj/lib-name',
projectRoot: 'shared/lib-name',
});
});
it('should use provided import path over scoped name', async () => {
const result = await determineProjectNameAndRootOptions(tree, {
name: '@scope/lib-name',
directory: 'shared',
projectType: 'library',
importPath: '@custom-scope/lib-name',
});
expect(result).toEqual({
projectName: '@scope/lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@custom-scope/lib-name',
projectRoot: 'shared',
});
});
it('should append the directory to the cwd when the provided directory does not start with the cwd and format is "as-provided"', async () => {
// simulate running in a subdirectory
const originalInitCwd = process.env.INIT_CWD;
process.env.INIT_CWD = join(workspaceRoot, 'some/path');
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'nested/lib-name',
projectType: 'library',
});
expect(result).toEqual({
projectName: 'lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@proj/lib-name',
projectRoot: 'some/path/nested/lib-name',
});
// restore original cwd
if (originalInitCwd === undefined) {
delete process.env.INIT_CWD;
} else {
process.env.INIT_CWD = originalInitCwd;
}
});
it('should not duplicate the cwd when the provided directory starts with the cwd and format is "as-provided"', async () => {
// simulate running in a subdirectory
const originalInitCwd = process.env.INIT_CWD;
process.env.INIT_CWD = join(workspaceRoot, 'some/path');
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'some/path/nested/lib-name',
projectType: 'library',
});
expect(result).toEqual({
projectName: 'lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@proj/lib-name',
projectRoot: 'some/path/nested/lib-name',
});
// restore original cwd
if (originalInitCwd === undefined) {
delete process.env.INIT_CWD;
} else {
process.env.INIT_CWD = originalInitCwd;
}
});
it('should return the directory considering the cwd', async () => {
// simulate running in a subdirectory
const originalInitCwd = process.env.INIT_CWD;
process.env.INIT_CWD = join(workspaceRoot, 'some/path');
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'lib-name',
projectType: 'library',
});
expect(result).toEqual({
projectName: 'lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@proj/lib-name',
projectRoot: 'some/path/lib-name',
});
// restore original cwd
if (originalInitCwd === undefined) {
delete process.env.INIT_CWD;
} else {
process.env.INIT_CWD = originalInitCwd;
}
});
it('should support a directory outside of the cwd', async () => {
// simulate running in a subdirectory
const originalInitCwd = process.env.INIT_CWD;
process.env.INIT_CWD = join(workspaceRoot, 'some/path');
const result = await determineProjectNameAndRootOptions(tree, {
directory: '../../libs/lib-name',
projectType: 'library',
});
expect(result).toEqual({
projectName: 'lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: '@proj/lib-name',
projectRoot: 'libs/lib-name',
});
// restore original cwd
if (originalInitCwd === undefined) {
delete process.env.INIT_CWD;
} else {
process.env.INIT_CWD = originalInitCwd;
}
});
it('should throw when the resolved directory is outside of the workspace root', async () => {
// simulate running in a subdirectory
const originalInitCwd = process.env.INIT_CWD;
process.env.INIT_CWD = join(workspaceRoot, 'some/path');
await expect(
determineProjectNameAndRootOptions(tree, {
directory: '../../../libs/lib-name',
projectType: 'library',
})
).rejects.toThrow(/is outside of the workspace root/);
// restore original cwd
if (originalInitCwd === undefined) {
delete process.env.INIT_CWD;
} else {
process.env.INIT_CWD = originalInitCwd;
}
});
it('should return the project name and directory as provided for root projects', async () => {
updateJson(tree, 'package.json', (json) => {
json.name = 'lib-name';
return json;
});
const result = await determineProjectNameAndRootOptions(tree, {
name: 'lib-name',
directory: '.',
projectType: 'library',
rootProject: true,
});
expect(result).toEqual({
projectName: 'lib-name',
names: {
projectSimpleName: 'lib-name',
projectFileName: 'lib-name',
},
importPath: 'lib-name',
projectRoot: '.',
});
});
it('should throw when a name is not provided for a root project', async () => {
await expect(
determineProjectNameAndRootOptions(tree, {
directory: '.',
projectType: 'library',
})
).rejects.toThrow(/you must also specify the name option/);
});
it('should throw when a directory is provided where the derived name is invalid', async () => {
await expect(
determineProjectNameAndRootOptions(tree, {
directory: '@scope/lib-name/invalid-extra-segment',
projectType: 'library',
})
).rejects.toThrow(/directory should match/);
});
it('should throw when an invalid name is provided', async () => {
await expect(
determineProjectNameAndRootOptions(tree, {
name: '!scope/lib-name',
directory: 'shared',
projectType: 'library',
})
).rejects.toThrow(/name should match/);
});
it('should handle providing a path including multiple "@" as the project name', async () => {
const result = await determineProjectNameAndRootOptions(tree, {
directory: 'shared/@foo/@scope/libName',
projectType: 'library',
});
expect(result).toEqual({
projectName: '@scope/libName',
names: {
projectSimpleName: 'libName',
projectFileName: 'libName',
},
importPath: '@scope/libName',
projectRoot: 'shared/@foo/@scope/libName',
});
});
});
@@ -0,0 +1,209 @@
import { prompt } from 'enquirer';
import {
joinPathFragments,
normalizePath,
type ProjectType,
readJson,
type Tree,
workspaceRoot,
} from 'nx/src/devkit-exports';
import { relative } from 'path';
export type ProjectGenerationOptions = {
directory: string;
name?: string;
projectType: ProjectType;
importPath?: string;
rootProject?: boolean;
};
export type ProjectNameAndRootOptions = {
/**
* Normalized full project name, including scope if name was provided with
* scope (e.g., `@scope/name`)
*/
projectName: string;
/**
* Normalized project root, including the layout directory if configured.
*/
projectRoot: string;
names: {
/**
* Normalized project name without scope. It's meant to be used when
* generating file names that contain the project name.
*/
projectFileName: string;
/**
* Normalized project name without scope or directory. It's meant to be used
* when generating shorter file names that contain the project name.
*/
projectSimpleName: string;
};
/**
* Normalized import path for the project.
*/
importPath: string;
};
export async function determineProjectNameAndRootOptions(
tree: Tree,
options: ProjectGenerationOptions
): Promise<ProjectNameAndRootOptions> {
// root projects must provide name option
if (options.directory === '.' && !options.name) {
throw new Error(
`When generating a root project, you must also specify the name option.`
);
}
const directory = normalizePath(options.directory);
const name =
options.name ??
directory.match(/(@[^@/]+(\/[^@/]+)+)/)?.[1] ??
directory.substring(directory.lastIndexOf('/') + 1);
validateOptions(options.name, name, options.directory);
let projectSimpleName: string;
let projectFileName: string;
if (name.startsWith('@')) {
const [_scope, ...rest] = name.split('/');
projectFileName = rest.join('-');
projectSimpleName = rest.pop();
} else {
projectSimpleName = name;
projectFileName = name;
}
let projectRoot: string;
const relativeCwd = getRelativeCwd();
if (directory) {
// append the directory to the current working directory if it doesn't start with it
if (directory === relativeCwd || directory.startsWith(`${relativeCwd}/`)) {
projectRoot = directory;
} else {
projectRoot = joinPathFragments(relativeCwd, directory);
}
} else if (options.rootProject) {
projectRoot = '.';
} else {
projectRoot = relativeCwd;
// append the project name to the current working directory if it doesn't end with it
if (!relativeCwd.endsWith(name)) {
projectRoot = joinPathFragments(relativeCwd, name);
}
}
if (projectRoot.startsWith('..')) {
throw new Error(
`The resolved project root "${projectRoot}" is outside of the workspace root "${workspaceRoot}".`
);
}
const importPath =
options.importPath ?? resolveImportPath(tree, name, projectRoot);
return {
projectName: name,
names: {
projectSimpleName,
projectFileName,
},
importPath,
projectRoot,
};
}
export function resolveImportPath(
tree: Tree,
projectName: string,
projectRoot: string
): string {
let importPath: string;
if (projectName.startsWith('@')) {
importPath = projectName;
} else {
const npmScope = getNpmScope(tree);
importPath =
projectRoot === '.'
? (readJson<{ name?: string }>(tree, 'package.json').name ??
getImportPath(npmScope, projectName))
: getImportPath(npmScope, projectName);
}
return importPath;
}
export async function ensureRootProjectName(
options: { directory: string; name?: string },
projectType: 'application' | 'library'
): Promise<void> {
if (!options.name && options.directory === '.' && getRelativeCwd() === '') {
const result = await prompt<{ name: string }>({
type: 'input',
name: 'name',
message: `What do you want to name the ${projectType}?`,
});
options.name = result.name;
}
}
function validateOptions(
providedName: string,
derivedName: string,
directory: string
): void {
/**
* The provided name and the derived name from the provided directory must match one of two cases:
*
* 1. Valid npm package names (e.g., '@scope/name' or 'name').
* 2. Names starting with a letter and can contain any character except whitespace and ':'.
*
* The second case is to support the legacy behavior (^[a-zA-Z].*$) with the difference
* that it doesn't allow the ":" character. It was wrong to allow it because it would
* conflict with the notation for tasks.
*/
const pattern =
'(?:^@[a-zA-Z0-9-*~][a-zA-Z0-9-*._~]*\\/[a-zA-Z0-9-~][a-zA-Z0-9-._~]*|^[a-zA-Z][^:]*)$';
const validationRegex = new RegExp(pattern);
if (providedName) {
if (!validationRegex.test(providedName)) {
throw new Error(
`The name should match the pattern "${pattern}". The provided value "${providedName}" does not match.`
);
}
} else if (!validationRegex.test(derivedName)) {
throw new Error(
`The derived name from the provided directory should match the pattern "${pattern}". The derived name "${derivedName}" from the provided value "${directory}" does not match.`
);
}
}
function getImportPath(npmScope: string | undefined, name: string) {
return npmScope ? `${npmScope === '@' ? '' : '@'}${npmScope}/${name}` : name;
}
function getNpmScope(tree: Tree): string | undefined {
const { name } = tree.exists('package.json')
? readJson<{ name?: string }>(tree, 'package.json')
: { name: null };
return name?.startsWith('@') ? name.split('/')[0].substring(1) : undefined;
}
/**
* When running a script with the package manager (e.g. `npm run`), the package manager will
* traverse the directory tree upwards until it finds a `package.json` and will set `process.cwd()`
* to the folder where it found it. The actual working directory is stored in the INIT_CWD
* environment variable (see here: https://docs.npmjs.com/cli/v9/commands/npm-run-script#description).
*/
function getCwd(): string {
return process.env.INIT_CWD?.startsWith(workspaceRoot)
? process.env.INIT_CWD
: process.cwd();
}
function getRelativeCwd(): string {
return normalizePath(relative(workspaceRoot, getCwd())).replace(/\/$/, '');
}
+19
View File
@@ -0,0 +1,19 @@
import { prompt } from 'enquirer';
import { isCI } from 'nx/src/devkit-internals';
export async function promptWhenInteractive<T>(
questions: Parameters<typeof prompt>[0],
defaultValue: T
): Promise<T> {
if (!isInteractive()) {
return defaultValue;
}
return await prompt(questions);
}
function isInteractive(): boolean {
return (
!isCI() && !!process.stdout.isTTY && process.env.NX_INTERACTIVE === 'true'
);
}
@@ -0,0 +1,16 @@
import type { GeneratorCallback } from 'nx/src/devkit-exports';
/**
* Run tasks in serial
*
* @param tasks The tasks to run in serial.
*/
export function runTasksInSerial(
...tasks: GeneratorCallback[]
): GeneratorCallback {
return async () => {
for (const task of tasks) {
await task();
}
};
}
@@ -0,0 +1,617 @@
// Stub out @nx/cypress/plugin so the dynamic `await import('@nx/cypress/plugin')`
// in addE2eCiTargetDefaults doesn't pull real plugin code (which transitively
// imports @nx/js source and inflates sandbox inputs).
jest.mock(
'@nx/cypress/plugin',
() => ({
createNodesV2: ['**/cypress.config.{js,ts,mjs,cjs}', jest.fn()],
}),
{ virtual: true }
);
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
import {
addProjectConfiguration,
readNxJson,
updateNxJson,
type NxJsonConfiguration,
type Tree,
} from 'nx/src/devkit-exports';
import {
addBuildTargetDefaults,
findTargetDefault,
updateTargetDefault,
upsertTargetDefault,
} from './target-defaults-utils';
describe('target-defaults-utils', () => {
describe('upsertTargetDefault', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('writes the object value form when nx.json has no targetDefaults', () => {
const nxJson = readNxJson(tree);
delete nxJson.targetDefaults;
upsertTargetDefault(tree, nxJson, { target: 'test', cache: true });
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: { cache: true },
});
});
it('merges into an existing object value (config wins)', () => {
const nxJson = readNxJson(tree);
nxJson.targetDefaults = { test: { cache: true } };
upsertTargetDefault(tree, nxJson, {
target: 'test',
inputs: ['default'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: { cache: true, inputs: ['default'] },
});
});
it('writes an executor-only entry under an executor key', () => {
const nxJson = readNxJson(tree);
delete nxJson.targetDefaults;
upsertTargetDefault(tree, nxJson, {
executor: '@nx/js:tsc',
cache: true,
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
'@nx/js:tsc': { cache: true },
});
});
it('merges a context filter with no matching entry into the generic (never authors a filtered entry)', () => {
const nxJson = readNxJson(tree);
nxJson.targetDefaults = { test: { cache: false } };
upsertTargetDefault(tree, nxJson, {
target: 'test',
plugin: '@nx/vite',
inputs: ['vite.config.ts'],
});
updateNxJson(tree, nxJson);
// No entry matches the `@nx/vite` context, so the config merges into the
// generic; upsert never writes a new filtered entry.
expect(readNxJson(tree).targetDefaults).toEqual({
test: { cache: false, inputs: ['vite.config.ts'] },
});
});
it('merges into an existing entry with the same filter', () => {
const nxJson = readNxJson(tree);
nxJson.targetDefaults = {
test: [{ filter: { plugin: '@nx/vite' }, cache: true }],
};
upsertTargetDefault(tree, nxJson, {
target: 'test',
plugin: '@nx/vite',
inputs: ['vite.config.ts'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: [
{
filter: { plugin: '@nx/vite' },
cache: true,
inputs: ['vite.config.ts'],
},
],
});
});
it('appends a catch-all to an existing array when upserting unfiltered', () => {
const nxJson = readNxJson(tree);
nxJson.targetDefaults = {
test: [{ filter: { plugin: '@nx/vite' }, cache: true }],
};
upsertTargetDefault(tree, nxJson, {
target: 'test',
inputs: ['default'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: [
{ filter: { plugin: '@nx/vite' }, cache: true },
{ inputs: ['default'] },
],
});
});
it('updates the matching filtered entry (not the generic) when the context filter matches', () => {
const nxJson = readNxJson(tree);
nxJson.targetDefaults = {
test: [
{ cache: true },
{ filter: { plugin: '@nx/vite' }, inputs: ['old'] },
],
};
upsertTargetDefault(tree, nxJson, {
target: 'test',
plugin: '@nx/vite',
inputs: ['vite.config.ts'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: [
{ cache: true },
{ filter: { plugin: '@nx/vite' }, inputs: ['vite.config.ts'] },
],
});
});
it('updates the generic when the context filter matches no existing entry', () => {
const nxJson = readNxJson(tree);
nxJson.targetDefaults = {
test: [
{ cache: true },
{ filter: { plugin: '@nx/vite' }, inputs: ['vite'] },
],
};
upsertTargetDefault(tree, nxJson, {
target: 'test',
plugin: '@nx/jest',
inputs: ['jest'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: [
{ cache: true, inputs: ['jest'] },
{ filter: { plugin: '@nx/vite' }, inputs: ['vite'] },
],
});
});
it('updates a `projects` filtered entry that covers the configured project (by tag)', () => {
addProjectConfiguration(tree, 'app-a', {
root: 'apps/app-a',
tags: ['unit'],
});
const nxJson = readNxJson(tree);
nxJson.targetDefaults = {
test: [
{ cache: true },
{ filter: { projects: ['tag:unit'] }, inputs: ['old'] },
],
};
// Configuring `app-a` (which has `tag:unit`) — the filtered entry covers
// it, so it is the one updated.
upsertTargetDefault(tree, nxJson, {
target: 'test',
projects: ['app-a'],
inputs: ['new'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: [
{ cache: true },
{ filter: { projects: ['tag:unit'] }, inputs: ['new'] },
],
});
});
it('updates the generic when no `projects` filtered entry covers the configured project', () => {
addProjectConfiguration(tree, 'app-b', {
root: 'apps/app-b',
tags: ['e2e'],
});
const nxJson = readNxJson(tree);
nxJson.targetDefaults = {
test: [
{ cache: true },
{ filter: { projects: ['tag:unit'] }, inputs: ['old'] },
],
};
// `app-b` lacks `tag:unit`, so the filtered entry does not cover it.
upsertTargetDefault(tree, nxJson, {
target: 'test',
projects: ['app-b'],
inputs: ['new'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: [
{ cache: true, inputs: ['new'] },
{ filter: { projects: ['tag:unit'] }, inputs: ['old'] },
],
});
});
it('downgrades a single unfiltered array entry back to the object form', () => {
const nxJson = readNxJson(tree);
// A hand-authored single-element array with no filter is equivalent to
// the plain object form; merging into it should collapse it back.
nxJson.targetDefaults = { test: [{ cache: true }] };
upsertTargetDefault(tree, nxJson, {
target: 'test',
inputs: ['default'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: { cache: true, inputs: ['default'] },
});
});
it('keeps an executor used with a target as a config field (object form)', () => {
const nxJson = readNxJson(tree);
delete nxJson.targetDefaults;
upsertTargetDefault(tree, nxJson, {
target: 'test',
executor: '@nx/jest:jest',
inputs: ['jest.config.ts'],
});
updateNxJson(tree, nxJson);
expect(readNxJson(tree).targetDefaults).toEqual({
test: { executor: '@nx/jest:jest', inputs: ['jest.config.ts'] },
});
});
it('throws when neither target nor executor is set', () => {
const nxJson = readNxJson(tree);
expect(() =>
upsertTargetDefault(tree, nxJson, { cache: true } as any)
).toThrow(/at least one of `target` or `executor`/);
});
});
describe('findTargetDefault', () => {
it('finds an unfiltered entry by target across the object form', () => {
expect(
findTargetDefault(
{ build: { cache: true }, test: { cache: false } },
{ target: 'build' }
)
).toEqual({ target: 'build', cache: true });
});
it('finds a filtered entry inside an array value', () => {
expect(
findTargetDefault(
{
test: [
{ cache: false },
{ filter: { plugin: '@nx/vite' }, inputs: ['vite.config.ts'] },
],
},
{ target: 'test', plugin: '@nx/vite' }
)
).toEqual({
target: 'test',
plugin: '@nx/vite',
inputs: ['vite.config.ts'],
});
});
it('returns undefined when no entry matches the locator', () => {
expect(
findTargetDefault({ build: { cache: true } }, { target: 'test' })
).toBeUndefined();
});
it('throws on an empty locator', () => {
expect(() => findTargetDefault({ build: { cache: true } }, {})).toThrow(
/at least one of/
);
});
});
describe('addBuildTargetDefaults', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('adds an executor-keyed cache default', () => {
const nxJson = readNxJson(tree);
delete nxJson.targetDefaults;
updateNxJson(tree, nxJson);
addBuildTargetDefaults(tree, '@nx/esbuild:esbuild');
expect(readNxJson(tree).targetDefaults).toEqual({
'@nx/esbuild:esbuild': {
cache: true,
dependsOn: ['^build'],
inputs: ['default', '^default'],
},
});
});
it('does not duplicate an existing unfiltered executor default', () => {
const nxJson = readNxJson(tree);
nxJson.targetDefaults = { '@nx/esbuild:esbuild': { cache: true } };
updateNxJson(tree, nxJson);
addBuildTargetDefaults(tree, '@nx/esbuild:esbuild');
expect(readNxJson(tree).targetDefaults).toEqual({
'@nx/esbuild:esbuild': { cache: true },
});
});
});
describe('updateTargetDefault', () => {
// Strips the bad option, mirroring the jest tsConfig-removal migration.
const removeTsConfig = (config: { options?: Record<string, unknown> }) => {
if (config.options) {
delete config.options.tsConfig;
if (Object.keys(config.options).length === 0) delete config.options;
}
};
it('runs the callback for object, executor-key, field, and filter forms that reference the executor', () => {
const nxJson: NxJsonConfiguration = {
targetDefaults: {
// target-key with the executor as a config field
build: {
executor: '@nx/jest:jest',
options: { tsConfig: 'x', a: 1 },
},
// executor-key form
'@nx/jest:jest': { options: { tsConfig: 'y', b: 2 } },
test: [
// target-key with the executor in the filter
{
filter: { executor: '@nx/jest:jest' },
options: { tsConfig: 'z', c: 3 },
},
// unrelated entry — untouched
{ options: { keep: true } },
],
// unrelated key — untouched
lint: { options: { tsConfig: 'should-stay' } },
},
};
updateTargetDefault(
nxJson,
{ executor: '@nx/jest:jest' },
removeTsConfig
);
expect(nxJson.targetDefaults).toEqual({
build: { executor: '@nx/jest:jest', options: { a: 1 } },
'@nx/jest:jest': { options: { b: 2 } },
test: [
{ filter: { executor: '@nx/jest:jest' }, options: { c: 3 } },
{ options: { keep: true } },
],
lint: { options: { tsConfig: 'should-stay' } },
});
});
it('passes the right info (target/executor/filter) per entry form', () => {
const seen: Array<Record<string, unknown>> = [];
const nxJson: NxJsonConfiguration = {
targetDefaults: {
build: { executor: '@nx/jest:jest', cache: true },
'@nx/jest:jest': { cache: true },
test: [{ filter: { executor: '@nx/jest:jest' }, cache: true }],
},
};
updateTargetDefault(
nxJson,
{ executor: '@nx/jest:jest' },
(_config, info) => {
seen.push({
key: info.key,
target: info.target,
executor: info.executor,
filter: info.filter,
});
}
);
expect(seen).toEqual([
{
key: 'build',
target: 'build',
executor: '@nx/jest:jest',
filter: undefined,
},
{
key: '@nx/jest:jest',
target: undefined,
executor: '@nx/jest:jest',
filter: undefined,
},
{
key: 'test',
target: 'test',
executor: '@nx/jest:jest',
filter: { executor: '@nx/jest:jest' },
},
]);
});
it('drops an array entry whose callback returns null and collapses to the object form', () => {
const nxJson: NxJsonConfiguration = {
targetDefaults: {
test: [
{
filter: { executor: '@nx/jest:jest' },
options: { tsConfig: 'z' },
},
{ cache: true },
],
},
};
updateTargetDefault(nxJson, { executor: '@nx/jest:jest' }, () => null);
// The filtered jest entry is dropped; the lone unfiltered entry collapses
// back to the plain object form.
expect(nxJson.targetDefaults).toEqual({ test: { cache: true } });
});
it('deletes the key when an object-form value callback returns null', () => {
const nxJson: NxJsonConfiguration = {
targetDefaults: {
'@nx/jest:jest': { options: { tsConfig: 'z' } },
build: { cache: true },
},
};
updateTargetDefault(nxJson, { executor: '@nx/jest:jest' }, () => null);
expect(nxJson.targetDefaults).toEqual({ build: { cache: true } });
});
it('removes targetDefaults entirely when the last key is deleted', () => {
const nxJson: NxJsonConfiguration = {
targetDefaults: { '@nx/jest:jest': { options: { tsConfig: 'z' } } },
};
updateTargetDefault(nxJson, { executor: '@nx/jest:jest' }, () => null);
expect(nxJson.targetDefaults).toBeUndefined();
});
it('replaces the entry payload when the callback returns a new config', () => {
const nxJson: NxJsonConfiguration = {
targetDefaults: { '@nx/jest:jest': { cache: true } },
};
updateTargetDefault(nxJson, { executor: '@nx/jest:jest' }, (config) => ({
...config,
cache: false,
inputs: ['production'],
}));
expect(nxJson.targetDefaults).toEqual({
'@nx/jest:jest': { cache: false, inputs: ['production'] },
});
});
it('narrows to project-filtered entries that cover the scoped projects', () => {
const nxJson: NxJsonConfiguration = {
targetDefaults: {
build: [
{ filter: { projects: ['app-a'] }, options: { a: 1 } },
{ filter: { projects: ['tag:unit'] }, options: { b: 2 } },
{ filter: { projects: ['other'] }, options: { c: 3 } },
{ options: { d: 4 } },
],
},
};
updateTargetDefault(
nxJson,
{
projects: {
'app-a': { data: { root: 'apps/app-a', tags: ['unit'] } },
},
},
(config) => {
(config.options as Record<string, unknown>).touched = true;
}
);
// app-a is matched by name and by `tag:unit`, and the unfiltered entry
// always applies; the `other`-scoped entry is left alone.
expect(nxJson.targetDefaults).toEqual({
build: [
{ filter: { projects: ['app-a'] }, options: { a: 1, touched: true } },
{
filter: { projects: ['tag:unit'] },
options: { b: 2, touched: true },
},
{ filter: { projects: ['other'] }, options: { c: 3 } },
{ options: { d: 4, touched: true } },
],
});
});
it('combines executor selection with project narrowing', () => {
const nxJson: NxJsonConfiguration = {
targetDefaults: {
test: [
{
filter: { executor: '@nx/jest:jest', projects: ['app-a'] },
options: { a: 1 },
},
{
filter: { executor: '@nx/jest:jest', projects: ['other'] },
options: { b: 2 },
},
{ filter: { executor: '@nx/jest:jest' }, options: { c: 3 } },
{ filter: { executor: '@nx/vite:test' }, options: { d: 4 } },
],
},
};
updateTargetDefault(
nxJson,
{
executor: '@nx/jest:jest',
projects: { 'app-a': { data: { root: 'apps/app-a' } } },
},
(config) => {
(config.options as Record<string, unknown>).touched = true;
}
);
expect(nxJson.targetDefaults).toEqual({
test: [
{
filter: { executor: '@nx/jest:jest', projects: ['app-a'] },
options: { a: 1, touched: true },
},
{
filter: { executor: '@nx/jest:jest', projects: ['other'] },
options: { b: 2 },
},
{
filter: { executor: '@nx/jest:jest' },
options: { c: 3, touched: true },
},
{ filter: { executor: '@nx/vite:test' }, options: { d: 4 } },
],
});
});
it('throws when neither executor nor projects is provided', () => {
expect(() =>
updateTargetDefault({ targetDefaults: {} }, {}, () => null)
).toThrow(/at least one of `executor` or `projects`/);
});
it('is a no-op when nx.json has no targetDefaults', () => {
const nxJson: NxJsonConfiguration = {};
expect(
updateTargetDefault(nxJson, { executor: '@nx/jest:jest' }, () => null)
).toBe(nxJson);
expect(nxJson.targetDefaults).toBeUndefined();
});
});
});
@@ -0,0 +1,613 @@
import {
type CreateNodes,
type NxJsonConfiguration,
type PluginConfiguration,
type TargetConfiguration,
type TargetDefaultArrayEntry,
type TargetDefaultEntry,
type TargetDefaultFilter,
type TargetDefaults,
type TargetDefaultValue,
type Tree,
getProjects,
readNxJson,
updateNxJson,
} from 'nx/src/devkit-exports';
import {
findMatchingConfigFiles,
findMatchingProjects,
readTargetDefaultsForTarget as readTargetDefaultsForTargetFromNx,
} from 'nx/src/devkit-internals';
import { minimatch } from 'minimatch';
/**
* Upsert a `targetDefaults` entry on the provided `nxJson`. Mutates
* `nxJson` in place and returns it so the caller can chain or batch
* other edits before persisting via `updateNxJson` exactly once.
*
* The input is the logical {@link TargetDefaultEntry} shape
* (`{ target?, executor?, projects?, plugin?, ...config }`). The `projects` /
* `plugin` filter is *context* describing what the caller is configuring, used
* to pick which existing entry to merge into — it is never authored as a new
* filtered entry. For the key (`target` ?? `executor`):
*
* - No value yet → create the generic (plain object) value.
* - Only a generic value → merge into it.
* - A filtered entry matching the context filter exists → merge into that one
* (so a caller updating a project the user already scoped a default for edits
* that scoped entry rather than clobbering the workspace baseline).
* - Otherwise → merge into the generic, creating one if needed.
*
* A lone unfiltered entry is always stored as a plain object, never a
* single-element array. The entry must set at least one of `target` /
* `executor`.
*/
export function upsertTargetDefault(
tree: Tree,
nxJson: NxJsonConfiguration,
options: TargetDefaultEntry
): NxJsonConfiguration {
const { target, executor, projects, plugin, ...config } = options;
const key = target ?? executor;
if (key === undefined) {
throw new Error(
'upsertTargetDefault requires at least one of `target` or `executor` to be set.'
);
}
// `executor` is the map key itself when no `target` is given; when a `target`
// is present it is a configuration field (a default executor) written onto
// the value.
const valueConfig: Partial<TargetConfiguration> =
target !== undefined && executor !== undefined
? { executor, ...config }
: config;
const targetDefaults = (nxJson.targetDefaults ??= {});
targetDefaults[key] = collapse(
upsertValue(targetDefaults[key], valueConfig, {
tree,
projects,
plugin,
executor,
})
);
return nxJson;
}
// What the caller is configuring, used to pick which existing entry to update.
interface UpsertContext {
tree: Tree;
projects: string[] | undefined;
plugin: string | undefined;
executor: string | undefined;
}
/**
* Merge `config` into the right entry of a key's value, per the algorithm
* documented on {@link upsertTargetDefault}. Returns the new value (always an
* array here; {@link collapse} downgrades a lone unfiltered one to an object).
*/
function upsertValue(
existing: TargetDefaultValue | undefined,
config: Partial<TargetConfiguration>,
context: UpsertContext
): TargetDefaultValue {
// No value yet → create the generic.
if (existing === undefined) {
return { ...config };
}
const entries: TargetDefaultArrayEntry[] = Array.isArray(existing)
? [...existing]
: [existing];
// A filtered entry that covers what's being configured → merge into it,
// preserving its filter.
const matchIdx = entries.findIndex(
(e) => e.filter !== undefined && filterCoversContext(e.filter, context)
);
if (matchIdx >= 0) {
entries[matchIdx] = { ...entries[matchIdx], ...config };
return entries;
}
// Otherwise merge into the generic (catch-all) entry, creating one if absent.
const genericIdx = entries.findIndex((e) => e.filter === undefined);
if (genericIdx >= 0) {
const { filter: _filter, ...rest } = entries[genericIdx];
entries[genericIdx] = { ...rest, ...config };
} else {
entries.push({ ...config });
}
return entries;
}
/**
* Whether an entry's `filter` applies to what the caller is configuring:
* `plugin` / `executor` must match exactly, and `projects` is evaluated as
* *coverage* — every configured project must match the filter's patterns
* (names, globs, `tag:`, directories, negation) via `findMatchingProjects`,
* resolved against each project's tags/root in the tree. A `projects` filter
* can't be confirmed when no project context is supplied (or the project
* isn't in the tree), so it falls through to the generic.
*/
function filterCoversContext(
filter: NonNullable<TargetDefaultArrayEntry['filter']>,
context: UpsertContext
): boolean {
if (filter.plugin !== undefined && filter.plugin !== context.plugin) {
return false;
}
if (filter.executor !== undefined && filter.executor !== context.executor) {
return false;
}
if (filter.projects !== undefined) {
const configured = context.projects ?? [];
const nodes = contextProjectNodes(context.tree, configured);
if (!projectsFilterCovers(filter.projects, nodes, configured)) {
return false;
}
}
return true;
}
/**
* Whether a `filter.projects` pattern set covers *every* required project name:
* each must be returned by `findMatchingProjects` against `nodes`. An empty
* `requiredNames` can never be "covered", so the entry falls through to the
* generic baseline. Shared by the upsert and update paths so the coverage
* semantics live in one place.
*/
function projectsFilterCovers(
patterns: string[],
nodes: Record<string, ContextProjectNode>,
requiredNames: string[]
): boolean {
if (requiredNames.length === 0) {
return false;
}
const matched = findMatchingProjects([...patterns], nodes);
return requiredNames.every((name) => matched.includes(name));
}
function contextProjectNodes(
tree: Tree,
projectNames: string[]
): Record<string, { data: { root: string; tags?: string[] } }> {
const projects = getProjects(tree);
const nodes: Record<string, { data: { root: string; tags?: string[] } }> = {};
for (const name of projectNames) {
const config = projects.get(name);
if (config) {
nodes[name] = { data: { root: config.root, tags: config.tags } };
}
}
return nodes;
}
/**
* Collapse a key's value back to the plain object form when it has degenerated
* to a single entry with no filter — at that point the array wrapper carries no
* information the object form doesn't, and the object form is the tidier,
* record-shape-compatible representation.
*/
function collapse(value: TargetDefaultValue): TargetDefaultValue {
if (
Array.isArray(value) &&
value.length === 1 &&
value[0].filter === undefined
) {
// Drop the (already-undefined) `filter` key so the result matches the
// object value form, which forbids `filter`.
const { filter: _filter, ...config } = value[0];
return config;
}
return value;
}
/**
* Find a `targetDefaults` entry by its logical locator tuple
* `(target, executor, projects, plugin)`. Locator keys default to
* `undefined`, matching only entries that also leave them unset — same
* semantics as `upsertTargetDefault`.
*
* Throws when called with an empty locator (no `target`, `executor`,
* `projects`, or `plugin`). An empty locator is almost always a bug — the
* caller intended to find a specific entry but forgot to populate the lookup.
*/
export function findTargetDefault(
targetDefaults: TargetDefaults | undefined,
locator: Pick<
TargetDefaultEntry,
'target' | 'executor' | 'projects' | 'plugin'
>
): TargetDefaultEntry | undefined {
if (
locator.target === undefined &&
locator.executor === undefined &&
locator.projects === undefined &&
locator.plugin === undefined
) {
throw new Error(
'findTargetDefault requires at least one of `target`, `executor`, `projects`, or `plugin` on the locator.'
);
}
for (const entry of logicalTargetDefaultEntries(targetDefaults)) {
if (
entry.target === locator.target &&
entry.executor === locator.executor &&
projectsEqual(entry.projects, locator.projects) &&
entry.plugin === locator.plugin
) {
return entry;
}
}
return undefined;
}
/**
* Expand the nested `targetDefaults` map into its logical {@link
* TargetDefaultEntry} view, one entry at a time. The key becomes `target` (or
* `executor` for executor-shaped keys) and any `filter` is un-nested into the
* flat `projects`/`plugin`/`executor` siblings. Lazy so callers that find a
* match early stop iterating. Internal to this module — the flat shape is a
* lookup convenience, never a `targetDefaults` value form.
*/
function* logicalTargetDefaultEntries(
targetDefaults: TargetDefaults | undefined
): Generator<TargetDefaultEntry> {
for (const key of Object.keys(targetDefaults ?? {})) {
const locator = isExecutorLikeKey(key)
? { executor: key }
: { target: key };
const value = targetDefaults[key];
const entries: TargetDefaultArrayEntry[] = Array.isArray(value)
? value
: [value ?? {}];
for (const entry of entries) {
const { filter, ...config } = entry;
yield {
...locator,
...(filter?.projects !== undefined
? { projects: filter.projects }
: {}),
...(filter?.plugin !== undefined ? { plugin: filter.plugin } : {}),
...(filter?.executor !== undefined
? { executor: filter.executor }
: {}),
...config,
};
}
}
}
// Mirrors `isGlobPattern` from `nx/src/utils/globs.ts`, which isn't on the
// devkit-exports surface guaranteed across the supported nx version range.
//
// Known limitation: a `:` is also legal in a target name, so a target literally
// named e.g. `e2e:ci` is classified as an executor key here. This matches how
// the rest of the target-defaults stack disambiguates keys (executor-shaped
// keys win), and such target names are vanishingly rare — accepted rather than
// worked around.
const GLOB_CHARACTERS = new Set(['*', '|', '{', '}', '(', ')', '[']);
function isExecutorLikeKey(key: string): boolean {
if (!key.includes(':')) return false;
for (const c of key) if (GLOB_CHARACTERS.has(c)) return false;
return true;
}
/**
* The subset of a project graph node that the `projects` narrowing reads —
* names come from the map keys, `root`/`tags` drive `findMatchingProjects`.
* Mirrors `MatcherProjectNode` without importing it, so the signature stays
* stable across the supported nx version range.
*/
type ContextProjectNode = { data: { root: string; tags?: string[] } };
/**
* What the caller is updating, used to pick which `targetDefaults` entries the
* callback runs against. At least one of `executor` / `projects` is required.
*/
export interface UpdateTargetDefaultContext {
/**
* Visit only entries that resolve to this executor — matched against the map
* key (executor-keyed form), an entry's `executor` field, or its
* `filter.executor`. When omitted, entries are not filtered by executor.
*/
executor?: string;
/**
* Project nodes the update is scoped to, keyed by project name. When set, a
* project-filtered entry is visited only if its `filter.projects` covers
* *every* one of these projects; unfiltered entries always match. When
* omitted, project filters are not a constraint and every (executor-matching)
* entry is visited — the workspace-wide case.
*/
projects?: Record<string, ContextProjectNode>;
}
/**
* Describes the entry a callback invocation is operating on, so the callback
* can tell the executor-keyed form from the target-keyed form and read the
* entry's filter.
*/
export interface UpdateTargetDefaultEntryInfo {
/** The `targetDefaults` map key the entry lives under. */
key: string;
/** Logical target name — `undefined` for executor-keyed entries. */
target?: string;
/** Executor the entry resolves to (from the key, `executor` field, or filter). */
executor?: string;
/** The entry's filter, present only for filtered array entries. */
filter?: TargetDefaultFilter;
}
/**
* Called once per matching entry. Receives the entry's flat config (everything
* but `filter`) and may mutate it in place. Return a new config to replace the
* entry's payload, return `null` to drop the entry, or return nothing to keep
* the (possibly mutated) config.
*/
export type UpdateTargetDefaultCallback = (
config: Partial<TargetConfiguration>,
info: UpdateTargetDefaultEntryInfo
) => Partial<TargetConfiguration> | null | void;
/**
* Walk the `targetDefaults` entries matching `context` and run `callback`
* against each, mutating `nxJson` in place and returning it. This is the
* read-modify-write counterpart to {@link upsertTargetDefault}, meant for
* generators and migrations that need to edit *existing* defaults across both
* value forms (plain object and filtered array) without re-implementing the
* normalize → edit → collapse dance by hand.
*
* Matching, per the `context`:
* - `executor` selects entries that reference it as the map key, an `executor`
* config field, or `filter.executor`.
* - `projects` narrows to entries whose `filter.projects` covers the scoped
* projects (unfiltered entries always match). With no `projects` context,
* project filters are ignored and every executor-matching entry is visited.
*
* Removal and collapsing follow the storage-form rules:
* - Array entry whose callback returns `null` → dropped from the array.
* - An array that collapses to a single unfiltered entry → stored as the plain
* object form; an emptied array → the whole key is deleted.
* - Object-form value whose callback returns `null` → the whole key is deleted.
* - An emptied `targetDefaults` map → removed from `nxJson` entirely.
*
* Throws when neither `executor` nor `projects` is provided — an empty context
* would match everything and is almost always a bug.
*/
export function updateTargetDefault(
nxJson: NxJsonConfiguration,
context: UpdateTargetDefaultContext,
callback: UpdateTargetDefaultCallback
): NxJsonConfiguration {
const { executor, projects } = context;
if (executor === undefined && projects === undefined) {
throw new Error(
'updateTargetDefault requires at least one of `executor` or `projects` on the context to locate entries to update.'
);
}
const targetDefaults = nxJson.targetDefaults;
if (!targetDefaults) {
return nxJson;
}
const projectNames = projects ? Object.keys(projects) : [];
for (const key of Object.keys(targetDefaults)) {
const value = targetDefaults[key];
const entries: TargetDefaultArrayEntry[] = Array.isArray(value)
? value
: [value];
const kept: TargetDefaultArrayEntry[] = [];
for (const entry of entries) {
if (!entryMatchesContext(key, entry, executor, projects, projectNames)) {
kept.push(entry);
continue;
}
const { filter, ...config } = entry;
const result = callback(config, {
key,
target: isExecutorLikeKey(key) ? undefined : key,
executor: isExecutorLikeKey(key)
? key
: (config.executor ?? filter?.executor),
filter,
}) as Partial<TargetConfiguration> | null | undefined;
// `null` drops the entry; anything else keeps the (mutated or replaced)
// config, re-attaching the filter so a filtered entry stays filtered.
if (result === null) {
continue;
}
const nextConfig = result ?? config;
kept.push(filter !== undefined ? { filter, ...nextConfig } : nextConfig);
}
if (kept.length === 0) {
delete targetDefaults[key];
} else {
targetDefaults[key] = collapse(kept);
}
}
if (Object.keys(targetDefaults).length === 0) {
delete nxJson.targetDefaults;
}
return nxJson;
}
/**
* Whether `callback` should run against this entry: it must reference the
* context's `executor` (when given), and — when the context is scoped to
* `projects` — any `filter.projects` it carries must cover every scoped
* project. Unfiltered entries always pass the project check.
*/
function entryMatchesContext(
key: string,
entry: TargetDefaultArrayEntry,
executor: string | undefined,
projects: Record<string, ContextProjectNode> | undefined,
projectNames: string[]
): boolean {
if (executor !== undefined) {
const referencesExecutor =
key === executor ||
entry.executor === executor ||
entry.filter?.executor === executor;
if (!referencesExecutor) {
return false;
}
}
if (projects !== undefined && entry.filter?.projects !== undefined) {
const patterns = Array.isArray(entry.filter.projects)
? entry.filter.projects
: [entry.filter.projects];
if (!projectsFilterCovers(patterns, projects, projectNames)) {
return false;
}
}
return true;
}
export function readTargetDefaultsForTarget(
targetName: string,
targetDefaults: TargetDefaults | undefined,
executor?: string,
opts?: Parameters<typeof readTargetDefaultsForTargetFromNx>[3]
): Partial<TargetConfiguration> | null {
// `targetDefaults` is the nested map; the nx reader resolves either the
// object or array value form for `targetName` natively.
return readTargetDefaultsForTargetFromNx(
targetName,
targetDefaults,
executor,
opts
);
}
// Order-insensitive equality so re-upserts with reordered patterns merge into
// the same entry rather than appending a duplicate. Comparing sorted copies
// keeps duplicates significant (`['a','a']` ≠ `['a']`) without special-casing.
function projectsEqual(
a: string[] | undefined,
b: string[] | undefined
): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((value, i) => value === sortedB[i]);
}
export function addBuildTargetDefaults(
tree: Tree,
executorName: string,
buildTargetName = 'build',
extraInputs: TargetConfiguration['inputs'] = []
): void {
const nxJson = readNxJson(tree) ?? {};
// Only skip when an *unfiltered* entry already covers this executor.
// A filtered entry (`projects:` or `plugin:`) only applies to a subset
// of targets, so it doesn't satisfy the workspace-wide default this
// helper writes — we still need to add the unfiltered baseline.
if (findTargetDefault(nxJson.targetDefaults, { executor: executorName })) {
return;
}
upsertTargetDefault(tree, nxJson, {
executor: executorName,
cache: true,
dependsOn: [`^${buildTargetName}`],
inputs: [
...(nxJson.namedInputs && 'production' in nxJson.namedInputs
? ['production', '^production']
: ['default', '^default']),
...extraInputs,
],
});
updateNxJson(tree, nxJson);
}
export async function addE2eCiTargetDefaults(
tree: Tree,
e2ePlugin: string,
buildTarget: string,
pathToE2EConfigFile: string
): Promise<void> {
const nxJson = readNxJson(tree);
if (!nxJson?.plugins) {
return;
}
const e2ePluginRegistrations = nxJson.plugins.filter((p) =>
typeof p === 'string' ? p === e2ePlugin : p.plugin === e2ePlugin
);
if (!e2ePluginRegistrations.length) {
return;
}
const resolvedE2ePlugin: {
createNodes?: CreateNodes;
createNodesV2?: CreateNodes;
} = await import(e2ePlugin);
const e2ePluginGlob =
resolvedE2ePlugin.createNodes?.[0] ?? resolvedE2ePlugin.createNodesV2?.[0];
// The e2e config file must be one this plugin actually processes (its path
// matches the plugin's createNodes glob) before the registration's
// include/exclude filters are applied.
const e2eConfigMatchesPluginGlob =
!e2ePluginGlob ||
minimatch(pathToE2EConfigFile, e2ePluginGlob, { dot: true });
let foundPluginForApplication: PluginConfiguration;
for (let i = 0; i < e2ePluginRegistrations.length; i++) {
let candidatePluginForApplication = e2ePluginRegistrations[i];
if (typeof candidatePluginForApplication === 'string') {
foundPluginForApplication = candidatePluginForApplication;
break;
}
const matchingConfigFiles = e2eConfigMatchesPluginGlob
? findMatchingConfigFiles(
[pathToE2EConfigFile],
candidatePluginForApplication.include,
candidatePluginForApplication.exclude
)
: [];
if (matchingConfigFiles.length) {
foundPluginForApplication = candidatePluginForApplication;
break;
}
}
if (!foundPluginForApplication) {
return;
}
const ciTargetName =
typeof foundPluginForApplication === 'string'
? 'e2e-ci'
: ((foundPluginForApplication.options as any)?.ciTargetName ?? 'e2e-ci');
const ciTargetNameGlob = `${ciTargetName}--**/**`;
const existing = findTargetDefault(nxJson.targetDefaults, {
target: ciTargetNameGlob,
});
const dependsOn = [...(existing?.dependsOn ?? [])];
if (!dependsOn.includes(buildTarget)) {
dependsOn.push(buildTarget);
}
upsertTargetDefault(tree, nxJson, { target: ciTargetNameGlob, dependsOn });
updateNxJson(tree, nxJson);
}
@@ -0,0 +1 @@
file in directory foo <%= foo %> contents
@@ -0,0 +1 @@
file in directory contents
@@ -0,0 +1 @@
file-with-property-foo-<%= foo %> contents
@@ -0,0 +1 @@
file with template suffix contents
@@ -0,0 +1 @@
file contents
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,57 @@
import { createTree } from 'nx/src/generators/testing-utils/create-tree';
import type { Tree } from 'nx/src/generators/tree';
import { toJS } from './to-js';
describe('toJS', () => {
let tree: Tree;
beforeEach(() => {
tree = createTree();
});
it('should renamed .ts and .tsx file to .js', () => {
tree.write('a.ts', '// a');
tree.write('b.tsx', '// b');
toJS(tree);
expect(tree.exists('a.ts')).toBeFalsy();
expect(tree.exists('b.tsx')).toBeFalsy();
expect(tree.read('a.js', 'utf-8')).toContain('// a');
expect(tree.read('b.js', 'utf-8')).toContain('// b');
});
it('should support different extensions', () => {
tree.write('a.ts', '// a');
toJS(tree, {
extension: '.mjs',
});
expect(tree.read('a.mjs', 'utf-8')).toContain('// a');
});
it('should rename .mts/.cts files alongside .ts', () => {
tree.write('a.mts', '// a');
tree.write('b.cts', '// b');
toJS(tree, { extension: '.mjs' });
expect(tree.exists('a.mts')).toBeFalsy();
expect(tree.exists('b.cts')).toBeFalsy();
expect(tree.read('a.mjs', 'utf-8')).toContain('// a');
expect(tree.read('b.mjs', 'utf-8')).toContain('// b');
});
it('should support .jsx rather than .js files (for Vite)', () => {
tree.write('a.ts', '// a');
tree.write('b.tsx', '// b');
toJS(tree, {
useJsx: true,
});
expect(tree.read('a.js', 'utf-8')).toContain('// a');
expect(tree.read('b.jsx', 'utf-8')).toContain('// b');
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { Tree } from 'nx/src/devkit-exports';
import type { ModuleKind, ScriptTarget } from 'typescript';
import { typescriptVersion } from '../utils/versions';
import { ensurePackage } from '../utils/package-json';
export type ToJSOptions = {
extension?: '.js' | '.mjs' | '.cjs';
module?: ModuleKind;
target?: ScriptTarget;
useJsx?: boolean;
};
/**
* Rename and transpile any new typescript files created to javascript files
*/
export function toJS(tree: Tree, options?: ToJSOptions): void {
const { JsxEmit, ScriptTarget, transpile, ModuleKind } = ensurePackage(
'typescript',
typescriptVersion
) as typeof import('typescript');
// Match `.ts`, `.mts`, `.cts`, `.tsx`. The optional `[cm]` keeps native
// ESM (`.mts`) and CommonJS (`.cts`) TypeScript extensions in the rename
// path so `--js` generators that emit `.mts`/`.cts` templates get
// converted instead of silently left as TypeScript on disk.
const tsExtRegex = /\.([cm]?ts|tsx)$/;
for (const c of tree.listChanges()) {
if (tsExtRegex.test(c.path) && c.type === 'CREATE') {
tree.write(
c.path,
transpile(c.content.toString('utf-8'), {
allowJs: true,
jsx: JsxEmit.Preserve,
target: options?.target ?? ScriptTarget.ESNext,
module: options?.module ?? ModuleKind.ESNext,
})
);
const targetExt = options?.extension ?? '.js';
tree.rename(c.path, c.path.replace(/\.([cm]?ts)$/, targetExt));
if (options?.useJsx) {
tree.rename(c.path, c.path.replace(/\.tsx$/, '.jsx'));
} else {
tree.rename(c.path, c.path.replace(/\.tsx$/, targetExt));
}
}
}
}
@@ -0,0 +1 @@
export function insertImport(): void {}
@@ -0,0 +1 @@
export function insertImport(): void {}
@@ -0,0 +1,90 @@
import type { Tree } from 'nx/src/generators/tree';
import { updateTsConfigsToJs } from './update-ts-configs-to-js';
import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace';
describe('updateTsConfigsToJs', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should set allowJs to true in tsconfig.json', () => {
tree.write('tsconfig.json', `{}`);
tree.write('tsconfig.lib.json', `{}`);
updateTsConfigsToJs(tree, { projectRoot: '.' });
expect(tree.read('tsconfig.json', 'utf-8')).toMatchInlineSnapshot(`
"{
"compilerOptions": {
"allowJs": true
}
}
"
`);
});
it.each`
tsconfig
${'tsconfig.app.json'}
${'tsconfig.lib.json'}
`(
'should add the relevant include and exclude to $tsconfig',
({ tsconfig }) => {
tree.write('tsconfig.json', `{}`);
tree.write(
tsconfig,
JSON.stringify({
include: ['src/**/*.ts'],
exclude: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
})
);
updateTsConfigsToJs(tree, { projectRoot: '.' });
expect(tree.read(tsconfig, 'utf-8')).toMatchInlineSnapshot(`
"{
"include": [
"src/**/*.ts",
"src/**/*.js"
],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.js",
"src/**/*.test.js"
]
}
"
`);
}
);
it.each`
tsconfig
${'tsconfig.app.json'}
${'tsconfig.lib.json'}
`(
'should update $tsconfig with the relevant include and exclude when those properties are not defined',
({ tsconfig }) => {
tree.write('tsconfig.json', `{}`);
tree.write(tsconfig, `{}`);
updateTsConfigsToJs(tree, { projectRoot: '.' });
expect(tree.read(tsconfig, 'utf-8')).toMatchInlineSnapshot(`
"{
"include": [
"src/**/*.js"
],
"exclude": [
"src/**/*.spec.js",
"src/**/*.test.js"
]
}
"
`);
}
);
});
@@ -0,0 +1,50 @@
import { Tree, updateJson } from 'nx/src/devkit-exports';
export function updateTsConfigsToJs(
tree: Tree,
options: { projectRoot: string }
): void {
let updateConfigPath: string | null = null;
const paths = {
tsConfig: `${options.projectRoot}/tsconfig.json`,
tsConfigLib: `${options.projectRoot}/tsconfig.lib.json`,
tsConfigApp: `${options.projectRoot}/tsconfig.app.json`,
};
updateJson(tree, paths.tsConfig, (json) => {
if (json.compilerOptions) {
json.compilerOptions.allowJs = true;
} else {
json.compilerOptions = { allowJs: true };
}
return json;
});
if (tree.exists(paths.tsConfigLib)) {
updateConfigPath = paths.tsConfigLib;
}
if (tree.exists(paths.tsConfigApp)) {
updateConfigPath = paths.tsConfigApp;
}
if (updateConfigPath) {
updateJson(tree, updateConfigPath, (json) => {
json.include = uniq([...(json.include ?? []), 'src/**/*.js']);
json.exclude = uniq([
...(json.exclude ?? []),
'src/**/*.spec.js',
'src/**/*.test.js',
]);
return json;
});
} else {
throw new Error(
`project is missing tsconfig.lib.json or tsconfig.app.json`
);
}
}
const uniq = <T extends string[]>(value: T) => [...new Set(value)] as T;
@@ -0,0 +1,56 @@
import { createTree } from 'nx/src/generators/testing-utils/create-tree';
import type { Tree } from 'nx/src/generators/tree';
import { visitNotIgnoredFiles } from './visit-not-ignored-files';
describe('visitNotIgnoredFiles', () => {
let tree: Tree;
beforeEach(() => {
tree = createTree();
});
it('should visit files recursively in a directory', () => {
tree.write('dir/file1.ts', '');
tree.write('dir/dir2/file2.ts', '');
const visitor = jest.fn();
visitNotIgnoredFiles(tree, 'dir', visitor);
expect(visitor).toHaveBeenCalledWith('dir/file1.ts');
expect(visitor).toHaveBeenCalledWith('dir/dir2/file2.ts');
});
it('should not visit ignored files in a directory', () => {
tree.write('.gitignore', 'node_modules');
tree.write('dir/file1.ts', '');
tree.write('dir/node_modules/file1.ts', '');
tree.write('dir/dir2/file2.ts', '');
const visitor = jest.fn();
visitNotIgnoredFiles(tree, 'dir', visitor);
expect(visitor).toHaveBeenCalledWith('dir/file1.ts');
expect(visitor).toHaveBeenCalledWith('dir/dir2/file2.ts');
expect(visitor).not.toHaveBeenCalledWith('dir/node_modules/file1.ts');
});
it.each(['', '.', '/', './'])(
'should be able to visit the root path "%s"',
(dirPath) => {
tree.write('.gitignore', 'node_modules');
tree.write('dir/file1.ts', '');
tree.write('dir/node_modules/file1.ts', '');
tree.write('dir/dir2/file2.ts', '');
const visitor = jest.fn();
visitNotIgnoredFiles(tree, dirPath, visitor);
expect(visitor).toHaveBeenCalledWith('.gitignore');
expect(visitor).toHaveBeenCalledWith('dir/file1.ts');
expect(visitor).toHaveBeenCalledWith('dir/dir2/file2.ts');
expect(visitor).not.toHaveBeenCalledWith('dir/node_modules/file1.ts');
}
);
});
@@ -0,0 +1,33 @@
import type { Tree } from 'nx/src/devkit-exports';
import { getIgnoreObjectForTree } from 'nx/src/devkit-internals';
import { join, relative, sep } from 'path';
/**
* Utility to act on all files in a tree that are not ignored by git.
*/
export function visitNotIgnoredFiles(
tree: Tree,
dirPath: string = tree.root,
visitor: (path: string) => void
): void {
const ig = getIgnoreObjectForTree(tree);
dirPath = normalizePathRelativeToRoot(dirPath, tree.root);
if (dirPath !== '' && ig?.ignores(dirPath)) {
return;
}
for (const child of tree.children(dirPath)) {
const fullPath = join(dirPath, child);
if (ig?.ignores(fullPath)) {
continue;
}
if (tree.isFile(fullPath)) {
visitor(fullPath);
} else {
visitNotIgnoredFiles(tree, fullPath, visitor);
}
}
}
function normalizePathRelativeToRoot(path: string, root: string): string {
return relative(root, join(root, path)).split(sep).join('/');
}
@@ -0,0 +1,29 @@
#### Rename deprecated CreateNodes `V2` types from `@nx/devkit`
The canonical plugin API types lost their `V2` suffix in Nx 23. The `V2` names are kept as `@deprecated` aliases, but new code should use the canonical names:
| Deprecated (`@nx/devkit`) | Canonical |
| ------------------------- | ------------------------ |
| `CreateNodesV2` | `CreateNodes` |
| `CreateNodesContextV2` | `CreateNodesContext` |
| `CreateNodesResultV2` | `CreateNodesResultArray` |
| `CreateNodesFunctionV2` | `CreateNodesFunction` |
| `NxPluginV2` | `NxPlugin` |
This migration scans every `.ts`, `.tsx`, `.cts`, and `.mts` file in your workspace and rewrites named imports and re-exports of these types from `@nx/devkit` to their canonical names.
#### Sample Code Changes
##### Before
```ts
import type { CreateNodesV2, CreateNodesContextV2 } from '@nx/devkit';
```
##### After
```ts
import type { CreateNodes, CreateNodesContext } from '@nx/devkit';
```
Aliases are preserved (`CreateNodesV2 as CN` becomes `CreateNodes as CN`), and if a file already imports both names (`{ CreateNodes, CreateNodesV2 }`) the redundant binding is dropped. The unrelated `CreateNodesResult` type is left untouched.
@@ -0,0 +1,213 @@
import { type Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import migration, {
CREATE_NODES_V2_TYPE_RENAMES,
rewriteCreateNodesV2Types,
} from './rename-create-nodes-v2-types';
describe('rename-create-nodes-v2-types migration', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
describe('rewriteCreateNodesV2Types', () => {
it('renames each deprecated type to its canonical name', () => {
for (const [from, to] of CREATE_NODES_V2_TYPE_RENAMES) {
const input = `import type { ${from} } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { ${to} } from '@nx/devkit';\n`
);
}
});
it('renames value-position imports too', () => {
const input = `import { NxPluginV2 } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import { NxPlugin } from '@nx/devkit';\n`
);
});
it('renames CreateNodesResultV2 to CreateNodesResultArray', () => {
const input = `import type { CreateNodesResultV2 } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { CreateNodesResultArray } from '@nx/devkit';\n`
);
});
it('leaves the unrelated CreateNodesResult type alone', () => {
const input = `import type { CreateNodesResult } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(input);
});
it('renames several types in one import and preserves others', () => {
const input = `import type { CreateNodesV2, CreateNodesContextV2, Tree } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { CreateNodes, CreateNodesContext, Tree } from '@nx/devkit';\n`
);
});
it('preserves `as` aliases', () => {
const input = `import type { CreateNodesV2 as CN } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { CreateNodes as CN } from '@nx/devkit';\n`
);
});
it('collapses a redundant `CreateNodesV2 as CreateNodes` alias', () => {
const input = `import type { CreateNodesV2 as CreateNodes } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { CreateNodes } from '@nx/devkit';\n`
);
});
it('dedupes when both the V2 and canonical names are imported', () => {
const input = `import type { CreateNodes, CreateNodesV2 } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { CreateNodes } from '@nx/devkit';\n`
);
});
it('handles inline `type` modifiers in a value import', () => {
const input = `import { type NxPluginV2, readJson } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import { type NxPlugin, readJson } from '@nx/devkit';\n`
);
});
it('handles multi-line imports', () => {
const input = `import type {\n NxPluginV2,\n CreateNodesFunctionV2,\n} from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { NxPlugin, CreateNodesFunction } from '@nx/devkit';\n`
);
});
it('rewrites named re-exports', () => {
const input = `export type { CreateNodesV2 } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`export type { CreateNodes } from '@nx/devkit';\n`
);
});
it('does not touch the types when imported from another specifier', () => {
const input = `import type { CreateNodesV2 } from '@nx/devkit/internal';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(input);
});
it('does not touch unrelated @nx/devkit imports', () => {
const input = `import { Tree, readJson } from '@nx/devkit';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(input);
});
it('renames in-body usages of a renamed non-aliased import', () => {
const input =
`import type { CreateNodesV2 } from '@nx/devkit';\n` +
`export const createNodesV2: CreateNodesV2 = ['**/x', () => ({})];\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { CreateNodes } from '@nx/devkit';\n` +
`export const createNodesV2: CreateNodes = ['**/x', () => ({})];\n`
);
});
it('renames every in-body usage of a renamed type', () => {
const input =
`import type { CreateNodesV2 } from '@nx/devkit';\n` +
`let a: CreateNodesV2;\n` +
`let b: Array<CreateNodesV2>;\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { CreateNodes } from '@nx/devkit';\n` +
`let a: CreateNodes;\n` +
`let b: Array<CreateNodes>;\n`
);
});
it('renames generic type usages and parameter annotations (php plugin shape)', () => {
const input =
`import { CreateNodesV2, CreateNodesContextV2 } from '@nx/devkit';\n` +
`export const createNodesV2: CreateNodesV2<ComposerPluginOptions> = ['x', () => ({})];\n` +
`async function fn(context: CreateNodesContextV2) {}\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import { CreateNodes, CreateNodesContext } from '@nx/devkit';\n` +
`export const createNodesV2: CreateNodes<ComposerPluginOptions> = ['x', () => ({})];\n` +
`async function fn(context: CreateNodesContext) {}\n`
);
});
it('does not rename in-body usages when the local binding is aliased', () => {
const input =
`import type { CreateNodesV2 as CN } from '@nx/devkit';\n` +
`let a: CN;\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { CreateNodes as CN } from '@nx/devkit';\n` +
`let a: CN;\n`
);
});
it('does not rename member positions that merely share the name', () => {
const input =
`import type { NxPluginV2 } from '@nx/devkit';\n` +
`let a: NxPluginV2;\n` +
`const x = foo.NxPluginV2;\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(
`import type { NxPlugin } from '@nx/devkit';\n` +
`let a: NxPlugin;\n` +
`const x = foo.NxPluginV2;\n`
);
});
it('leaves the V2 names in strings and comments alone', () => {
const input =
`// import { CreateNodesV2 } from '@nx/devkit';\n` +
`const s = 'CreateNodesV2';\n`;
expect(rewriteCreateNodesV2Types(input)).toBe(input);
});
});
describe('migration runner', () => {
it('rewrites type imports across .ts/.tsx/.cts/.mts files', async () => {
tree.write(
'libs/foo/src/a.ts',
`import type { CreateNodesV2 } from '@nx/devkit';\n`
);
tree.write(
'libs/foo/src/b.tsx',
`import { NxPluginV2 } from '@nx/devkit';\n`
);
tree.write(
'libs/foo/src/c.cts',
`export type { CreateNodesContextV2 } from '@nx/devkit';\n`
);
tree.write(
'libs/foo/src/d.mts',
`import type { CreateNodesResultV2 } from '@nx/devkit';\n`
);
await migration(tree);
expect(tree.read('libs/foo/src/a.ts', 'utf-8')).toContain(
`import type { CreateNodes } from '@nx/devkit';`
);
expect(tree.read('libs/foo/src/b.tsx', 'utf-8')).toContain(
`import { NxPlugin } from '@nx/devkit';`
);
expect(tree.read('libs/foo/src/c.cts', 'utf-8')).toContain(
`export type { CreateNodesContext } from '@nx/devkit';`
);
expect(tree.read('libs/foo/src/d.mts', 'utf-8')).toContain(
`import type { CreateNodesResultArray } from '@nx/devkit';`
);
});
it('does not rewrite non-TS files', async () => {
const md = `Example: \`import type { CreateNodesV2 } from '@nx/devkit';\`\n`;
tree.write('docs/example.md', md);
await migration(tree);
expect(tree.read('docs/example.md', 'utf-8')).toContain(
`import type { CreateNodesV2 } from '@nx/devkit';`
);
});
});
});
@@ -0,0 +1,276 @@
import { logger, type Tree } from 'nx/src/devkit-exports';
import type {
ExportDeclaration,
ExportSpecifier,
Identifier,
ImportDeclaration,
ImportSpecifier,
NamedExports,
NamedImports,
Node,
SourceFile,
} from 'typescript';
import { formatFiles } from '../../generators/format-files';
import { visitNotIgnoredFiles } from '../../generators/visit-not-ignored-files';
import { ensurePackage } from '../../utils/package-json';
import {
applyChangesToString,
ChangeType,
type StringChange,
} from '../../utils/string-change';
import { typescriptVersion } from '../../utils/versions';
const TS_EXTENSIONS = ['.ts', '.tsx', '.cts', '.mts'] as const;
const DEVKIT_SPECIFIER = '@nx/devkit';
// The `*V2` plugin types were renamed to their canonical un-suffixed names in
// Nx 23. The `V2` names remain as `@deprecated` aliases, so this migration only
// moves callers onto the canonical names. (`CreateNodesResultV2` is renamed to
// `CreateNodesResultArray`, not `CreateNodesResult`, which is an unrelated type.)
export const CREATE_NODES_V2_TYPE_RENAMES: ReadonlyMap<string, string> =
new Map([
['CreateNodesV2', 'CreateNodes'],
['CreateNodesContextV2', 'CreateNodesContext'],
['CreateNodesResultV2', 'CreateNodesResultArray'],
['CreateNodesFunctionV2', 'CreateNodesFunction'],
['NxPluginV2', 'NxPlugin'],
]);
let ts: typeof import('typescript') | undefined;
export default async function renameCreateNodesV2Types(
tree: Tree
): Promise<void> {
let touchedCount = 0;
visitNotIgnoredFiles(tree, '.', (filePath) => {
if (!TS_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
return;
}
const original = tree.read(filePath, 'utf-8');
if (!original || !original.includes('V2')) {
return;
}
const updated = rewriteCreateNodesV2Types(original);
if (updated !== original) {
tree.write(filePath, updated);
touchedCount += 1;
}
});
if (touchedCount > 0) {
logger.info(
`Renamed deprecated CreateNodes V2 type imports from @nx/devkit in ${touchedCount} file(s).`
);
}
await formatFiles(tree);
}
/**
* Rewrites named imports and re-exports of the deprecated `*V2` plugin types
* from `@nx/devkit` to their canonical names. Only the named bindings are
* touched — the module specifier, the `import`/`export` keyword, any `type`
* modifier, and any default import are left untouched.
*/
export function rewriteCreateNodesV2Types(source: string): string {
ts ??= ensurePackage<typeof import('typescript')>(
'typescript',
typescriptVersion
);
const sourceFile = ts.createSourceFile(
'tmp.ts',
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
ts.ScriptKind.TSX
);
const changes: StringChange[] = [];
// Local bindings whose name changes as a result of rewriting a non-aliased
// import specifier (e.g. `import { CreateNodesV2 }` -> `import { CreateNodes }`).
// Every in-body reference to such a binding must be renamed too, otherwise the
// rewritten import leaves a dangling reference to the old name.
const localRenames = new Map<string, string>();
for (const stmt of sourceFile.statements) {
if (ts.isImportDeclaration(stmt)) {
collectImportRewrite(sourceFile, stmt, changes, localRenames);
} else if (ts.isExportDeclaration(stmt)) {
collectExportRewrite(sourceFile, stmt, changes);
}
}
if (localRenames.size > 0) {
collectUsageRewrites(sourceFile, localRenames, changes);
}
return changes.length > 0 ? applyChangesToString(source, changes) : source;
}
function isDevkitSpecifier(
node: ImportDeclaration['moduleSpecifier']
): boolean {
return ts!.isStringLiteral(node) && node.text === DEVKIT_SPECIFIER;
}
function collectImportRewrite(
sourceFile: SourceFile,
stmt: ImportDeclaration,
changes: StringChange[],
localRenames: Map<string, string>
): void {
if (!isDevkitSpecifier(stmt.moduleSpecifier)) {
return;
}
const namedBindings = stmt.importClause?.namedBindings;
if (!namedBindings || !ts!.isNamedImports(namedBindings)) {
return;
}
// A non-aliased specifier (`{ CreateNodesV2 }`) renames the local binding, so
// its in-body references must be rewritten as well. An aliased specifier
// (`{ CreateNodesV2 as Foo }`) keeps the local name `Foo`, so it does not.
for (const el of namedBindings.elements) {
if (el.propertyName) {
continue;
}
const canonical = CREATE_NODES_V2_TYPE_RENAMES.get(el.name.text);
if (canonical) {
localRenames.set(el.name.text, canonical);
}
}
rewriteNamedBindings(sourceFile, namedBindings, changes);
}
/**
* Renames in-body references (type annotations, value usages) of bindings that
* were renamed by an import rewrite. References inside import/export
* declarations are left to the binding rewrite, and member positions
* (`foo.CreateNodesV2`, `NS.CreateNodesV2`) are not standalone references to the
* imported binding, so they are skipped.
*/
function collectUsageRewrites(
sourceFile: SourceFile,
localRenames: Map<string, string>,
changes: StringChange[]
): void {
const visit = (node: Node): void => {
if (
ts!.isIdentifier(node) &&
localRenames.has(node.text) &&
isRenameableReference(node)
) {
const start = node.getStart(sourceFile);
changes.push(
{ type: ChangeType.Delete, start, length: node.getEnd() - start },
{
type: ChangeType.Insert,
index: start,
text: localRenames.get(node.text)!,
}
);
}
ts!.forEachChild(node, visit);
};
ts!.forEachChild(sourceFile, visit);
}
function isRenameableReference(id: Identifier): boolean {
const parent = id.parent;
// `foo.CreateNodesV2` — the member name is not the imported binding.
if (ts!.isPropertyAccessExpression(parent) && parent.name === id) {
return false;
}
// `NS.CreateNodesV2` in a type position — same reasoning.
if (ts!.isQualifiedName(parent) && parent.right === id) {
return false;
}
// Anything inside an import/export declaration is handled by the binding
// rewrite; skip it here to avoid touching the specifier twice.
for (let n: Node | undefined = id; n; n = n.parent) {
if (ts!.isImportDeclaration(n) || ts!.isExportDeclaration(n)) {
return false;
}
}
return true;
}
function collectExportRewrite(
sourceFile: SourceFile,
stmt: ExportDeclaration,
changes: StringChange[]
): void {
if (!stmt.moduleSpecifier || !isDevkitSpecifier(stmt.moduleSpecifier)) {
return;
}
if (!stmt.exportClause || !ts!.isNamedExports(stmt.exportClause)) {
return;
}
rewriteNamedBindings(sourceFile, stmt.exportClause, changes);
}
/**
* Re-renders the `{ ... }` of a named import/export, renaming any deprecated
* `*V2` type to its canonical name. If renaming would collide with a canonical
* name that is already present, the duplicate is dropped. Returns without
* recording a change when the binding list contains none of the renamed types.
*/
function rewriteNamedBindings(
sourceFile: SourceFile,
namedBindings: NamedImports | NamedExports,
changes: StringChange[]
): void {
const elements = namedBindings.elements as readonly (
| ImportSpecifier
| ExportSpecifier
)[];
const hasRenamed = elements.some((el) =>
CREATE_NODES_V2_TYPE_RENAMES.has((el.propertyName ?? el.name).text)
);
if (!hasRenamed) {
return;
}
const seen = new Set<string>();
const rendered: string[] = [];
for (const el of elements) {
const text = renderSpecifier(el);
if (!seen.has(text)) {
seen.add(text);
rendered.push(text);
}
}
const start = namedBindings.getStart(sourceFile);
changes.push(
{
type: ChangeType.Delete,
start,
length: namedBindings.getEnd() - start,
},
{
type: ChangeType.Insert,
index: start,
text: `{ ${rendered.join(', ')} }`,
}
);
}
function renderSpecifier(el: ImportSpecifier | ExportSpecifier): string {
const typePrefix = el.isTypeOnly ? 'type ' : '';
const rename = (name: string) =>
CREATE_NODES_V2_TYPE_RENAMES.get(name) ?? name;
// `{ name }` — no alias, so the local binding follows the rename.
if (!el.propertyName) {
return `${typePrefix}${rename(el.name.text)}`;
}
// `{ propertyName as name }` — only the imported (left) side is renamed; the
// local alias is preserved. A now-redundant alias such as
// `CreateNodesV2 as CreateNodes` collapses to `CreateNodes`.
const canonicalImported = rename(el.propertyName.text);
const localName = el.name.text;
return canonicalImported === localName
? `${typePrefix}${localName}`
: `${typePrefix}${canonicalImported} as ${localName}`;
}
@@ -0,0 +1,43 @@
#### Update `@nx/devkit` deep imports
`@nx/devkit` now ships a strict `exports` map, so deep imports like `@nx/devkit/src/utils/...` and `@nx/devkit/src/generators/...` are no longer reachable through Node module resolution.
This migration scans every `.ts`, `.tsx`, `.cts`, and `.mts` file in your workspace and rewrites those deep imports to one of the supported entry points:
- Symbols that are part of the stable `@nx/devkit` public API are routed to `@nx/devkit`.
- Symbols that were previously only reachable through deep imports are routed to `@nx/devkit/internal`.
After rewriting, the migration **collapses duplicate imports** so a file never ends up with two `import ... from '@nx/devkit'` (or `@nx/devkit/internal`) lines — including merging into any matching import you already had.
#### Sample Code Changes
##### Before
```ts
import { Tree } from '@nx/devkit';
import { dasherize, names } from '@nx/devkit/src/utils/string-utils';
import { addPlugin } from '@nx/devkit/src/utils/add-plugin';
```
##### After
```ts
import { Tree, names } from '@nx/devkit';
import { dasherize, addPlugin } from '@nx/devkit/internal';
```
`names` was already in the public API, so it joins the existing `@nx/devkit` import. `dasherize` and `addPlugin` move to `@nx/devkit/internal`, and the two `/internal` imports are collapsed into one.
#### Fallback for non-named imports
For deep-import shapes that can't be split by symbol — default imports, namespace imports, side-effect imports, `require(...)` calls, dynamic `import(...)`, and `jest.mock(...)` / `vi.mock(...)`-style mock-helper calls — the migration rewrites the specifier to `@nx/devkit/internal` as a best guess, since most symbols that previously lived under `@nx/devkit/src/...` ended up there.
```ts
// Before
const { dasherize } = require('@nx/devkit/src/utils/string-utils');
// After
const { dasherize } = require('@nx/devkit/internal');
```
If the symbol you're after is part of the stable public API instead, the rewritten import will fail to resolve against `@nx/devkit/internal` — switch it to `@nx/devkit` by hand. The migration also leaves `typeof import('@nx/devkit/src/...')` type queries and any deep-import strings inside template literals or comments untouched, so you'll need to update those by hand.
@@ -0,0 +1,370 @@
import { type Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import migration, {
DEVKIT_INTERNAL_SYMBOLS,
rewriteDevkitDeepImports,
} from './update-deep-imports';
describe('update-deep-imports migration', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
describe('rewriteDevkitDeepImports', () => {
it('routes a single internal symbol to @nx/devkit/internal', () => {
const input = `import { dasherize } from '@nx/devkit/src/utils/string-utils';\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`import { dasherize } from '@nx/devkit/internal';\n`
);
});
it('routes a single public symbol to @nx/devkit', () => {
const input = `import { names } from '@nx/devkit/src/utils/names';\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`import { names } from '@nx/devkit';\n`
);
});
it('splits mixed public and internal symbols across two declarations', () => {
const input = `import { dasherize, names } from '@nx/devkit/src/utils/string-utils';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`import { names } from '@nx/devkit';`);
expect(output).toContain(
`import { dasherize } from '@nx/devkit/internal';`
);
});
it('preserves `as` aliases', () => {
const input = `import { dasherize as toKebab } from '@nx/devkit/src/utils/string-utils';\n`;
expect(rewriteDevkitDeepImports(input)).toContain(
`import { dasherize as toKebab } from '@nx/devkit/internal';`
);
});
it('handles `import type` declarations', () => {
const input = `import type { FileExtensionType } from '@nx/devkit/src/generators/artifact-name-and-directory-utils';\n`;
expect(rewriteDevkitDeepImports(input)).toContain(
`import type { FileExtensionType } from '@nx/devkit/internal';`
);
});
it('preserves inline `type` modifiers on individual specifiers', () => {
const input = `import { type FileExtensionType, addPlugin } from '@nx/devkit/src/x';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(
`import { type FileExtensionType, addPlugin } from '@nx/devkit/internal';`
);
});
it('drops redundant inline `type` when the whole import is already type-only', () => {
const input = `import type { type FileExtensionType } from '@nx/devkit/src/x';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(
`import type { FileExtensionType } from '@nx/devkit/internal';`
);
});
it('handles multi-line named imports', () => {
const input = `import {\n dasherize,\n names,\n classify,\n} from '@nx/devkit/src/utils/string-utils';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`import { names } from '@nx/devkit';`);
expect(output).toContain(
`import { dasherize, classify } from '@nx/devkit/internal';`
);
});
it('rewrites multiple deep imports in one file', () => {
const input =
`import { dasherize } from '@nx/devkit/src/utils/string-utils';\n` +
`import { names } from '@nx/devkit/src/utils/names';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(
`import { dasherize } from '@nx/devkit/internal';`
);
expect(output).toContain(`import { names } from '@nx/devkit';`);
});
it('falls back to /internal for side-effect imports', () => {
const input = `import '@nx/devkit/src/utils/some-side-effect';\n`;
expect(rewriteDevkitDeepImports(input)).toContain(
`import '@nx/devkit/internal';`
);
});
it('falls back to /internal for default imports', () => {
const input = `import x from '@nx/devkit/src/utils/foo';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`'@nx/devkit/internal'`);
expect(output).toContain(`import x from`);
});
it('falls back to /internal for namespace imports', () => {
const input = `import * as devkit from '@nx/devkit/src/utils/foo';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(
`import * as devkit from '@nx/devkit/internal';`
);
});
it('falls back to /internal for require()', () => {
const input = `const x = require('@nx/devkit/src/utils/foo');\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`const x = require('@nx/devkit/internal');\n`
);
});
it('falls back to /internal for dynamic import()', () => {
const input = `const x = await import('@nx/devkit/src/utils/foo');\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`const x = await import('@nx/devkit/internal');\n`
);
});
it('preserves quote style on fallback paths', () => {
const input = `const x = require("@nx/devkit/src/utils/foo");\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`const x = require("@nx/devkit/internal");\n`
);
});
it('does not touch unrelated @nx/devkit imports', () => {
const input = `import { Tree } from '@nx/devkit';\n`;
expect(rewriteDevkitDeepImports(input)).toBe(input);
});
it('does not touch unrelated @nx/devkit/internal imports', () => {
const input = `import { dasherize } from '@nx/devkit/internal';\n`;
expect(rewriteDevkitDeepImports(input)).toBe(input);
});
});
// These tests guard against an earlier implementation that string-replaced
// every `'@nx/devkit/src/...'` literal in the file. That over-eager rewrite
// mangled test fixtures inside template literals, type queries, comments,
// and other non-runtime usages. The current AST-based pass must leave them
// alone.
describe('non-runtime string literals', () => {
it('does not rewrite deep-import paths inside template literals', () => {
const input = `const fixture = \`import { dasherize } from '@nx/devkit/src/utils/string-utils';\\n\`;\n`;
expect(rewriteDevkitDeepImports(input)).toBe(input);
});
it('does not rewrite deep-import paths inside `typeof import(...)` type queries', () => {
const input = `type Devkit = typeof import('@nx/devkit/src/executors/parse-target-string');\n`;
expect(rewriteDevkitDeepImports(input)).toBe(input);
});
it('does not rewrite deep-import paths inside block comments', () => {
const input = `/* see @nx/devkit/src/utils/foo */\nconst x = 1;\n`;
expect(rewriteDevkitDeepImports(input)).toBe(input);
});
it('does not rewrite deep-import paths inside line comments', () => {
const input = `// see '@nx/devkit/src/utils/foo'\nconst x = 1;\n`;
expect(rewriteDevkitDeepImports(input)).toBe(input);
});
it('does not rewrite arbitrary string-literal arguments to unrelated calls', () => {
const input = `someUnrelatedFn('@nx/devkit/src/utils/foo');\n`;
expect(rewriteDevkitDeepImports(input)).toBe(input);
});
});
describe('mock helper calls', () => {
it('rewrites jest.mock(...) targets', () => {
const input = `jest.mock('@nx/devkit/src/utils/foo');\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`jest.mock('@nx/devkit/internal');\n`
);
});
it('rewrites jest.requireActual(...) targets', () => {
const input = `const real = jest.requireActual('@nx/devkit/src/utils/foo');\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`const real = jest.requireActual('@nx/devkit/internal');\n`
);
});
it('rewrites vi.mock(...) targets', () => {
const input = `vi.mock('@nx/devkit/src/utils/foo');\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`vi.mock('@nx/devkit/internal');\n`
);
});
it('rewrites vi.importActual(...) targets', () => {
const input = `const real = await vi.importActual('@nx/devkit/src/utils/foo');\n`;
expect(rewriteDevkitDeepImports(input)).toBe(
`const real = await vi.importActual('@nx/devkit/internal');\n`
);
});
it('rewrites paired import + jest.mock together', () => {
const input =
`import * as cfg from '@nx/devkit/src/utils/config-utils';\n` +
`jest.mock('@nx/devkit/src/utils/config-utils', () => ({\n` +
` ...jest.requireActual('@nx/devkit/src/utils/config-utils'),\n` +
`}));\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`import * as cfg from '@nx/devkit/internal';`);
expect(output).toContain(`jest.mock('@nx/devkit/internal',`);
expect(output).toContain(`jest.requireActual('@nx/devkit/internal')`);
});
});
describe('migration runner', () => {
it('rewrites deep imports across .ts/.tsx/.cts/.mts files', async () => {
tree.write(
'libs/foo/src/a.ts',
`import { dasherize } from '@nx/devkit/src/utils/string-utils';\n`
);
tree.write(
'libs/foo/src/b.tsx',
`import { addPlugin } from '@nx/devkit/src/utils/add-plugin';\n`
);
tree.write(
'libs/foo/src/c.cts',
`const { classify } = require('@nx/devkit/src/utils/string-utils');\n`
);
tree.write(
'libs/foo/src/d.mts',
`import { camelize } from '@nx/devkit/src/utils/string-utils';\n`
);
await migration(tree);
expect(tree.read('libs/foo/src/a.ts', 'utf-8')).toContain(
`'@nx/devkit/internal'`
);
expect(tree.read('libs/foo/src/b.tsx', 'utf-8')).toContain(
`'@nx/devkit/internal'`
);
expect(tree.read('libs/foo/src/c.cts', 'utf-8')).toContain(
`'@nx/devkit/internal'`
);
expect(tree.read('libs/foo/src/d.mts', 'utf-8')).toContain(
`'@nx/devkit/internal'`
);
});
it('does not rewrite deep-import strings inside non-TS files', async () => {
const md = `Example: \`import { x } from '@nx/devkit/src/utils/foo';\`\n`;
tree.write('docs/example.md', md);
await migration(tree);
// The deep-import literal must survive untouched in markdown — only TS
// sources should be rewritten. (formatFiles may normalize trailing
// whitespace, so we assert on substring rather than full equality.)
expect(tree.read('docs/example.md', 'utf-8')).toContain(
`'@nx/devkit/src/utils/foo'`
);
});
it('does not touch files that do not contain the deep prefix', async () => {
const content = `import { Tree } from '@nx/devkit';\n`;
tree.write('libs/foo/src/index.ts', content);
await migration(tree);
expect(tree.read('libs/foo/src/index.ts', 'utf-8')).toBe(content);
});
});
describe('collapse', () => {
it('merges a rewritten import into a pre-existing @nx/devkit import', () => {
const input =
`import { Tree } from '@nx/devkit';\n` +
`import { names } from '@nx/devkit/src/utils/names';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`import { Tree, names } from '@nx/devkit';`);
expect(output).not.toMatch(
/import \{[^}]*\} from '@nx\/devkit';[\s\S]*import \{[^}]*\} from '@nx\/devkit';/
);
});
it('merges a rewritten import into a pre-existing @nx/devkit/internal import', () => {
const input =
`import { dasherize } from '@nx/devkit/internal';\n` +
`import { classify } from '@nx/devkit/src/utils/string-utils';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(
`import { dasherize, classify } from '@nx/devkit/internal';`
);
});
it('merges multiple rewritten imports that target the same specifier', () => {
const input =
`import { dasherize } from '@nx/devkit/src/utils/string-utils';\n` +
`import { classify } from '@nx/devkit/src/utils/string-utils';\n` +
`import { camelize } from '@nx/devkit/src/utils/string-utils';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(
`import { dasherize, classify, camelize } from '@nx/devkit/internal';`
);
// Only one /internal declaration in the output.
expect((output.match(/from '@nx\/devkit\/internal'/g) ?? []).length).toBe(
1
);
});
it('keeps public and internal collapses independent', () => {
const input =
`import { Tree } from '@nx/devkit';\n` +
`import { dasherize, names } from '@nx/devkit/src/utils/string-utils';\n` +
`import { classify } from '@nx/devkit/src/utils/string-utils';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`import { Tree, names } from '@nx/devkit';`);
expect(output).toContain(
`import { dasherize, classify } from '@nx/devkit/internal';`
);
});
it('does not merge value imports with type-only imports', () => {
const input =
`import type { Tree } from '@nx/devkit';\n` +
`import { names } from '@nx/devkit/src/utils/names';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`import type { Tree } from '@nx/devkit';`);
expect(output).toContain(`import { names } from '@nx/devkit';`);
});
it('merges duplicate specifiers without repeating them', () => {
const input =
`import { Tree } from '@nx/devkit';\n` +
`import { Tree, names } from '@nx/devkit';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`import { Tree, names } from '@nx/devkit';`);
expect((output.match(/Tree/g) ?? []).length).toBe(1);
});
it('does not touch a file that already has a single canonical import', () => {
const input = `import { Tree, names } from '@nx/devkit';\n`;
expect(rewriteDevkitDeepImports(input)).toBe(input);
});
it('leaves unrelated imports between merged declarations alone', () => {
const input =
`import { Tree } from '@nx/devkit';\n` +
`import { readFileSync } from 'node:fs';\n` +
`import { names } from '@nx/devkit/src/utils/names';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(`import { Tree, names } from '@nx/devkit';`);
expect(output).toContain(`import { readFileSync } from 'node:fs';`);
});
});
describe('symbol set sanity', () => {
it('treats every name in DEVKIT_INTERNAL_SYMBOLS as internal', () => {
for (const name of DEVKIT_INTERNAL_SYMBOLS) {
const input = `import { ${name} } from '@nx/devkit/src/x';\n`;
const output = rewriteDevkitDeepImports(input);
expect(output).toContain(
`import { ${name} } from '@nx/devkit/internal';`
);
}
});
});
});
@@ -0,0 +1,414 @@
import { logger, type Tree } from 'nx/src/devkit-exports';
import type {
CallExpression,
ImportDeclaration,
ImportSpecifier,
NamedImports,
Node,
SourceFile,
} from 'typescript';
import { formatFiles } from '../../generators/format-files';
import { visitNotIgnoredFiles } from '../../generators/visit-not-ignored-files';
import { ensurePackage } from '../../utils/package-json';
import {
applyChangesToString,
ChangeType,
type StringChange,
} from '../../utils/string-change';
import { typescriptVersion } from '../../utils/versions';
const TS_EXTENSIONS = ['.ts', '.tsx', '.cts', '.mts'] as const;
const DEEP_IMPORT_PREFIX = '@nx/devkit/src/';
const PUBLIC_SPECIFIER = '@nx/devkit';
const INTERNAL_SPECIFIER = '@nx/devkit/internal';
// Names re-exported from `@nx/devkit/internal` (see packages/devkit/internal.ts
// at the time this migration was authored). Anything imported from a
// `@nx/devkit/src/...` path whose name is NOT in this set is assumed to be
// part of the stable public `@nx/devkit` API.
export const DEVKIT_INTERNAL_SYMBOLS: ReadonlySet<string> = new Set([
'signalToCode',
'createProjectRootMappingsFromProjectConfigurations',
'PluginCache',
'safeWriteFileCache',
'determineArtifactNameAndDirectoryOptions',
'getRelativeCwd',
'FileExtensionType',
'getE2EWebServerInfo',
'E2EWebServerDetails',
'forEachExecutorOptions',
'AggregatedLog',
'migrateProjectExecutorsToPlugin',
'migrateProjectExecutorsToPluginV1',
'NoTargetsToMigrateError',
'InferredTargetConfiguration',
'processTargetOutputs',
'deleteMatchingProperties',
'toProjectRelativePath',
'determineProjectNameAndRootOptions',
'ensureRootProjectName',
'resolveImportPath',
'promptWhenInteractive',
'addBuildTargetDefaults',
'addE2eCiTargetDefaults',
'addPlugin',
'createAsyncIterable',
'combineAsyncIterables',
'mapAsyncIterable',
'calculateHashForCreateNodes',
'calculateHashesForCreateNodes',
'getCatalogManager',
'loadConfigFile',
'clearRequireCache',
'findPluginForConfigFile',
'getNamedInputs',
'logShowProjectCommand',
'eachValueFrom',
'checkAndCleanWithSemver',
'camelize',
'capitalize',
'classify',
'dasherize',
'emitPluginWorkerLog',
'throwForUnsupportedVersion',
]);
// Methods on `jest` and `vi` that take a module specifier as their first
// argument. Calls like `jest.mock('@nx/devkit/src/...')` are rewritten so the
// mock target lines up with the rewritten import.
const MOCK_HELPER_METHODS: ReadonlySet<string> = new Set([
'mock',
'unmock',
'doMock',
'dontMock',
'requireActual',
'requireMock',
'importActual',
'importMock',
]);
let ts: typeof import('typescript') | undefined;
export default async function updateDevkitDeepImports(
tree: Tree
): Promise<void> {
let touchedCount = 0;
visitNotIgnoredFiles(tree, '.', (filePath) => {
if (!TS_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
return;
}
const original = tree.read(filePath, 'utf-8');
if (!original || !original.includes(DEEP_IMPORT_PREFIX)) {
return;
}
const updated = rewriteDevkitDeepImports(original);
if (updated !== original) {
tree.write(filePath, updated);
touchedCount += 1;
}
});
if (touchedCount > 0) {
logger.info(`Rewrote @nx/devkit deep imports in ${touchedCount} file(s).`);
}
await formatFiles(tree);
}
export function rewriteDevkitDeepImports(source: string): string {
ts ??= ensurePackage<typeof import('typescript')>(
'typescript',
typescriptVersion
);
const sourceFile = ts.createSourceFile(
'tmp.ts',
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
ts.ScriptKind.TSX
);
const changes: StringChange[] = [];
for (const stmt of sourceFile.statements) {
if (!ts.isImportDeclaration(stmt)) continue;
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
if (!stmt.moduleSpecifier.text.startsWith(DEEP_IMPORT_PREFIX)) continue;
const replacement = buildReplacement(stmt, sourceFile);
changes.push(
{
type: ChangeType.Delete,
start: stmt.getStart(sourceFile),
length: stmt.getEnd() - stmt.getStart(sourceFile),
},
{
type: ChangeType.Insert,
index: stmt.getStart(sourceFile),
text: replacement,
}
);
}
// Pass 2: rewrite `require('@nx/devkit/src/...')`, dynamic
// `import('@nx/devkit/src/...')`, and `jest.mock(...)` / `vi.mock(...)`-style
// calls. We can't bucket these by symbol (no named binding to inspect), so
// we route them at `/internal` as a best guess. Walking the AST instead of
// string-replacing keeps us out of unrelated string literals — template
// strings, `typeof import('...')` type queries, comments, etc.
collectCallExpressionRewrites(sourceFile, changes);
let updated =
changes.length > 0 ? applyChangesToString(source, changes) : source;
// Final pass: collapse any duplicate `@nx/devkit` and `@nx/devkit/internal`
// named-only imports into a single declaration. This handles both the
// imports we just emitted AND any that the user already had, so we never
// leave the file with two `from '@nx/devkit'` lines.
updated = collapseDevkitImports(updated);
return updated;
}
function collapseDevkitImports(source: string): string {
const sourceFile = ts!.createSourceFile(
'tmp.ts',
source,
ts!.ScriptTarget.Latest,
/* setParentNodes */ true,
ts!.ScriptKind.TSX
);
// Group eligible declarations by (specifier, isTypeOnly).
type Group = {
decls: ImportDeclaration[];
specifier: string;
typeOnly: boolean;
};
const groups = new Map<string, Group>();
for (const stmt of sourceFile.statements) {
if (!ts!.isImportDeclaration(stmt)) continue;
if (!ts!.isStringLiteral(stmt.moduleSpecifier)) continue;
const specifier = stmt.moduleSpecifier.text;
if (specifier !== PUBLIC_SPECIFIER && specifier !== INTERNAL_SPECIFIER) {
continue;
}
const importClause = stmt.importClause;
if (!importClause) continue; // skip side-effect imports
if (importClause.name) continue; // skip default imports
const namedBindings = importClause.namedBindings;
if (!namedBindings || !ts!.isNamedImports(namedBindings)) continue;
const typeOnly = !!importClause.isTypeOnly;
const key = `${specifier}\x00${typeOnly ? 'type' : 'value'}`;
if (!groups.has(key)) {
groups.set(key, { decls: [], specifier, typeOnly });
}
groups.get(key)!.decls.push(stmt);
}
const changes: StringChange[] = [];
for (const { decls, specifier, typeOnly } of groups.values()) {
if (decls.length < 2) continue;
const seen = new Set<string>();
const merged: string[] = [];
for (const decl of decls) {
const named = decl.importClause!.namedBindings as NamedImports;
for (const el of named.elements) {
const text = renderSpecifierFromNode(el, typeOnly);
if (!seen.has(text)) {
seen.add(text);
merged.push(text);
}
}
}
// Replace the first declaration with the merged one in place.
const first = decls[0];
changes.push(
{
type: ChangeType.Delete,
start: first.getStart(sourceFile),
length: first.getEnd() - first.getStart(sourceFile),
},
{
type: ChangeType.Insert,
index: first.getStart(sourceFile),
text: renderImport(merged, specifier, typeOnly),
}
);
// Delete every other declaration in this group (and consume one trailing
// newline so we don't leave behind a blank line that prettier has to clean
// up later).
for (let i = 1; i < decls.length; i++) {
const decl = decls[i];
const start = decl.getStart(sourceFile);
let end = decl.getEnd();
if (source[end] === '\n') {
end += 1;
} else if (source[end] === '\r' && source[end + 1] === '\n') {
end += 2;
}
changes.push({
type: ChangeType.Delete,
start,
length: end - start,
});
}
}
return changes.length > 0 ? applyChangesToString(source, changes) : source;
}
function renderSpecifierFromNode(
el: ImportSpecifier,
parentIsTypeOnly: boolean
): string {
const aliasText = el.propertyName ? ` as ${el.name.text}` : '';
const typePrefix = !parentIsTypeOnly && el.isTypeOnly ? 'type ' : '';
return `${typePrefix}${(el.propertyName ?? el.name).text}${aliasText}`;
}
function buildReplacement(
decl: ImportDeclaration,
sourceFile: SourceFile
): string {
const importClause = decl.importClause;
// `import '@nx/devkit/src/...';` (side-effect) — no clause to bucket.
if (!importClause) {
return `import '${INTERNAL_SPECIFIER}';`;
}
const namedBindings = importClause.namedBindings;
const isNamedImport =
namedBindings && ts!.isNamedImports(namedBindings) && !importClause.name;
// Default / namespace / mixed-default-and-named — can't bucket reliably.
// Preserve the import shape, swap the specifier.
if (!isNamedImport) {
const before = source(decl, sourceFile).slice(
0,
decl.moduleSpecifier.getStart(sourceFile) - decl.getStart(sourceFile)
);
const after = source(decl, sourceFile).slice(
decl.moduleSpecifier.getEnd() - decl.getStart(sourceFile)
);
return `${before}'${INTERNAL_SPECIFIER}'${after}`;
}
const isTypeOnlyImport = importClause.isTypeOnly;
const elements = (namedBindings as NamedImports).elements;
const publik: string[] = [];
const internal: string[] = [];
for (const el of elements) {
bucketSpecifier(el, isTypeOnlyImport, publik, internal);
}
const lines: string[] = [];
if (publik.length > 0) {
lines.push(renderImport(publik, PUBLIC_SPECIFIER, isTypeOnlyImport));
}
if (internal.length > 0) {
lines.push(renderImport(internal, INTERNAL_SPECIFIER, isTypeOnlyImport));
}
if (lines.length === 0) {
// Defensive: empty `import {} from '...'` — point at /internal.
lines.push(`import {} from '${INTERNAL_SPECIFIER}';`);
}
return lines.join('\n');
}
function bucketSpecifier(
el: ImportSpecifier,
parentIsTypeOnly: boolean,
publik: string[],
internal: string[]
): void {
const lookupName = (el.propertyName ?? el.name).text;
const elementIsTypeOnly = el.isTypeOnly;
const aliasText = el.propertyName ? ` as ${el.name.text}` : '';
// Inline `type` is illegal when the parent import is already `import type`.
const typePrefix = !parentIsTypeOnly && elementIsTypeOnly ? 'type ' : '';
const text = `${typePrefix}${(el.propertyName ?? el.name).text}${aliasText}`;
if (DEVKIT_INTERNAL_SYMBOLS.has(lookupName)) {
internal.push(text);
} else {
publik.push(text);
}
}
function renderImport(
specifiers: string[],
from: string,
typeOnly: boolean
): string {
const prefix = typeOnly ? 'import type' : 'import';
return `${prefix} { ${specifiers.join(', ')} } from '${from}';`;
}
function source(decl: ImportDeclaration, sourceFile: SourceFile): string {
return sourceFile.text.slice(decl.getStart(sourceFile), decl.getEnd());
}
function collectCallExpressionRewrites(
sourceFile: SourceFile,
changes: StringChange[]
): void {
const visit = (node: Node): void => {
if (
ts!.isCallExpression(node) &&
shouldRewriteCallExpression(node) &&
node.arguments.length >= 1 &&
ts!.isStringLiteral(node.arguments[0]) &&
node.arguments[0].text.startsWith(DEEP_IMPORT_PREFIX)
) {
const arg = node.arguments[0];
const start = arg.getStart(sourceFile);
const end = arg.getEnd();
const quote = sourceFile.text.charAt(start);
changes.push(
{
type: ChangeType.Delete,
start,
length: end - start,
},
{
type: ChangeType.Insert,
index: start,
text: `${quote}${INTERNAL_SPECIFIER}${quote}`,
}
);
}
ts!.forEachChild(node, visit);
};
visit(sourceFile);
}
function shouldRewriteCallExpression(call: CallExpression): boolean {
const callee = call.expression;
// `require('...')`
if (ts!.isIdentifier(callee) && callee.text === 'require') return true;
// dynamic `import('...')` — the runtime form parses as a CallExpression
// whose callee is the `import` keyword. The type-position form
// (`typeof import('...')`) parses as `ImportTypeNode`, not a CallExpression,
// so we don't touch it.
if (callee.kind === ts!.SyntaxKind.ImportKeyword) return true;
// `jest.mock(...)` / `vi.mock(...)` and friends.
if (ts!.isPropertyAccessExpression(callee)) {
const obj = callee.expression;
if (
ts!.isIdentifier(obj) &&
(obj.text === 'jest' || obj.text === 'vi') &&
MOCK_HELPER_METHODS.has(callee.name.text)
) {
return true;
}
}
return false;
}
@@ -0,0 +1,60 @@
import { execSync, type ExecSyncOptions } from 'child_process';
import { join } from 'path';
import {
detectPackageManager,
getPackageManagerCommand,
joinPathFragments,
PackageManager,
Tree,
} from 'nx/src/devkit-exports';
/**
* Runs `npm install` or `yarn install`. It will skip running the install if
* `package.json` hasn't changed at all or it hasn't changed since the last invocation.
*
* @param tree - the file system tree
* @param ensureInstall - ensure install runs even if `package.json` hasn't changed,
* unless install already ran this generator cycle.
*/
export function installPackagesTask(
tree: Tree,
ensureInstall: boolean = false,
cwd: string = '',
packageManager: PackageManager = detectPackageManager(join(tree.root, cwd))
): void {
const packageJsonPath = joinPathFragments(cwd, 'package.json');
const packageJsonChanged = tree
.listChanges()
.some((f) => f.path === packageJsonPath);
if (!packageJsonChanged && !ensureInstall) {
return;
}
const packageJsonValue = tree.read(packageJsonPath, 'utf-8');
const storedPackageJsonValue: string = global['__packageJsonInstallCache__'];
const installAlreadyRan = storedPackageJsonValue != null;
const packageJsonDiffers = storedPackageJsonValue != packageJsonValue;
if (packageJsonDiffers || (ensureInstall && !installAlreadyRan)) {
global['__packageJsonInstallCache__'] = packageJsonValue;
const pmc = getPackageManagerCommand(packageManager);
const execSyncOptions: ExecSyncOptions = {
cwd: join(tree.root, cwd),
stdio: process.env.NX_GENERATE_QUIET === 'true' ? 'ignore' : 'inherit',
windowsHide: true,
};
// ensure local registry from process is not interfering with the install
// when we start the process from temp folder the local registry would override the custom registry
if (
process.env.npm_config_registry &&
process.env.npm_config_registry.match(
/^https:\/\/registry\.(npmjs\.org|yarnpkg\.com)/
)
) {
delete process.env.npm_config_registry;
}
execSync(pmc.install, execSyncOptions);
}
}
@@ -0,0 +1,607 @@
import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace';
import type { Tree } from 'nx/src/generators/tree';
import { readJson, writeJson } from 'nx/src/generators/utils/json';
import type { PackageJson } from 'nx/src/utils/package-json';
import { CreateNodes } from 'nx/src/project-graph/plugins';
import { ProjectGraph } from 'nx/src/devkit-exports';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { addPlugin, generateCombinations } from './add-plugin';
describe('addPlugin', () => {
let tree: Tree;
let createNodes: CreateNodes<{ targetName: string }>;
let graph: ProjectGraph;
let fs: TempFs;
beforeEach(async () => {
tree = createTreeWithEmptyWorkspace();
fs = new TempFs('add-plugin');
tree.root = fs.tempDir;
graph = {
nodes: {
app1: {
name: 'app1',
type: 'app',
data: {
root: 'app1',
targets: {},
},
},
app2: {
name: 'app2',
type: 'app',
data: {
root: 'app2',
targets: {},
},
},
app3: {
name: 'app3',
type: 'app',
data: {
root: 'app3',
targets: {},
},
},
},
dependencies: {
app1: [],
app2: [],
app3: [],
},
};
createNodes = [
'**/next.config.{ts,js,cjs,mjs}',
(_, { targetName }) => [
[
'app1/next.config.js',
{
projects: {
app1: {
name: 'app1',
targets: {
[targetName]: { command: 'next build' },
},
},
},
},
],
[
'app2/next.config.js',
{
projects: {
app2: {
name: 'app2',
targets: {
[targetName]: { command: 'next build' },
},
},
},
},
],
],
];
await fs.createFiles({
'app1/next.config.js': '',
'app2/next.config.js': '',
});
});
afterEach(() => {
fs.cleanup();
});
describe('adding the plugin', () => {
it('should not conflicting with the existing graph', async () => {
graph.nodes.app1.data.targets.build = {};
graph.nodes.app2.data.targets.build1 = {};
// app 3 doesn't have a next config, so it having this
// target should not affect the plugin options
graph.nodes.app3.data.targets.build2 = {};
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build', 'build1', 'build2'],
},
true
);
expect(readJson(tree, 'nx.json').plugins).toContainEqual({
plugin: '@nx/next/plugin',
options: {
targetName: 'build2',
},
});
});
});
it('should throw an error if no non-conflicting options are provided', async () => {
graph.nodes.app1.data.targets.build = {};
try {
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
fail('Should have thrown an error');
} catch (e) {}
});
describe('updating package scripts', () => {
test.each`
script
${'next-remote-watch'}
${'anext build'}
${'next builda'}
`('should not replace "$script"', async ({ script }) => {
writeJson(tree, 'app1/package.json', {
name: 'app1',
scripts: {
build: script,
},
});
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
const { scripts } = readJson<PackageJson>(tree, 'app1/package.json');
expect(scripts.build).toBe(script);
});
test.each`
script | expected
${'next build'} | ${'nx build'}
${'npx next build'} | ${'npx nx build'}
${'next build --debug'} | ${'nx build --debug'}
${'NODE_OPTIONS="--inspect" next build'} | ${'NODE_OPTIONS="--inspect" nx build'}
${'NODE_OPTIONS="--inspect" npx next build --debug'} | ${'NODE_OPTIONS="--inspect" npx nx build --debug'}
${'next build && echo "Done"'} | ${'nx build && echo "Done"'}
${'echo "Building..." && next build'} | ${'echo "Building..." && nx build'}
${'echo "Building..." && next build && echo "Done"'} | ${'echo "Building..." && nx build && echo "Done"'}
${'echo "Building..." &&next build&& echo "Done"'} | ${'echo "Building..." &&nx build&& echo "Done"'}
${'echo "Building..." && NODE_OPTIONS="--inspect" npx next build --debug && echo "Done"'} | ${'echo "Building..." && NODE_OPTIONS="--inspect" npx nx build --debug && echo "Done"'}
`(
'should replace "$script" with "$expected"',
async ({ script, expected }) => {
writeJson(tree, 'app1/package.json', {
name: 'app1',
scripts: {
build: script,
},
});
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
const { scripts } = readJson<PackageJson>(tree, 'app1/package.json');
expect(scripts.build).toBe(expected);
}
);
test.each`
script | expected
${'cypress run --e2e --config-file cypress.config.ts'} | ${'nx e2e'}
${'echo "Starting..." && cypress run --e2e --config-file cypress.config.ts && echo "Done"'} | ${'echo "Starting..." && nx e2e && echo "Done"'}
`(
'should replace "$script" with "$expected"',
async ({ script, expected }) => {
await fs.createFile('app1/cypress.config.ts', '');
writeJson(tree, 'app1/package.json', {
name: 'app1',
scripts: {
e2e: script,
},
});
createNodes = [
'**/cypress.config.{js,ts,mjs,mts,cjs,cts}',
() => [
[
'app1/cypress.config.ts',
{
projects: {
app1: {
name: 'app1',
targets: {
e2e: {
command:
'cypress run --config-file cypress.config.ts --e2e',
},
},
},
},
},
],
],
];
await addPlugin(
tree,
graph,
'@nx/cypress/plugin',
createNodes,
{
targetName: ['e2e'],
},
true
);
const { scripts } = readJson<PackageJson>(tree, 'app1/package.json');
expect(scripts.e2e).toBe(expected);
}
);
it('should handle scripts with name different than the target name', async () => {
writeJson(tree, 'app1/package.json', {
name: 'app1',
scripts: {
'build:dev': 'next build',
},
});
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
const { scripts } = readJson<PackageJson>(tree, 'app1/package.json');
expect(scripts['build:dev']).toBe('nx build');
});
it('should support replacing scripts where a command is the same as the cli entry point', async () => {
writeJson(tree, 'app1/package.json', {
name: 'app1',
scripts: {
dev: 'next',
build: 'tsc -b && next build',
preview: 'next preview',
},
});
createNodes = [
'**/next.config.{ts,js,cjs,mjs}',
() => [
[
'app1/next.config.js',
{
projects: {
app1: {
name: 'app1',
targets: {
build: { command: 'next build' },
dev: { command: 'next' },
preview: { command: 'next preview' },
},
},
},
},
],
],
];
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
const { scripts } = readJson<PackageJson>(tree, 'app1/package.json');
expect(scripts.dev).toBe('nx dev');
expect(scripts.build).toBe('tsc -b && nx build');
expect(scripts.preview).toBe('nx preview');
});
it('should support replacing multiple scripts', async () => {
writeJson(tree, 'app1/package.json', {
name: 'app1',
scripts: {
dev: 'PORT=4000 next dev --experimental-https',
start: 'next build && PORT=4000 next start --experimental-https',
},
});
createNodes = [
'**/next.config.{ts,js,cjs,mjs}',
() => [
[
'app1/next.config.js',
{
projects: {
app1: {
name: 'app1',
targets: {
build: { command: 'next build' },
dev: { command: 'next dev' },
start: { command: 'next start' },
},
},
},
},
],
],
];
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
const { scripts } = readJson<PackageJson>(tree, 'app1/package.json');
expect(scripts.dev).toBe('PORT=4000 nx dev --experimental-https');
expect(scripts.start).toBe(
'nx build && PORT=4000 nx start --experimental-https'
);
});
it('should support multiple occurrences of the same command within a script', async () => {
await fs.createFile('app1/tsconfig.json', '');
writeJson(tree, 'app1/package.json', {
name: 'app1',
scripts: {
typecheck: 'tsc -p tsconfig.lib.json && tsc -p tsconfig.spec.json',
},
});
createNodes = [
'**/tsconfig.json',
() => [
[
'app1/tsconfig.json',
{
projects: {
app1: {
name: 'app1',
targets: {
build: { command: 'tsc' },
},
},
},
},
],
],
];
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
const { scripts } = readJson<PackageJson>(tree, 'app1/package.json');
expect(scripts.typecheck).toBe(
'nx build -p tsconfig.lib.json && nx build -p tsconfig.spec.json'
);
});
it('should support multiple occurrences of the same command within a script with extra commands', async () => {
await fs.createFile('app1/tsconfig.json', '');
writeJson(tree, 'app1/package.json', {
name: 'app1',
scripts: {
typecheck:
'echo "Typechecking..." && tsc -p tsconfig.lib.json && tsc -p tsconfig.spec.json && echo "Done"',
},
});
createNodes = [
'**/tsconfig.json',
() => [
[
'app1/tsconfig.json',
{
projects: {
app1: {
name: 'app1',
targets: {
build: { command: 'tsc' },
},
},
},
},
],
],
];
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
const { scripts } = readJson<PackageJson>(tree, 'app1/package.json');
expect(scripts.typecheck).toBe(
'echo "Typechecking..." && nx build -p tsconfig.lib.json && nx build -p tsconfig.spec.json && echo "Done"'
);
});
it('should not touch the package.json when there are no changes to make', async () => {
// package.json with mixed/bad indentation and array value in a single line
// JSON serialization would have a standard indentation and would expand the array value into multiple lines
const packageJsonContent = `{
"name": "app1",
"scripts": {
"build": "tsc --build"
},
"keywords": ["foo", "bar", "baz"]
}`;
tree.write('app1/package.json', packageJsonContent);
await addPlugin(
tree,
graph,
'@nx/next/plugin',
createNodes,
{
targetName: ['build'],
},
true
);
expect(tree.read('app1/package.json', 'utf-8')).toBe(packageJsonContent);
});
});
});
describe('generateCombinations', () => {
it('should return all combinations for a 2x2 array of strings', () => {
const input = {
prop1: ['1', '2'],
prop2: ['a', 'b'],
};
expect(generateCombinations(input).map((v) => JSON.stringify(v)))
.toMatchInlineSnapshot(`
[
"{"prop1":"1","prop2":"a"}",
"{"prop1":"2","prop2":"a"}",
"{"prop1":"1","prop2":"b"}",
"{"prop1":"2","prop2":"b"}",
]
`);
});
it('should handle arrays of 2x3 array of strings', () => {
const input = {
prop1: ['1', '2', '3'],
prop2: ['a', 'b'],
};
expect(generateCombinations(input).map((v) => JSON.stringify(v)))
.toMatchInlineSnapshot(`
[
"{"prop1":"1","prop2":"a"}",
"{"prop1":"2","prop2":"a"}",
"{"prop1":"3","prop2":"a"}",
"{"prop1":"1","prop2":"b"}",
"{"prop1":"2","prop2":"b"}",
"{"prop1":"3","prop2":"b"}",
]
`);
});
it('should handle arrays of 3x2 array of strings', () => {
const input = {
prop1: ['1', '2'],
prop2: ['a', 'b'],
prop3: ['i', 'ii'],
};
expect(generateCombinations(input).map((v) => JSON.stringify(v)))
.toMatchInlineSnapshot(`
[
"{"prop1":"1","prop2":"a","prop3":"i"}",
"{"prop1":"2","prop2":"a","prop3":"i"}",
"{"prop1":"1","prop2":"b","prop3":"i"}",
"{"prop1":"2","prop2":"b","prop3":"i"}",
"{"prop1":"1","prop2":"a","prop3":"ii"}",
"{"prop1":"2","prop2":"a","prop3":"ii"}",
"{"prop1":"1","prop2":"b","prop3":"ii"}",
"{"prop1":"2","prop2":"b","prop3":"ii"}",
]
`);
});
it('should be able to be used to generate possible combinations of plugin options', () => {
const possibleOptions = generateCombinations({
buildTargetName: ['build', 'vite:build', 'vite-build'],
testTargetName: ['test', 'vite:test', 'vite-test'],
serveTargetName: ['serve', 'vite:serve', 'vite-serve'],
previewTargetName: ['preview', 'vite:preview', 'vite-preview'],
serveStaticTargetName: [
'serve-static',
'vite:serve-static',
'vite-serve-static',
],
});
expect(possibleOptions.length).toEqual(3 ** 5);
expect(possibleOptions[0]).toEqual({
buildTargetName: 'build',
previewTargetName: 'preview',
testTargetName: 'test',
serveTargetName: 'serve',
serveStaticTargetName: 'serve-static',
});
// The first defined option is the first to be changed
expect(possibleOptions[1]).toEqual({
buildTargetName: 'vite:build',
previewTargetName: 'preview',
testTargetName: 'test',
serveTargetName: 'serve',
serveStaticTargetName: 'serve-static',
});
expect(possibleOptions[possibleOptions.length - 1]).toEqual({
buildTargetName: 'vite-build',
previewTargetName: 'vite-preview',
testTargetName: 'vite-test',
serveTargetName: 'vite-serve',
serveStaticTargetName: 'vite-serve-static',
});
});
});
+452
View File
@@ -0,0 +1,452 @@
import type { PackageJson } from 'nx/src/utils/package-json';
import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils';
import yargs from 'yargs-parser';
import {
CreateNodes,
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: 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
);
}
/**
* @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(
{
specifiedPlugins: [pluginFactory(pluginOptions)],
defaultPlugins: [],
},
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(
{
specifiedPlugins: [pluginFactory(pluginOptions)],
defaultPlugins: [],
},
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;
targetCommands.sort(
(a, b) => b.command.split(/\s/).length - a.command.split(/\s/).length
);
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
const script = packageJson.scripts[scriptName];
packageJson.scripts[scriptName] = script.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])
);
}
}
@@ -0,0 +1,188 @@
import { combineAsyncIterables } from './combine-async-iterables';
import { createAsyncIterable } from './create-async-iterable';
import { eachValueFrom } from 'nx/src/adapter/rxjs-for-await';
import { Subject } from 'rxjs';
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('combineAsyncIterables', () => {
it('should combine generators', async () => {
async function* a() {
await delay(20);
yield 'a';
}
async function* b() {
await delay(0);
yield 'b';
}
const c = combineAsyncIterables(a(), b());
const results = [];
for await (const x of c) {
results.push(x);
}
expect(results).toEqual(['b', 'a']);
});
it('should support .throw()', async () => {
async function* a() {
yield 1;
yield 2;
}
async function* b() {
yield 3;
yield 4;
}
const c = combineAsyncIterables(a(), b());
const results = [];
try {
for await (const x of c) {
results.push(x);
await c.throw(new Error('oops'));
}
} catch (e) {
expect(e.message).toMatch(/oops/);
expect(results).toEqual([1]);
}
});
it('should support .return()', async () => {
async function* a() {
yield 1;
yield 2;
}
async function* b() {
yield 3;
yield 4;
}
const c = combineAsyncIterables(a(), b());
const results = [];
for await (const x of c) {
results.push(x);
const { value: y } = await c.return(10);
results.push(y);
}
expect(results).toEqual([1, 10]);
});
it('should throw when one generator throws', async () => {
async function* a() {
await delay(20);
yield 'a';
}
async function* b() {
throw new Error('threw in b');
}
const c = combineAsyncIterables(a(), b());
async function* d() {
yield* c;
}
try {
for await (const x of d()) {
}
throw new Error('should not reach here');
} catch (e) {
expect(e.message).toMatch(/threw in b/);
}
});
it('should combine async iterables', async () => {
const a = createAsyncIterable<number>(({ next, done }) => {
next(1);
next(2);
next(3);
done();
});
const b = createAsyncIterable<number>(({ next, done }) => {
next(4);
next(5);
next(6);
done();
});
const c = combineAsyncIterables(a, b);
const results: number[] = [];
for await (const x of c) {
results.push(x);
}
expect(results).toEqual([1, 4, 2, 5, 3, 6]);
});
it('should throw error when an async iterable throws', async () => {
const a = createAsyncIterable<number>(({ next, done }) => {
next(1);
done();
});
const b = createAsyncIterable<number>(({ next, done }) => {
throw new Error('threw in b');
});
const c = combineAsyncIterables(a, b);
try {
for await (const _x of c) {
// nothing
}
} catch (e) {
expect(e.message).toMatch(/threw in b/);
}
});
it('should handle throws from combined iterator which uses combineAsyncIterable and eachValueFrom', async () => {
// ARRANGE
const subjectThatWontThrow = new Subject<string>();
const iter =
createCombinedAsyncIterableFromEachValueFrom(subjectThatWontThrow);
const subjectThatThrows = new Subject<string>();
const iter2 =
createCombinedAsyncIterableFromEachValueFrom(subjectThatThrows);
// ACT
const c = combineAsyncIterables(iter, iter2);
subjectThatWontThrow.next('from test');
subjectThatThrows.next('from test');
subjectThatThrows.error('my error');
// ASSERT
try {
for await (const _x of c) {
// do nothing
}
} catch (e) {
expect(e).toMatch(/my error/);
}
});
});
async function* createCombinedAsyncIterableFromEachValueFrom(
subject: Subject<string>
) {
async function* otherIterable() {
yield 'foo';
yield 'bar';
yield 'baz';
}
async function* fromEachValueFrom() {
return yield* eachValueFrom(subject.asObservable());
}
return yield* combineAsyncIterables(otherIterable(), fromEachValueFrom());
}
@@ -0,0 +1,64 @@
export async function* combineAsyncIterables<T = any>(
..._iterators: { 0: AsyncIterable<T> } & AsyncIterable<T>[]
): AsyncGenerator<T> {
// Convert iterables into iterators with next, return, throws methods.
// If it's already an iterator, keep it.
const iterators: AsyncIterableIterator<T>[] = _iterators.map((it) => {
if (typeof it['next'] === 'function') {
return it as AsyncIterableIterator<T>;
} else {
return (async function* wrapped() {
for await (const val of it) {
yield val;
}
})();
}
});
let [options] = iterators;
if (typeof options.next === 'function') {
options = Object.create(null);
} else {
iterators.shift();
}
const getNextAsyncIteratorValue = getNextAsyncIteratorFactory(options);
const asyncIteratorsValues = new Map(
iterators.map((it, idx) => [idx, getNextAsyncIteratorValue(it, idx)])
);
try {
do {
const { iterator, index } = await Promise.race(
asyncIteratorsValues.values()
);
if (iterator.done) {
asyncIteratorsValues.delete(index);
} else {
yield iterator.value;
asyncIteratorsValues.set(
index,
getNextAsyncIteratorValue(iterators[index], index)
);
}
} while (asyncIteratorsValues.size > 0);
} finally {
for (const [index, iterator] of asyncIteratorsValues.entries())
if (iterator?.['return'] !== null) iterator['return']?.();
}
}
function getNextAsyncIteratorFactory(options) {
return async (asyncIterator, index) => {
try {
const iterator = await asyncIterator.next();
return { index, iterator };
} catch (err) {
if (options.errorCallback) {
options.errorCallback(err, index);
}
return Promise.reject(err);
}
};
}
@@ -0,0 +1,70 @@
import { createAsyncIterable } from './create-async-iterable';
import { getLastValueFromAsyncIterableIterator } from 'nx/src/utils/async-iterator';
describe(createAsyncIterable.name, () => {
test('simple callback', async () => {
const it = createAsyncIterable<string>(({ next, done }) => {
setTimeout(() => next('bye'), 10);
setTimeout(() => next('hello'), 0);
setTimeout(() => done(), 20);
setTimeout(() => next('ignored'), 30);
});
const results: string[] = [];
for await (const x of it) {
results.push(x);
}
expect(results).toEqual(['hello', 'bye']);
});
test('throwing error', async () => {
const it = createAsyncIterable(({ next, error }) => {
setTimeout(() => next('hello'), 0);
setTimeout(() => error(new Error('Oops')), 10);
});
await expect(async () => {
for await (const _x of it) {
// nothing
}
}).rejects.toThrow(/Oops/);
});
test('multiple signals in the same macro/microtask', async () => {
const it = createAsyncIterable<string>(({ next, done }) => {
setTimeout(() => {
next('first');
});
setTimeout(() => {
// should pass through in the same macro/microtask
next('second');
next('third');
next('fourth');
done();
}, 10);
setTimeout(() => {
next('should be ignored');
}, 20);
});
const results: string[] = [];
for await (const x of it) {
results.push(x);
}
expect(results).toEqual(['first', 'second', 'third', 'fourth']);
});
test('works with getLastValueFromAsyncIterableIterator', async () => {
const it = createAsyncIterable<string>(({ next, done }) => {
setTimeout(() => {
next('foo');
done();
});
});
const result = await getLastValueFromAsyncIterableIterator(it);
expect(result).toEqual('foo');
});
});
@@ -0,0 +1,77 @@
export interface AsyncPushCallbacks<T> {
next: (value: T) => void;
done: () => void;
error: (err: unknown) => void;
registerCleanup?: (cb: () => void | Promise<void>) => void;
}
export function createAsyncIterable<T = unknown>(
listener: (ls: AsyncPushCallbacks<T>) => void
): AsyncIterable<T> {
let done = false;
let error: unknown | null = null;
let cleanup: (() => void | Promise<void>) | undefined;
const pushQueue: T[] = [];
const pullQueue: Array<
[
(x: { value: T | undefined; done: boolean }) => void,
(err: unknown) => void,
]
> = [];
return {
[Symbol.asyncIterator]() {
listener({
next: (value) => {
if (done || error) return;
if (pullQueue.length > 0) {
pullQueue.shift()?.[0]({ value, done: false });
} else {
pushQueue.push(value);
}
},
error: (err) => {
if (done || error) return;
if (pullQueue.length > 0) {
pullQueue.shift()?.[1](err);
}
error = err;
},
done: () => {
if (pullQueue.length > 0) {
pullQueue.shift()?.[0]({ value: undefined, done: true });
}
done = true;
},
registerCleanup: (cb) => {
cleanup = cb;
},
});
return {
next() {
return new Promise<{ value: T | undefined; done: boolean }>(
(resolve, reject) => {
if (pushQueue.length > 0) {
resolve({ value: pushQueue.shift(), done: false });
} else if (done) {
resolve({ value: undefined, done: true });
} else if (error) {
reject(error);
} else {
pullQueue.push([resolve, reject]);
}
}
);
},
async return() {
if (cleanup) {
await cleanup();
}
return { value: undefined, done: true };
},
};
},
} as AsyncIterable<T>;
}
@@ -0,0 +1,4 @@
export * from './create-async-iterable';
export * from './combine-async-iterables';
export * from './map-async-iteratable';
export * from './tap-async-iteratable';
@@ -0,0 +1,20 @@
import { mapAsyncIterable } from './map-async-iteratable';
describe('mapAsyncIterator', () => {
it('should map over values', async () => {
async function* f() {
yield 1;
yield 2;
yield 3;
}
const c = mapAsyncIterable(f(), (x) => x * 2);
const results = [];
for await (const x of c) {
results.push(x);
}
expect(results).toEqual([2, 4, 6]);
});
});
@@ -0,0 +1,22 @@
export async function* mapAsyncIterable<T = any, I = any, O = any>(
data: AsyncIterable<T> | AsyncIterableIterator<T>,
transform: (
input: I,
index?: number,
data?: AsyncIterable<T> | AsyncIterableIterator<T>
) => O
): AsyncIterable<O> | AsyncIterableIterator<O> {
async function* f() {
const generator = data[Symbol.asyncIterator] || data[Symbol.iterator];
const iterator = generator.call(data);
let index = 0;
let item = await iterator.next();
while (!item.done) {
yield await transform(await item.value, index, data);
index++;
item = await iterator.next();
}
}
return yield* f();
}
@@ -0,0 +1,25 @@
import { tapAsyncIterable } from './tap-async-iteratable';
describe('tapAsyncIterator', () => {
it('should tap values', async () => {
async function* f() {
yield 1;
yield 2;
yield 3;
}
const tapped = [];
const results = [];
const c = tapAsyncIterable(f(), (x) => {
tapped.push(`tap: ${x}`);
});
for await (const x of c) {
results.push(x);
}
expect(tapped).toEqual(['tap: 1', 'tap: 2', 'tap: 3']);
expect(results).toEqual([1, 2, 3]);
});
});
@@ -0,0 +1,11 @@
import { mapAsyncIterable } from './map-async-iteratable';
export async function* tapAsyncIterable<T = any, I = any, O = any>(
data: AsyncIterable<T> | AsyncIterableIterator<T>,
fn: (input: I) => void
): AsyncIterable<T> | AsyncIterableIterator<T> {
return yield* mapAsyncIterable(data, (x) => {
fn(x);
return x;
});
}
@@ -0,0 +1,279 @@
import { extname } from 'path';
const binaryExtensions = new Set([
// types originally from https://github.com/sindresorhus/binary-extensions/blob/40e44b510d87a63dcf42300bc8fbcb105f45a61c/binary-extensions.json
'.3dm',
'.3ds',
'.3g2',
'.3gp',
'.7z',
'.a',
'.aac',
'.adp',
'.ai',
'.aif',
'.aiff',
'.als',
'.alz',
'.ape',
'.apk',
'.appimage',
'.ar',
'.arj',
'.asf',
'.au',
'.avi',
'.bak',
'.baml',
'.bh',
'.bin',
'.bk',
'.bmp',
'.btif',
'.bz2',
'.bzip2',
'.cab',
'.caf',
'.cgm',
'.class',
'.cmx',
'.cpio',
'.cr2',
'.cur',
'.dat',
'.dcm',
'.deb',
'.dex',
'.djvu',
'.dll',
'.dmg',
'.dng',
'.doc',
'.docm',
'.docx',
'.dot',
'.dotm',
'.dra',
'.DS_Store',
'.dsk',
'.dts',
'.dtshd',
'.dvb',
'.dwg',
'.dxf',
'.ecelp4800',
'.ecelp7470',
'.ecelp9600',
'.egg',
'.eol',
'.eot',
'.epub',
'.exe',
'.f4v',
'.fbs',
'.fh',
'.fla',
'.flac',
'.flatpak',
'.fli',
'.flv',
'.fpx',
'.fst',
'.fvt',
'.g3',
'.gh',
'.gif',
'.glb',
'.graffle',
'.gz',
'.gzip',
'.h261',
'.h263',
'.h264',
'.icns',
'.ico',
'.ief',
'.img',
'.ipa',
'.iso',
'.jar',
'.jpeg',
'.jpg',
'.jpgv',
'.jpm',
'.jxr',
'.key',
'.keystore',
'.ktx',
'.lha',
'.lib',
'.lvp',
'.lz',
'.lzh',
'.lzma',
'.lzo',
'.m3u',
'.m4a',
'.m4v',
'.mar',
'.mdi',
'.mht',
'.mid',
'.midi',
'.mj2',
'.mka',
'.mkv',
'.mmr',
'.mng',
'.mobi',
'.mov',
'.movie',
'.mp3',
'.mp4',
'.mp4a',
'.mpeg',
'.mpg',
'.mpga',
'.msi',
'.mxu',
'.nef',
'.npx',
'.npy',
'.numbers',
'.nupkg',
'.o',
'.odp',
'.ods',
'.odt',
'.oga',
'.ogg',
'.ogv',
'.otf',
'.ott',
'.pages',
'.pbm',
'.pbf',
'.pcx',
'.pdb',
'.pdf',
'.pea',
'.pgm',
'.pic',
'.pkg',
'.plist',
'.png',
'.pnm',
'.pot',
'.potm',
'.potx',
'.ppa',
'.ppam',
'.ppm',
'.pps',
'.ppsm',
'.ppsx',
'.ppt',
'.pptm',
'.pptx',
'.psd',
'.pxd',
'.pxz',
'.pya',
'.pyc',
'.pyo',
'.pyv',
'.qt',
'.rar',
'.ras',
'.raw',
'.resources',
'.rgb',
'.rip',
'.riv',
'.rlc',
'.rmf',
'.rmvb',
'.rpm',
'.rtf',
'.rz',
'.s3m',
'.s7z',
'.scpt',
'.sgi',
'.shar',
'.snap',
'.sil',
'.sketch',
'.slk',
'.smv',
'.snk',
'.so',
'.stl',
'.suo',
'.sub',
'.swf',
'.tar',
'.tbz',
'.tbz2',
'.tga',
'.tgz',
'.thmx',
'.tif',
'.tiff',
'.tlz',
'.ttc',
'.ttf',
'.txz',
'.udf',
'.uvh',
'.uvi',
'.uvm',
'.uvp',
'.uvs',
'.uvu',
'.viv',
'.vob',
'.war',
'.wav',
'.wax',
'.wbmp',
'.wdp',
'.weba',
'.webm',
'.webp',
'.whl',
'.wim',
'.wm',
'.wma',
'.wmv',
'.wmx',
'.woff',
'.woff2',
'.wrm',
'.wvx',
'.xbm',
'.xif',
'.xla',
'.xlam',
'.xls',
'.xlsb',
'.xlsm',
'.xlsx',
'.xlt',
'.xltm',
'.xltx',
'.xm',
'.xmind',
'.xpi',
'.xpm',
'.xwd',
'.xz',
'.z',
'.zip',
'.zipx',
]);
// TODO(v24): use the version from nx/src/utils
export function isBinaryPath(path: string): boolean {
return binaryExtensions.has(extname(path).toLowerCase());
}
@@ -0,0 +1,55 @@
import { join } from 'path';
import { CreateNodesContext, hashArray } from 'nx/src/devkit-exports';
import {
hashMultiGlobWithWorkspaceContext,
hashObject,
hashWithWorkspaceContext,
} from 'nx/src/devkit-internals';
/**
* @deprecated Use {@link calculateHashesForCreateNodes} instead, which batches
* workspace-context hashing across multiple project roots in a single call.
* This will be removed in Nx 24.
*/
export async function calculateHashForCreateNodes(
projectRoot: string,
options: object,
context: CreateNodesContext,
additionalGlobs: string[] = []
): Promise<string> {
return hashArray([
await hashWithWorkspaceContext(context.workspaceRoot, [
join(projectRoot, '**/*'),
...additionalGlobs,
]),
hashObject(options),
]);
}
export async function calculateHashesForCreateNodes(
projectRoots: string[],
options: object,
context: CreateNodesContext,
additionalGlobs: string[][] = []
): Promise<string[]> {
if (projectRoots.length === 0) {
return [];
}
if (
additionalGlobs.length &&
additionalGlobs.length !== projectRoots.length
) {
throw new Error(
`calculateHashesForCreateNodes: projectRoots.length (${projectRoots.length}) !== additionalGlobs.length (${additionalGlobs.length})`
);
}
const hashes = await hashMultiGlobWithWorkspaceContext(
context.workspaceRoot,
projectRoots.map((projectRoot, idx) => [
join(projectRoot, '**/*'),
...(additionalGlobs.length ? additionalGlobs[idx] : []),
])
);
return hashes.map((hash) => hashArray([hash, hashObject(options)]));
}
@@ -0,0 +1,46 @@
import { readJson, type Tree } from 'nx/src/devkit-exports';
import { getCatalogManager } from './manager-factory';
import type { CatalogManager } from './manager';
export { type CatalogManager, getCatalogManager };
/**
* Detects which packages in a package.json use catalog references
* Returns Map of package name -> catalog name (undefined for default catalog)
*/
export function getCatalogDependenciesFromPackageJson(
tree: Tree,
packageJsonPath: string,
manager: CatalogManager
): Map<string, string | undefined> {
const catalogDeps = new Map<string, string | undefined>();
if (!tree.exists(packageJsonPath)) {
return catalogDeps;
}
try {
const packageJson = readJson(tree, packageJsonPath);
const allDependencies: Record<string, string> = {
...packageJson.dependencies,
...packageJson.devDependencies,
...packageJson.peerDependencies,
...packageJson.optionalDependencies,
};
for (const [packageName, version] of Object.entries(
allDependencies || {}
)) {
if (manager.isCatalogReference(version)) {
const catalogRef = manager.parseCatalogReference(version);
if (catalogRef) {
catalogDeps.set(packageName, catalogRef.catalogName);
}
}
}
} catch (error) {
// If we can't read the package.json, return empty map
}
return catalogDeps;
}
@@ -0,0 +1,22 @@
import { detectPackageManager } from 'nx/src/devkit-exports';
import type { CatalogManager } from './manager';
import { PnpmCatalogManager } from './pnpm-manager';
import { YarnCatalogManager } from './yarn-manager';
/**
* Factory function to get the appropriate catalog manager based on the package manager
*/
export function getCatalogManager(
workspaceRoot: string
): CatalogManager | null {
const packageManager = detectPackageManager(workspaceRoot);
switch (packageManager) {
case 'pnpm':
return new PnpmCatalogManager();
case 'yarn':
return new YarnCatalogManager();
default:
return null;
}
}
@@ -0,0 +1,246 @@
// Keep in sync with packages/nx/src/utils/catalog/manager-utils.ts; the body below
// the imports is duplicated because @nx/devkit supports a range of nx majors and
// this logic isn't part of the nx surface it can import across that range.
import { load } from '@zkochan/js-yaml';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { output, type Tree } from 'nx/src/devkit-exports';
import { readYamlFile } from 'nx/src/devkit-internals';
import {
Document,
isAlias,
isMap,
isScalar,
LineCounter,
parseDocument,
visit,
YAMLMap,
type Node,
} from 'yaml';
import type { CatalogDefinitions } from './types';
// Walks `path` resolving aliases at every step so structural checks can
// see through `key: *anchor` references. Neither `doc.getIn` nor
// `doc.hasIn` traverse aliases on their own. Returns the resolved node, or
// `undefined` if any ancestor isn't a map.
function resolveAt(doc: Document, path: string[]): Node | null | undefined {
let node: Node | null | undefined = doc.contents;
for (let i = 0; i < path.length; i++) {
if (isAlias(node)) node = node.resolve(doc);
if (!isMap(node)) return undefined;
node = node.get(path[i], true) as Node | null | undefined;
}
if (isAlias(node)) node = node.resolve(doc);
return node;
}
function isMapAt(doc: Document, path: string[]): boolean {
return isMap(resolveAt(doc, path));
}
function existsAt(doc: Document, path: string[]): boolean {
return resolveAt(doc, path) !== undefined;
}
// `doc.getIn` does not traverse aliases. For aliased paths it returns
// undefined even when the resolved node has a value. Use the alias-aware
// walk and unwrap scalar nodes to their JS value.
function getValueAt(doc: Document, path: string[]): unknown {
const node = resolveAt(doc, path);
return isScalar(node) ? node.value : node;
}
// Walks `targetPath` resolving aliases at every step and mutates the
// resolved map directly so anchors are preserved. When a step is missing
// or holds a null placeholder (`key:` with no value), creates a fresh map
// inside the current parent and carries over the placeholder's comments
// and anchor so they aren't dropped. A dropped anchor would leave any
// alias referencing it unresolved and make `String(doc)` throw. Falls back
// to `doc.setIn` when the current parent isn't a map: an empty-document
// root gets bootstrapped, while a non-map value where a catalog map was
// expected makes `setIn` throw, surfacing the malformed config.
function setThroughAliases(
doc: Document,
targetPath: string[],
value: unknown
): void {
let parent: Node | null | undefined = doc.contents;
if (isAlias(parent)) parent = parent.resolve(doc);
for (let i = 0; i < targetPath.length - 1; i++) {
if (!isMap(parent)) {
doc.setIn(targetPath, value);
return;
}
let next = parent.get(targetPath[i], true) as Node | null | undefined;
const nextWasAlias = isAlias(next);
if (isAlias(next)) next = next.resolve(doc);
const placeholder =
isScalar(next) && next.value === null ? next : undefined;
if (next === undefined || placeholder) {
let cur = parent;
for (let j = i; j < targetPath.length - 1; j++) {
const fresh = new YAMLMap();
if (j === i && placeholder) {
if (placeholder.comment) fresh.comment = placeholder.comment;
if (placeholder.commentBefore)
fresh.commentBefore = placeholder.commentBefore;
// Copy the anchor only for a directly-held placeholder. When it
// was reached through an alias, the anchor still lives on the
// definition node, so re-emitting it here would duplicate it.
if (!nextWasAlias && placeholder.anchor)
fresh.anchor = placeholder.anchor;
}
cur.set(targetPath[j], fresh);
cur = fresh;
}
cur.set(targetPath[targetPath.length - 1], value);
return;
}
parent = next;
}
if (!isMap(parent)) {
doc.setIn(targetPath, value);
return;
}
parent.set(targetPath[targetPath.length - 1], value);
}
export function readCatalogConfigFromFs(
filename: string,
fullPath: string
): CatalogDefinitions | null {
try {
return readYamlFile<CatalogDefinitions>(fullPath);
} catch (error) {
output.warn({
title: `Unable to parse ${filename}`,
bodyLines: [error.toString()],
});
return null;
}
}
export function readCatalogConfigFromTree(
filename: string,
tree: Tree
): CatalogDefinitions | null {
const content = tree.read(filename, 'utf-8');
try {
return load(content, { filename }) as CatalogDefinitions;
} catch (error) {
output.warn({
title: `Unable to parse ${filename}`,
bodyLines: [error.toString()],
});
return null;
}
}
export function updateCatalogVersionsInFile(
filename: string,
treeOrRoot: Tree | string,
updates: Array<{
packageName: string;
version: string;
catalogName?: string;
}>
): void {
let checkExists: () => boolean;
let readYaml: () => string;
let writeYaml: (content: string) => void;
if (typeof treeOrRoot === 'string') {
const configPath = join(treeOrRoot, filename);
checkExists = () => existsSync(configPath);
readYaml = () => readFileSync(configPath, 'utf-8');
writeYaml = (content) => writeFileSync(configPath, content, 'utf-8');
} else {
checkExists = () => treeOrRoot.exists(filename);
readYaml = () => treeOrRoot.read(filename, 'utf-8');
writeYaml = (content) => treeOrRoot.write(filename, content);
}
if (!checkExists()) {
output.warn({
title: `No ${filename} found`,
bodyLines: [
`Cannot update catalog versions without a ${filename} file.`,
`Create a ${filename} file to use catalogs.`,
],
});
return;
}
try {
// parseDocument keeps comments and anchors so a catalog bump doesn't
// rewrite the user's config file.
const lineCounter = new LineCounter();
const doc = parseDocument(readYaml(), { lineCounter });
// parseDocument collects syntax errors instead of throwing; surface them
// now, with their line/column detail, rather than failing later in
// `String(doc)` with a generic message or skipping a no-op write on a
// malformed file (the previous js-yaml `load()` threw here).
if (doc.errors.length > 0) {
throw new Error(doc.errors.map((e) => e.message).join('\n'));
}
// A dangling alias (`*ref` with no matching `&ref`) is not a syntax error,
// so parseDocument leaves it out of doc.errors. Surface it here with the
// same line/column detail the old js-yaml `load()` reported; otherwise a
// broken reference is silently overwritten or written back untouched.
const unresolvedAliases: string[] = [];
visit(doc, {
Alias(_key, node) {
if (node.resolve(doc) !== undefined) return;
const pos = node.range ? lineCounter.linePos(node.range[0]) : undefined;
unresolvedAliases.push(
pos
? `Unresolved alias "${node.source}" at line ${pos.line}, column ${pos.col}`
: `Unresolved alias "${node.source}"`
);
},
});
if (unresolvedAliases.length > 0) {
throw new Error(unresolvedAliases.join('\n'));
}
let hasChanges = false;
for (const update of updates) {
const { packageName, version, catalogName } = update;
const normalizedCatalogName =
catalogName === 'default' ? undefined : catalogName;
let targetPath: string[];
if (!normalizedCatalogName) {
// An empty `catalog:` placeholder must not claim the default route
// when `catalogs.default` is populated; that would create a
// duplicate-default config rejected by pnpm.
if (isMapAt(doc, ['catalog'])) {
targetPath = ['catalog', packageName];
} else if (existsAt(doc, ['catalogs', 'default'])) {
targetPath = ['catalogs', 'default', packageName];
} else {
targetPath = ['catalog', packageName];
}
} else {
targetPath = ['catalogs', normalizedCatalogName, packageName];
}
if (getValueAt(doc, targetPath) !== version) {
setThroughAliases(doc, targetPath, version);
hasChanges = true;
}
}
if (hasChanges) {
writeYaml(String(doc));
}
} catch (error) {
output.error({
title: 'Failed to update catalog versions',
bodyLines: [error instanceof Error ? error.message : String(error)],
});
throw error;
}
}
@@ -0,0 +1,85 @@
import type { Tree } from 'nx/src/devkit-exports';
import type { CatalogDefinitions, CatalogReference } from './types';
export function formatCatalogError(
error: string,
suggestions: string[]
): string {
let message = error;
if (suggestions.length > 0) {
message += '\n\nSuggestions:';
suggestions.forEach((suggestion) => {
message += `\n • ${suggestion}`;
});
}
return message;
}
/**
* Interface for catalog managers that handle package manager-specific catalog implementations.
*/
export interface CatalogManager {
readonly name: string;
isCatalogReference(version: string): boolean;
parseCatalogReference(version: string): CatalogReference | null;
getCatalogDefinitionFilePaths(): string[];
/**
* Get catalog definitions from the workspace.
*/
getCatalogDefinitions(workspaceRoot: string): CatalogDefinitions | null;
getCatalogDefinitions(tree: Tree): CatalogDefinitions | null;
/**
* Resolve a catalog reference to an actual version.
*/
resolveCatalogReference(
workspaceRoot: string,
packageName: string,
version: string
): string | null;
resolveCatalogReference(
tree: Tree,
packageName: string,
version: string
): string | null;
/**
* Check that a catalog reference is valid.
*/
validateCatalogReference(
workspaceRoot: string,
packageName: string,
version: string
): void;
validateCatalogReference(
tree: Tree,
packageName: string,
version: string
): void;
/**
* Updates catalog definitions for specified packages in their respective catalogs.
*/
updateCatalogVersions(
tree: Tree,
updates: Array<{
packageName: string;
version: string;
catalogName?: string;
}>
): void;
updateCatalogVersions(
workspaceRoot: string,
updates: Array<{
packageName: string;
version: string;
catalogName?: string;
}>
): void;
}
@@ -0,0 +1,224 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { type Tree } from 'nx/src/devkit-exports';
import { formatCatalogError, type CatalogManager } from './manager';
import {
readCatalogConfigFromFs,
readCatalogConfigFromTree,
updateCatalogVersionsInFile,
} from './manager-utils';
import type {
CatalogDefinitions,
CatalogEntry,
CatalogReference,
} from './types';
const PNPM_WORKSPACE_FILENAME = 'pnpm-workspace.yaml';
/**
* PNPM-specific catalog manager implementation
*/
export class PnpmCatalogManager implements CatalogManager {
readonly name = 'pnpm';
readonly catalogProtocol = 'catalog:';
isCatalogReference(version: string): boolean {
return version.startsWith(this.catalogProtocol);
}
parseCatalogReference(version: string): CatalogReference | null {
if (!this.isCatalogReference(version)) {
return null;
}
const catalogName = version.substring(this.catalogProtocol.length);
// Normalize both "catalog:" and "catalog:default" to the same representation
const isDefault = !catalogName || catalogName === 'default';
return {
catalogName: isDefault ? undefined : catalogName,
isDefaultCatalog: isDefault,
};
}
getCatalogDefinitionFilePaths(): string[] {
return [PNPM_WORKSPACE_FILENAME];
}
getCatalogDefinitions(treeOrRoot: Tree | string): CatalogDefinitions | null {
if (typeof treeOrRoot === 'string') {
const configPath = join(treeOrRoot, PNPM_WORKSPACE_FILENAME);
if (!existsSync(configPath)) {
return null;
}
return readCatalogConfigFromFs(PNPM_WORKSPACE_FILENAME, configPath);
} else {
if (!treeOrRoot.exists(PNPM_WORKSPACE_FILENAME)) {
return null;
}
return readCatalogConfigFromTree(PNPM_WORKSPACE_FILENAME, treeOrRoot);
}
}
resolveCatalogReference(
treeOrRoot: Tree | string,
packageName: string,
version: string
): string | null {
const catalogRef = this.parseCatalogReference(version);
if (!catalogRef) {
return null;
}
const catalogDefs = this.getCatalogDefinitions(treeOrRoot);
if (!catalogDefs) {
return null;
}
let catalogToUse: CatalogEntry | undefined;
if (catalogRef.isDefaultCatalog) {
// Check both locations for default catalog
catalogToUse = catalogDefs.catalog ?? catalogDefs.catalogs?.default;
} else if (catalogRef.catalogName) {
catalogToUse = catalogDefs.catalogs?.[catalogRef.catalogName];
}
return catalogToUse?.[packageName] || null;
}
validateCatalogReference(
treeOrRoot: Tree | string,
packageName: string,
version: string
): void {
const catalogRef = this.parseCatalogReference(version);
if (!catalogRef) {
throw new Error(
`Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"`
);
}
const catalogDefs = this.getCatalogDefinitions(treeOrRoot);
if (!catalogDefs) {
throw new Error(
formatCatalogError(
`Cannot get Pnpm catalog definitions. No ${PNPM_WORKSPACE_FILENAME} found in workspace root.`,
[`Create a ${PNPM_WORKSPACE_FILENAME} file in your workspace root`]
)
);
}
let catalogToUse: CatalogEntry | undefined;
if (catalogRef.isDefaultCatalog) {
const hasCatalog = !!catalogDefs.catalog;
const hasCatalogsDefault = !!catalogDefs.catalogs?.default;
// Error if both defined (matches pnpm behavior)
if (hasCatalog && hasCatalogsDefault) {
throw new Error(
"The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both."
);
}
catalogToUse = catalogDefs.catalog ?? catalogDefs.catalogs?.default;
if (!catalogToUse) {
const availableCatalogs = Object.keys(catalogDefs.catalogs || {});
const suggestions = [
`Define a default catalog in ${PNPM_WORKSPACE_FILENAME} under the "catalog" key`,
];
if (availableCatalogs.length > 0) {
suggestions.push(
`Or select from the available named catalogs: ${availableCatalogs
.map((c) => `"catalog:${c}"`)
.join(', ')}`
);
}
throw new Error(
formatCatalogError(
`No default catalog defined in ${PNPM_WORKSPACE_FILENAME}`,
suggestions
)
);
}
} else if (catalogRef.catalogName) {
catalogToUse = catalogDefs.catalogs?.[catalogRef.catalogName];
if (!catalogToUse) {
const availableCatalogs = Object.keys(
catalogDefs.catalogs || {}
).filter((c) => c !== 'default');
const defaultCatalog = !!catalogDefs.catalog
? 'catalog'
: !catalogDefs.catalogs?.default
? 'catalogs.default'
: null;
const suggestions = [
`Define the catalog in ${PNPM_WORKSPACE_FILENAME} under the "catalogs" key`,
];
if (availableCatalogs.length > 0) {
suggestions.push(
`Or select from the available named catalogs: ${availableCatalogs
.map((c) => `"catalog:${c}"`)
.join(', ')}`
);
}
if (defaultCatalog) {
suggestions.push(`Or use the default catalog ("${defaultCatalog}")`);
}
throw new Error(
formatCatalogError(
`Catalog "${catalogRef.catalogName}" not found in ${PNPM_WORKSPACE_FILENAME}`,
suggestions
)
);
}
}
if (!catalogToUse![packageName]) {
let catalogName: string;
if (catalogRef.isDefaultCatalog) {
// Context-aware messaging based on which location exists
const hasCatalog = !!catalogDefs.catalog;
catalogName = hasCatalog
? 'default catalog ("catalog")'
: 'default catalog ("catalogs.default")';
} else {
catalogName = `catalog '${catalogRef.catalogName}'`;
}
const availablePackages = Object.keys(catalogToUse!);
const suggestions = [
`Add "${packageName}" to ${catalogName} in ${PNPM_WORKSPACE_FILENAME}`,
];
if (availablePackages.length > 0) {
suggestions.push(
`Or select from the available packages in ${catalogName}: ${availablePackages
.map((p) => `"${p}"`)
.join(', ')}`
);
}
throw new Error(
formatCatalogError(
`Package "${packageName}" not found in ${catalogName}`,
suggestions
)
);
}
}
updateCatalogVersions(
treeOrRoot: Tree | string,
updates: Array<{
packageName: string;
version: string;
catalogName?: string;
}>
): void {
updateCatalogVersionsInFile(PNPM_WORKSPACE_FILENAME, treeOrRoot, updates);
}
}
@@ -0,0 +1,13 @@
export interface CatalogReference {
catalogName?: string;
isDefaultCatalog: boolean;
}
export interface CatalogEntry {
[packageName: string]: string;
}
export interface CatalogDefinitions {
catalog?: CatalogEntry;
catalogs?: Record<string, CatalogEntry>;
}
@@ -0,0 +1,224 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { type Tree } from 'nx/src/devkit-exports';
import { formatCatalogError, type CatalogManager } from './manager';
import {
readCatalogConfigFromFs,
readCatalogConfigFromTree,
updateCatalogVersionsInFile,
} from './manager-utils';
import type {
CatalogDefinitions,
CatalogEntry,
CatalogReference,
} from './types';
const YARNRC_FILENAME = '.yarnrc.yml';
/**
* Yarn Berry (v4+) catalog manager implementation
*/
export class YarnCatalogManager implements CatalogManager {
readonly name = 'yarn';
readonly catalogProtocol = 'catalog:';
isCatalogReference(version: string): boolean {
return version.startsWith(this.catalogProtocol);
}
parseCatalogReference(version: string): CatalogReference | null {
if (!this.isCatalogReference(version)) {
return null;
}
const catalogName = version.substring(this.catalogProtocol.length);
// Normalize both "catalog:" and "catalog:default" to the same representation
const isDefault = !catalogName || catalogName === 'default';
return {
catalogName: isDefault ? undefined : catalogName,
isDefaultCatalog: isDefault,
};
}
getCatalogDefinitionFilePaths(): string[] {
return [YARNRC_FILENAME];
}
getCatalogDefinitions(treeOrRoot: Tree | string): CatalogDefinitions | null {
if (typeof treeOrRoot === 'string') {
const configPath = join(treeOrRoot, YARNRC_FILENAME);
if (!existsSync(configPath)) {
return null;
}
return readCatalogConfigFromFs(YARNRC_FILENAME, configPath);
} else {
if (!treeOrRoot.exists(YARNRC_FILENAME)) {
return null;
}
return readCatalogConfigFromTree(YARNRC_FILENAME, treeOrRoot);
}
}
resolveCatalogReference(
treeOrRoot: Tree | string,
packageName: string,
version: string
): string | null {
const catalogRef = this.parseCatalogReference(version);
if (!catalogRef) {
return null;
}
const catalogDefs = this.getCatalogDefinitions(treeOrRoot);
if (!catalogDefs) {
return null;
}
let catalogToUse: CatalogEntry | undefined;
if (catalogRef.isDefaultCatalog) {
// Check both locations for default catalog
catalogToUse = catalogDefs.catalog ?? catalogDefs.catalogs?.default;
} else if (catalogRef.catalogName) {
catalogToUse = catalogDefs.catalogs?.[catalogRef.catalogName];
}
return catalogToUse?.[packageName] || null;
}
validateCatalogReference(
treeOrRoot: Tree | string,
packageName: string,
version: string
): void {
const catalogRef = this.parseCatalogReference(version);
if (!catalogRef) {
throw new Error(
`Invalid catalog reference syntax: "${version}". Expected format: "catalog:" or "catalog:name"`
);
}
const catalogDefs = this.getCatalogDefinitions(treeOrRoot);
if (!catalogDefs) {
throw new Error(
formatCatalogError(
`Cannot get Yarn catalog definitions. No ${YARNRC_FILENAME} found in workspace root.`,
[`Create a ${YARNRC_FILENAME} file in your workspace root`]
)
);
}
let catalogToUse: CatalogEntry | undefined;
if (catalogRef.isDefaultCatalog) {
const hasCatalog = !!catalogDefs.catalog;
const hasCatalogsDefault = !!catalogDefs.catalogs?.default;
// Error if both defined
if (hasCatalog && hasCatalogsDefault) {
throw new Error(
"The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both."
);
}
catalogToUse = catalogDefs.catalog ?? catalogDefs.catalogs?.default;
if (!catalogToUse) {
const availableCatalogs = Object.keys(catalogDefs.catalogs || {});
const suggestions = [
`Define a default catalog in ${YARNRC_FILENAME} under the "catalog" key`,
];
if (availableCatalogs.length > 0) {
suggestions.push(
`Or select from the available named catalogs: ${availableCatalogs
.map((c) => `"catalog:${c}"`)
.join(', ')}`
);
}
throw new Error(
formatCatalogError(
`No default catalog defined in ${YARNRC_FILENAME}`,
suggestions
)
);
}
} else if (catalogRef.catalogName) {
catalogToUse = catalogDefs.catalogs?.[catalogRef.catalogName];
if (!catalogToUse) {
const availableCatalogs = Object.keys(
catalogDefs.catalogs || {}
).filter((c) => c !== 'default');
const defaultCatalog = !!catalogDefs.catalog
? 'catalog'
: !catalogDefs.catalogs?.default
? 'catalogs.default'
: null;
const suggestions = [
`Define the catalog in ${YARNRC_FILENAME} under the "catalogs" key`,
];
if (availableCatalogs.length > 0) {
suggestions.push(
`Or select from the available named catalogs: ${availableCatalogs
.map((c) => `"catalog:${c}"`)
.join(', ')}`
);
}
if (defaultCatalog) {
suggestions.push(`Or use the default catalog ("${defaultCatalog}")`);
}
throw new Error(
formatCatalogError(
`Catalog "${catalogRef.catalogName}" not found in ${YARNRC_FILENAME}`,
suggestions
)
);
}
}
if (!catalogToUse![packageName]) {
let catalogName: string;
if (catalogRef.isDefaultCatalog) {
// Context-aware messaging based on which location exists
const hasCatalog = !!catalogDefs.catalog;
catalogName = hasCatalog
? 'default catalog ("catalog")'
: 'default catalog ("catalogs.default")';
} else {
catalogName = `catalog '${catalogRef.catalogName}'`;
}
const availablePackages = Object.keys(catalogToUse!);
const suggestions = [
`Add "${packageName}" to ${catalogName} in ${YARNRC_FILENAME}`,
];
if (availablePackages.length > 0) {
suggestions.push(
`Or select from the available packages in ${catalogName}: ${availablePackages
.map((p) => `"${p}"`)
.join(', ')}`
);
}
throw new Error(
formatCatalogError(
`Package "${packageName}" not found in ${catalogName}`,
suggestions
)
);
}
}
updateCatalogVersions(
treeOrRoot: Tree | string,
updates: Array<{
packageName: string;
version: string;
catalogName?: string;
}>
): void {
updateCatalogVersionsInFile(YARNRC_FILENAME, treeOrRoot, updates);
}
}
+212
View File
@@ -0,0 +1,212 @@
import { existsSync, readdirSync } from 'fs';
import { pathToFileURL } from 'node:url';
import { workspaceRoot } from 'nx/src/devkit-exports';
import {
forceRegisterEsmLoader,
loadTsFile,
registerTsProject,
} from 'nx/src/devkit-internals';
import { dirname, extname, join, sep } from 'path';
export let dynamicImport = new Function(
'modulePath',
'return import(modulePath);'
);
export async function loadConfigFile<T extends object = any>(
configFilePath: string,
tsconfigFileNames?: string[]
): Promise<T> {
const extension = extname(configFilePath);
const module = await loadModule(configFilePath, extension, tsconfigFileNames);
return module.default ?? module;
}
async function loadModule(
path: string,
extension: string,
tsconfigFileNames?: string[]
): Promise<any> {
if (isTypeScriptFile(extension)) {
return await loadTypeScriptModule(path, extension, tsconfigFileNames);
}
return await loadJavaScriptModule(path, extension);
}
function isTypeScriptFile(extension: string): boolean {
return extension.endsWith('ts');
}
async function loadTypeScriptModule(
path: string,
extension: string,
tsconfigFileNames?: string[]
): Promise<any> {
const tsConfigPath = getTypeScriptConfigPath(path, tsconfigFileNames);
if (!tsConfigPath) {
return await loadModuleByExtension(path, extension);
}
// loadTsFile was added in nx@23. @nx/devkit's peer range supports older
// nx majors, so fall back to the legacy registerTsProject + require path
// when loadTsFile isn't available on the host nx.
if (typeof loadTsFile !== 'function') {
const cleanup = registerTsProject(tsConfigPath);
try {
return await loadModuleByExtension(path, extension);
} finally {
cleanup();
}
}
// Both .ts and .mts go through loadTsFile first. Node 22.12+ supports
// require() of synchronous ESM by default, and loadTsFile's lazy fallback
// covers swc/ts-node + tsconfig-paths registration when needed (swc-node
// hooks .cts/.mts/.ts via Module._extensions). Async-only ESM modules
// (top-level await) throw ERR_REQUIRE_ASYNC_MODULE and fall through to
// dynamic import(). ERR_REQUIRE_ESM is the legacy code for the same case
// - kept for older Node lines.
try {
return loadTsFile(path, tsConfigPath);
} catch (e: any) {
if (
e?.code !== 'ERR_REQUIRE_ESM' &&
e?.code !== 'ERR_REQUIRE_ASYNC_MODULE'
) {
throw e;
}
// The module must be loaded via dynamic import(). Register
// tsconfig-paths first so workspace alias imports resolve, then try a
// native dynamic import. Node 22.18+ LTS strips TS types on the ESM
// path natively, so pure-ESM TLA configs load without any swc/ts-node
// ESM loader. Only escalate to forceRegisterEsmLoader (which throws
// when neither @swc-node/register nor ts-node is installed) if the
// native attempt hits unsupported TS syntax.
const cleanup = registerTsProject(tsConfigPath);
try {
return await loadESM(path);
} catch (esmErr: any) {
if (
esmErr?.code !== 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' ||
typeof forceRegisterEsmLoader !== 'function'
) {
throw esmErr;
}
// Module.register is global and one-shot per process. After this
// runs, every subsequent ESM import in the process is routed
// through the registered loader, forfeiting Node's native TS
// stripping for the dynamic-import path. If neither swc-node nor
// ts-node is installed, forceRegisterEsmLoader throws - surface the
// original ESM error in that case so the user sees the real
// problem, not a misleading "loader missing" message.
try {
forceRegisterEsmLoader();
} catch {
throw esmErr;
}
return await loadESM(path);
} finally {
cleanup();
}
}
}
function getTypeScriptConfigPath(
path: string,
tsconfigFileNames?: string[]
): string | null {
const siblingFiles = readdirSync(dirname(path));
const tsConfigFileName = (tsconfigFileNames ?? ['tsconfig.json']).find(
(name) => siblingFiles.includes(name)
);
return tsConfigFileName
? join(dirname(path), tsConfigFileName)
: getRootTsConfigPath();
}
async function loadJavaScriptModule(
path: string,
extension: string
): Promise<any> {
return await loadModuleByExtension(path, extension);
}
async function loadModuleByExtension(
path: string,
extension: string
): Promise<any> {
switch (extension) {
case '.cts':
case '.cjs':
return await loadCommonJS(path);
case '.mjs':
return await loadESM(path);
default:
// For both .ts and .mts files, try to load them as CommonJS first, then try ESM.
// It's possible that the file is written like ESM (e.g. using `import`) but uses CJS features like `__dirname` or `__filename`.
return await load(path);
}
}
export function getRootTsConfigPath(): string | null {
const tsConfigFileName = getRootTsConfigFileName();
return tsConfigFileName ? join(workspaceRoot, tsConfigFileName) : null;
}
export function getRootTsConfigFileName(): string | null {
for (const tsConfigName of ['tsconfig.base.json', 'tsconfig.json']) {
const pathExists = existsSync(join(workspaceRoot, tsConfigName));
if (pathExists) {
return tsConfigName;
}
}
return null;
}
const packageInstallationDirectories = [
`${sep}node_modules${sep}`,
`${sep}.yarn${sep}`,
];
export function clearRequireCache(): void {
for (const k of Object.keys(require.cache)) {
if (!packageInstallationDirectories.some((dir) => k.includes(dir))) {
delete require.cache[k];
}
}
}
async function load(path: string): Promise<any> {
try {
// Try using `require` first, which works for CJS modules.
// Modules are CJS unless it is named `.mjs` or `package.json` sets type to "module".
return await loadCommonJS(path);
} catch (e: any) {
if (e.code === 'ERR_REQUIRE_ESM') {
// If `require` fails to load ESM, try dynamic `import()`. ESM requires file url protocol for handling absolute paths.
return loadESM(path);
}
// Re-throw all other errors
throw e;
}
}
/**
* Load the module after ensuring that the require cache is cleared.
*/
async function loadCommonJS(path: string): Promise<any> {
// Clear cache if the path is in the cache
if (require.cache[path]) {
clearRequireCache();
}
return require(path);
}
async function loadESM(path: string): Promise<any> {
const pathAsFileUrl = pathToFileURL(path).pathname;
return await dynamicImport(`${pathAsFileUrl}?t=${Date.now()}`);
}
@@ -0,0 +1,107 @@
import { TempFs } from '../../internal-testing-utils';
import { convertNxExecutor } from './convert-nx-executor';
describe('Convert Nx Executor', () => {
let fs: TempFs;
beforeAll(async () => {
fs = new TempFs('convert-nx-executor');
// The tests in this file don't actually care about the files in the temp dir.
// The converted executor reads project configuration from the workspace root,
// which is set to the temp dir in the tests. If there are no files in the temp
// dir, the glob search currently hangs. So we create a dummy file to prevent that.
await fs.createFile('blah.json', JSON.stringify({}));
});
afterAll(() => {
fs.cleanup();
});
it('should convertNxExecutor to builder correctly and produce the same output', async () => {
// ARRANGE
const { schema } = require('@angular-devkit/core');
const {
TestingArchitectHost,
// nx-ignore-next-line
} =
require('@angular-devkit/architect/testing') as typeof import('@angular-devkit/architect/testing');
const { Architect } = require('@angular-devkit/architect');
const registry = new schema.CoreSchemaRegistry();
registry.addPostTransform(schema.transforms.addUndefinedDefaults);
const testArchitectHost = new TestingArchitectHost(fs.tempDir, fs.tempDir);
testArchitectHost.workspaceRoot = fs.tempDir;
const architect = new Architect(testArchitectHost, registry);
const convertedExecutor = convertNxExecutor(echoExecutor);
const realBuilder = require('@angular-devkit/architect').createBuilder(
echo
);
testArchitectHost.addBuilder('nx:test', convertedExecutor);
testArchitectHost.addBuilder('ng:test', realBuilder);
const consoleSpy = jest.spyOn(console, 'log');
// ACT
const convertedRun = await architect.scheduleBuilder('nx:test', {
name: 'test',
});
const realRun = await architect.scheduleBuilder('ng:test', {
name: 'test',
});
const convertedRunResult = await convertedRun.result;
const realRunResult = await realRun.result;
// ASSERT
expect(convertedRunResult).toMatchInlineSnapshot(`
{
"info": {
"builderName": "nx:test",
"description": "Testing only builder.",
"optionSchema": {
"type": "object",
},
},
"success": true,
}
`);
expect(realRunResult).toMatchInlineSnapshot(`
{
"info": {
"builderName": "ng:test",
"description": "Testing only builder.",
"optionSchema": {
"type": "object",
},
},
"success": true,
}
`);
expect(convertedRunResult.success).toEqual(realRunResult.success);
expect(consoleSpy).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenNthCalledWith(1, 'Executor ran', {
name: 'test',
});
expect(consoleSpy).toHaveBeenNthCalledWith(2, 'Executor ran', {
name: 'test',
});
expect(convertedExecutor.toString()).toEqual(realBuilder.toString());
expect(convertedExecutor.handler.toString()).toEqual(
realBuilder.handler.toString()
);
});
});
function echo(options: { name: string }) {
console.log('Executor ran', options);
return {
success: true,
};
}
async function echoExecutor(options: { name: string }) {
return echo(options);
}
@@ -0,0 +1,118 @@
import type { Observable } from 'rxjs';
import type {
Executor,
ExecutorContext,
ProjectsConfigurations,
} from 'nx/src/devkit-exports';
import { NX_VERSION } from './package-json';
import { lt } from 'semver';
import {
readNxJsonFromDisk,
readProjectConfigurationsFromRootMap,
retrieveProjectConfigurationsWithAngularProjects,
} from 'nx/src/devkit-internals';
/**
* Convert an Nx Executor into an Angular Devkit Builder
*
* Use this to expose a compatible Angular Builder
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function convertNxExecutor(executor: Executor) {
const builderFunction = (options, builderContext) => {
const nxJsonConfiguration = readNxJsonFromDisk(
builderContext.workspaceRoot
);
const promise = async () => {
const projectsConfigurations: ProjectsConfigurations = {
version: 2,
projects: await retrieveProjectConfigurationsWithAngularProjects(
builderContext.workspaceRoot,
nxJsonConfiguration
).then((p) => {
if ((p as any).projectNodes) {
return (p as any).projectNodes;
}
// v18.3.4 changed projects to be keyed by root
// rather than project name
if (lt(NX_VERSION, '18.3.4')) {
return p.projects;
}
if (readProjectConfigurationsFromRootMap) {
return readProjectConfigurationsFromRootMap(p.projects);
}
throw new Error(
'Unable to successfully map Nx executor -> Angular Builder'
);
}),
};
const context: ExecutorContext = {
root: builderContext.workspaceRoot,
projectName: builderContext.target?.project,
targetName: builderContext.target?.target,
target: builderContext.target?.target,
configurationName: builderContext.target?.configuration,
projectsConfigurations,
nxJsonConfiguration,
cwd: process.cwd(),
projectGraph: null,
taskGraph: null,
isVerbose: false,
};
return executor(options, context);
};
return toObservable(promise());
};
return require('@angular-devkit/architect').createBuilder(builderFunction);
}
function toObservable<T extends { success: boolean }>(
promiseOrAsyncIterator: Promise<T | AsyncIterableIterator<T>>
): Observable<T> {
return new (require('rxjs') as typeof import('rxjs')).Observable(
(subscriber) => {
promiseOrAsyncIterator
.then((value) => {
if (!(value as any).next) {
subscriber.next(value as T);
subscriber.complete();
} else {
let asyncIterator = value as AsyncIterableIterator<T>;
function recurse(iterator: AsyncIterableIterator<T>) {
iterator
.next()
.then((result) => {
if (!result.done) {
subscriber.next(result.value);
recurse(iterator);
} else {
if (result.value) {
subscriber.next(result.value);
}
subscriber.complete();
}
})
.catch((e) => {
subscriber.error(e);
});
}
recurse(asyncIterator);
return () => {
asyncIterator.return();
};
}
})
.catch((err) => {
subscriber.error(err);
});
}
);
}
@@ -0,0 +1,185 @@
// Stub out @nx/cypress/plugin so findPluginForConfigFile's dynamic
// `await import('@nx/cypress/plugin')` (fired when include/exclude is present)
// doesn't pull real plugin code, which transitively imports @nx/js source and
// inflates sandbox inputs.
jest.mock(
'@nx/cypress/plugin',
() => ({
createNodesV2: ['**/cypress.config.{js,ts,mjs,cjs}', jest.fn()],
}),
{ virtual: true }
);
import { type Tree, readNxJson, updateNxJson } from 'nx/src/devkit-exports';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
import { findPluginForConfigFile } from './find-plugin-for-config-file';
describe('find-plugin-for-config-file', () => {
let tree: Tree;
let tempFs: TempFs;
beforeEach(() => {
tempFs = new TempFs('target-defaults-utils');
tree = createTreeWithEmptyWorkspace();
tree.root = tempFs.tempDir;
});
afterEach(() => {
tempFs.cleanup();
jest.resetModules();
});
it('should return the plugin when its registered as just a string', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push('@nx/cypress/plugin');
updateNxJson(tree, nxJson);
tree.write('apps/myapp-e2e/cypress.config.ts', '');
await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', '');
// ACT
const plugin = await findPluginForConfigFile(
tree,
'@nx/cypress/plugin',
'apps/myapp-e2e/cypress.config.ts'
);
// ASSERT
expect(plugin).toBeTruthy();
expect(plugin).toEqual('@nx/cypress/plugin');
});
it('should return the plugin when it does not have an include or exclude', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/cypress/plugin',
options: {
targetName: 'e2e',
ciTargetName: 'e2e-ci',
},
});
updateNxJson(tree, nxJson);
tree.write('apps/myapp-e2e/cypress.config.ts', '');
await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', '');
// ACT
const plugin = await findPluginForConfigFile(
tree,
'@nx/cypress/plugin',
'apps/myapp-e2e/cypress.config.ts'
);
// ASSERT
expect(plugin).toBeTruthy();
expect(plugin).toMatchInlineSnapshot(`
{
"options": {
"ciTargetName": "e2e-ci",
"targetName": "e2e",
},
"plugin": "@nx/cypress/plugin",
}
`);
});
it('should return the plugin when it the includes finds the config file', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/cypress/plugin',
options: {
targetName: 'e2e',
ciTargetName: 'e2e-ci',
},
include: ['libs/**'],
});
nxJson.plugins.push({
plugin: '@nx/cypress/plugin',
options: {
targetName: 'e2e',
ciTargetName: 'cypress:e2e-ci',
},
include: ['apps/**'],
});
updateNxJson(tree, nxJson);
tree.write('apps/myapp-e2e/cypress.config.ts', '');
await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', '');
// ACT
const plugin = await findPluginForConfigFile(
tree,
'@nx/cypress/plugin',
'apps/myapp-e2e/cypress.config.ts'
);
// ASSERT
expect(plugin).toBeTruthy();
expect(plugin).toMatchInlineSnapshot(`
{
"include": [
"apps/**",
],
"options": {
"ciTargetName": "cypress:e2e-ci",
"targetName": "e2e",
},
"plugin": "@nx/cypress/plugin",
}
`);
});
it('should return a valid plugin when it the excludes does not include the config file', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/cypress/plugin',
options: {
targetName: 'e2e',
ciTargetName: 'cypress:e2e-ci',
},
exclude: ['apps/**'],
});
nxJson.plugins.push({
plugin: '@nx/cypress/plugin',
options: {
targetName: 'e2e',
ciTargetName: 'e2e-ci',
},
exclude: ['libs/**'],
});
updateNxJson(tree, nxJson);
tree.write('apps/myapp-e2e/cypress.config.ts', '');
await tempFs.createFile('apps/myapp-e2e/cypress.config.ts', '');
// ACT
const plugin = await findPluginForConfigFile(
tree,
'@nx/cypress/plugin',
'apps/myapp-e2e/cypress.config.ts'
);
// ASSERT
expect(plugin).toBeTruthy();
expect(plugin).toMatchInlineSnapshot(`
{
"exclude": [
"libs/**",
],
"options": {
"ciTargetName": "e2e-ci",
"targetName": "e2e",
},
"plugin": "@nx/cypress/plugin",
}
`);
});
});
@@ -0,0 +1,55 @@
import {
type Tree,
type PluginConfiguration,
readNxJson,
CreateNodes,
} from 'nx/src/devkit-exports';
import { findMatchingConfigFiles } from 'nx/src/devkit-internals';
import { minimatch } from 'minimatch';
export async function findPluginForConfigFile(
tree: Tree,
pluginName: string,
pathToConfigFile: string
): Promise<PluginConfiguration> {
const nxJson = readNxJson(tree);
if (!nxJson.plugins) {
return;
}
const pluginRegistrations: PluginConfiguration[] = nxJson.plugins.filter(
(p) => (typeof p === 'string' ? p === pluginName : p.plugin === pluginName)
);
for (const plugin of pluginRegistrations) {
if (typeof plugin === 'string') {
return plugin;
}
if (!plugin.include && !plugin.exclude) {
return plugin;
}
if (plugin.include || plugin.exclude) {
const resolvedPlugin: {
createNodes?: CreateNodes;
createNodesV2?: CreateNodes;
} = await import(pluginName);
const pluginGlob =
resolvedPlugin.createNodes?.[0] ?? resolvedPlugin.createNodesV2?.[0];
// The file must be one this plugin actually processes (its path matches
// the plugin's createNodes glob) before the registration's include/exclude
// filters are applied.
const matchingConfigFile =
!pluginGlob || minimatch(pathToConfigFile, pluginGlob, { dot: true })
? findMatchingConfigFiles(
[pathToConfigFile],
plugin.include,
plugin.exclude
)
: [];
if (matchingConfigFile.length) {
return plugin;
}
}
}
}
@@ -0,0 +1,35 @@
import { join } from 'path';
import { existsSync } from 'fs';
import type { PackageJson } from 'nx/src/utils/package-json';
import type { InputDefinition } from 'nx/src/config/workspace-json-project-json';
import {
CreateNodesContext,
ProjectConfiguration,
readJsonFile,
} from 'nx/src/devkit-exports';
/**
* Get the named inputs available for a directory
*/
export function getNamedInputs(
directory: string,
context: CreateNodesContext
): { [inputName: string]: (string | InputDefinition)[] } {
const projectJsonPath = join(directory, 'project.json');
const projectJson: ProjectConfiguration = existsSync(projectJsonPath)
? readJsonFile(projectJsonPath)
: null;
const packageJsonPath = join(directory, 'package.json');
const packageJson: PackageJson = existsSync(packageJsonPath)
? readJsonFile(packageJsonPath)
: null;
return {
...context.nxJsonConfiguration.namedInputs,
...packageJson?.nx?.namedInputs,
...projectJson?.namedInputs,
};
}
@@ -0,0 +1,85 @@
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
import {
getWorkspaceLayout,
extractLayoutDirectory,
} from './get-workspace-layout';
describe('getWorkspaceLayout', () => {
it('should return selected values', () => {
const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
tree.write(
'nx.json',
JSON.stringify({
workspaceLayout: {
appsDir: 'custom-apps',
libsDir: 'custom-libs',
},
})
);
expect(getWorkspaceLayout(tree)).toEqual({
appsDir: 'custom-apps',
libsDir: 'custom-libs',
npmScope: undefined,
standaloneAsDefault: true,
});
});
it('should return apps and libs when present', () => {
const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
tree.write('nx.json', JSON.stringify({}));
tree.write('apps/file', '');
tree.write('libs/file', '');
tree.write('packages/file', '');
expect(getWorkspaceLayout(tree)).toEqual({
appsDir: 'apps',
libsDir: 'libs',
npmScope: undefined,
standaloneAsDefault: true,
});
});
it('should return packages when present', () => {
const tree = createTreeWithEmptyWorkspace();
tree.write('nx.json', JSON.stringify({}));
tree.write('packages/file', '');
tree.write('something/file', '');
expect(getWorkspaceLayout(tree)).toEqual({
appsDir: 'packages',
libsDir: 'packages',
npmScope: undefined,
standaloneAsDefault: true,
});
});
it('should return . in other cases', () => {
const tree = createTreeWithEmptyWorkspace();
tree.write('nx.json', JSON.stringify({}));
tree.write('something/file', '');
expect(getWorkspaceLayout(tree)).toEqual({
appsDir: '.',
libsDir: '.',
npmScope: undefined,
standaloneAsDefault: true,
});
});
});
describe('extractLayoutDirectory', () => {
it('should extract layout directory', () => {
expect(extractLayoutDirectory('apps/my-app')).toEqual({
layoutDirectory: 'apps',
projectDirectory: 'my-app',
});
expect(extractLayoutDirectory('libs/my-lib')).toEqual({
layoutDirectory: 'libs',
projectDirectory: 'my-lib',
});
expect(extractLayoutDirectory('packages/my-package')).toEqual({
layoutDirectory: 'packages',
projectDirectory: 'my-package',
});
expect(extractLayoutDirectory(undefined)).toEqual({
layoutDirectory: null,
projectDirectory: undefined,
});
});
});
@@ -0,0 +1,61 @@
import { readNxJson, Tree } from 'nx/src/devkit-exports';
/**
* Returns workspace defaults. It includes defaults folders for apps and libs,
* and the default scope.
*
* Example:
*
* ```typescript
* { appsDir: 'apps', libsDir: 'libs' }
* ```
* @param tree - file system tree
*/
export function getWorkspaceLayout(tree: Tree): {
appsDir: string;
libsDir: string;
standaloneAsDefault: boolean;
} {
const nxJson = readNxJson(tree);
return {
appsDir:
nxJson?.workspaceLayout?.appsDir ??
inOrderOfPreference(tree, ['apps', 'packages'], '.'),
libsDir:
nxJson?.workspaceLayout?.libsDir ??
inOrderOfPreference(tree, ['libs', 'packages'], '.'),
standaloneAsDefault: true,
};
}
/**
* Experimental
*/
export function extractLayoutDirectory(directory?: string): {
layoutDirectory: string | null;
projectDirectory?: string;
} {
if (directory) {
directory = directory.startsWith('/') ? directory.substring(1) : directory;
for (let dir of ['apps', 'libs', 'packages']) {
if (directory.startsWith(dir + '/') || directory === dir) {
return {
layoutDirectory: dir,
projectDirectory: directory.substring(dir.length + 1),
};
}
}
}
return { layoutDirectory: null, projectDirectory: directory };
}
function inOrderOfPreference(
tree: Tree,
selectedFolders: string[],
defaultChoice: string
) {
for (let i = 0; i < selectedFolders.length; ++i) {
if (tree.exists(selectedFolders[i])) return selectedFolders[i];
}
return defaultChoice;
}
@@ -0,0 +1,80 @@
import { updateJson } from 'nx/src/devkit-exports';
import { createTreeWithEmptyWorkspace } from '../../testing';
import {
getDeclaredPackageVersion,
getInstalledPackageVersion,
} from './installed-version';
describe('getDeclaredPackageVersion', () => {
it('returns the cleaned version for a declared range', () => {
const tree = createTreeWithEmptyWorkspace();
updateJson(tree, 'package.json', (json) => ({
...json,
dependencies: { 'some-pkg': '^1.5.2' },
}));
expect(getDeclaredPackageVersion(tree, 'some-pkg')).toBe('1.5.2');
});
it('returns null when the package is not declared and no fallback is provided', () => {
const tree = createTreeWithEmptyWorkspace();
expect(getDeclaredPackageVersion(tree, 'some-pkg')).toBeNull();
});
it('falls back to the cleaned `latestKnownVersion` when the package is not declared', () => {
const tree = createTreeWithEmptyWorkspace();
expect(getDeclaredPackageVersion(tree, 'some-pkg', '^2.3.4')).toBe('2.3.4');
});
it('resolves `latest` to the cleaned `latestKnownVersion`', () => {
const tree = createTreeWithEmptyWorkspace();
updateJson(tree, 'package.json', (json) => ({
...json,
dependencies: { 'some-pkg': 'latest' },
}));
expect(getDeclaredPackageVersion(tree, 'some-pkg', '^2.3.4')).toBe('2.3.4');
});
it('resolves `next` to the cleaned `latestKnownVersion`', () => {
const tree = createTreeWithEmptyWorkspace();
updateJson(tree, 'package.json', (json) => ({
...json,
dependencies: { 'some-pkg': 'next' },
}));
expect(getDeclaredPackageVersion(tree, 'some-pkg', '^2.3.4')).toBe('2.3.4');
});
it('returns null for `latest` when no `latestKnownVersion` is provided', () => {
const tree = createTreeWithEmptyWorkspace();
updateJson(tree, 'package.json', (json) => ({
...json,
dependencies: { 'some-pkg': 'latest' },
}));
expect(getDeclaredPackageVersion(tree, 'some-pkg')).toBeNull();
});
it('returns null when the declared range cannot be normalized', () => {
const tree = createTreeWithEmptyWorkspace();
updateJson(tree, 'package.json', (json) => ({
...json,
dependencies: { 'some-pkg': 'workspace:*' },
}));
expect(getDeclaredPackageVersion(tree, 'some-pkg')).toBeNull();
});
});
describe('getInstalledPackageVersion', () => {
it('returns null for an unresolvable package', () => {
expect(
getInstalledPackageVersion('some-pkg-that-cannot-possibly-exist-xyz')
).toBeNull();
});
it('returns the concrete installed version for a resolvable package', () => {
expect(getInstalledPackageVersion('semver')).toMatch(/^\d+\.\d+\.\d+/);
});
});
@@ -0,0 +1,61 @@
import { readModulePackageJson } from 'nx/src/devkit-internals';
import type { Tree } from 'nx/src/devkit-exports';
import { clean, coerce } from 'semver';
import { getDependencyVersionFromPackageJson } from './package-json';
/**
* Returns the concrete version of a package as resolved by Node module
* resolution from the workspace. Reads the installed package's own
* `package.json` — not the workspace's declared range.
*
* Use this from executor / runtime contexts where node_modules is present.
* Generator-time code should use `getDeclaredPackageVersion` instead.
*
* Returns `null` when the package is not resolvable.
*/
export function getInstalledPackageVersion(packageName: string): string | null {
try {
const { packageJson } = readModulePackageJson(packageName);
return packageJson.version ?? null;
} catch {
return null;
}
}
/**
* Returns the declared version of a package as read from the workspace's
* `package.json`, normalized to a plain semver string (range markers
* stripped) suitable for arithmetic comparisons (e.g. `lt(v, '1.37.0')`).
*
* When the package is missing or declared as `latest`/`next`, falls back to
* the cleaned `latestKnownVersion` if provided; otherwise returns `null`.
*
* Use this from generator-time contexts where node_modules is not assumed
* to be present. Executor / runtime code should use
* `getInstalledPackageVersion` instead.
*/
export function getDeclaredPackageVersion(
tree: Tree,
packageName: string,
latestKnownVersion?: string
): string | null {
const declared = getDependencyVersionFromPackageJson(tree, packageName);
if (declared && !isNonSemverDistTag(declared)) {
const normalized = normalizeSemver(declared);
if (normalized) return normalized;
}
return latestKnownVersion ? normalizeSemver(latestKnownVersion) : null;
}
export const NON_SEMVER_DIST_TAGS = ['latest', 'next'] as const;
export type NonSemverDistTag = (typeof NON_SEMVER_DIST_TAGS)[number];
export function isNonSemverDistTag(
version: string
): version is NonSemverDistTag {
return (NON_SEMVER_DIST_TAGS as readonly string[]).includes(version);
}
export function normalizeSemver(version: string): string | null {
return clean(version) ?? coerce(version)?.version ?? null;
}
@@ -0,0 +1,36 @@
import type { Tree } from 'nx/src/generators/tree';
import { convertNxGenerator } from './invoke-nx-generator';
import { lastValueFrom } from 'rxjs';
describe('Convert Nx Generator', () => {
it('should convert an nx generator to angular schematic correctly', async () => {
// ARRANGE
const {
SchematicTestRunner,
UnitTestTree,
} = require('@angular-devkit/schematics/testing');
const ngSchematicRunner = new SchematicTestRunner(
'@schematics/angular',
require.resolve('@schematics/angular/collection.json')
);
const appTree = await ngSchematicRunner.runSchematic('workspace', {
name: 'workspace',
newProjectRoot: 'projects',
version: '6.0.0',
});
// ACT
const convertedGenerator = convertNxGenerator(newFileGenerator);
const tree: typeof UnitTestTree = await lastValueFrom(
ngSchematicRunner.callRule(convertedGenerator, appTree)
);
// ASSERT
expect(tree.files).toContain(`/my-file.ts`);
});
});
async function newFileGenerator(tree: Tree, options: {}) {
tree.write('my-file.ts', `const hello = "hello world";`);
}
@@ -0,0 +1,216 @@
import { join, relative } from 'path';
import type { Mode } from 'fs';
import type { TreeWriteOptions } from 'nx/src/generators/tree';
import {
FileChange,
Generator,
GeneratorCallback,
logger,
Tree,
} from 'nx/src/devkit-exports';
import { stripIndent } from 'nx/src/devkit-internals';
class RunCallbackTask {
constructor(private callback: GeneratorCallback) {}
toConfiguration() {
return {
name: 'RunCallback',
options: {
callback: this.callback,
},
};
}
}
function createRunCallbackTask() {
return {
name: 'RunCallback',
create: () => {
return Promise.resolve(
async ({ callback }: { callback: GeneratorCallback }) => {
await callback();
}
);
},
};
}
/**
* Convert an Nx Generator into an Angular Devkit Schematic.
* @param generator The Nx generator to convert to an Angular Devkit Schematic.
*/
export function convertNxGenerator<T = any>(
generator: Generator<T>,
skipWritingConfigInOldFormat: boolean = false
) {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
return (generatorOptions: T) =>
invokeNxGenerator(generator, generatorOptions);
}
/**
* Create a Rule to invoke an Nx Generator
*/
function invokeNxGenerator<T = any>(
generator: Generator<T>,
options: T,
skipWritingConfigInOldFormat?: boolean
) {
return async (tree, context) => {
if (context.engine.workflow) {
const engineHost = (context.engine.workflow as any).engineHost;
engineHost.registerTaskExecutor(createRunCallbackTask());
}
const root =
context.engine.workflow && context.engine.workflow.engineHost.paths
? context.engine.workflow.engineHost.paths[1]
: tree.root.path;
const adapterTree = new DevkitTreeFromAngularDevkitTree(
tree,
root,
skipWritingConfigInOldFormat
);
const result = await generator(adapterTree, options);
if (!result) {
return adapterTree['tree'];
}
if (typeof result === 'function') {
if (context.engine.workflow) {
context.addTask(new RunCallbackTask(result));
}
}
};
}
const actionToFileChangeMap = {
c: 'CREATE',
o: 'UPDATE',
d: 'DELETE',
};
class DevkitTreeFromAngularDevkitTree implements Tree {
private configFileName: string;
constructor(
private tree,
private _root: string,
private skipWritingConfigInOldFormat?: boolean
) {
/**
* When using the UnitTestTree from @angular-devkit/schematics/testing, the root is just `/`.
* This causes a massive issue if `getProjects()` is used in the underlying generator because it
* causes fast-glob to be set to work on the user's entire file system.
*
* Therefore, in this case, patch the root to match what Nx Devkit does and use /virtual instead.
*/
try {
const { UnitTestTree } = require('@angular-devkit/schematics/testing');
if (tree instanceof UnitTestTree && _root === '/') {
this._root = '/virtual';
}
} catch {}
}
get root(): string {
return this._root;
}
children(dirPath: string): string[] {
const { subdirs, subfiles } = this.tree.getDir(dirPath);
return [...subdirs, ...subfiles];
}
delete(filePath: string): void {
this.tree.delete(filePath);
}
exists(filePath: string): boolean {
if (this.isFile(filePath)) {
return this.tree.exists(filePath);
} else {
return this.children(filePath).length > 0;
}
}
isFile(filePath: string): boolean {
return this.tree.exists(filePath) && !!this.tree.read(filePath);
}
listChanges(): FileChange[] {
const fileChanges = [];
for (const action of this.tree.actions) {
if (action.kind === 'r') {
fileChanges.push({
path: this.normalize(action.to),
type: 'CREATE',
content: this.read(action.to),
});
fileChanges.push({
path: this.normalize(action.path),
type: 'DELETE',
content: null,
});
} else if (action.kind === 'c' || action.kind === 'o') {
fileChanges.push({
path: this.normalize(action.path),
type: actionToFileChangeMap[action.kind],
content: action.content,
});
} else {
fileChanges.push({
path: this.normalize(action.path),
type: 'DELETE',
content: null,
});
}
}
return fileChanges;
}
private normalize(path: string): string {
return relative(this.root, join(this.root, path));
}
read(filePath: string): Buffer;
read(filePath: string, encoding: BufferEncoding): string;
read(filePath: string, encoding?: BufferEncoding) {
return encoding
? this.tree.read(filePath).toString(encoding)
: this.tree.read(filePath);
}
rename(from: string, to: string): void {
this.tree.rename(from, to);
}
write(
filePath: string,
content: Buffer | string,
options?: TreeWriteOptions
): void {
if (options?.mode) {
this.warnUnsupportedFilePermissionsChange(filePath, options.mode);
}
if (this.tree.exists(filePath)) {
this.tree.overwrite(filePath, content);
} else {
this.tree.create(filePath, content);
}
}
changePermissions(filePath: string, mode: Mode): void {
this.warnUnsupportedFilePermissionsChange(filePath, mode);
}
private warnUnsupportedFilePermissionsChange(filePath: string, mode: Mode) {
logger.warn(
stripIndent(`The Angular DevKit tree does not support changing a file permissions.
Ignoring changing ${filePath} permissions to ${mode}.`)
);
}
}
@@ -0,0 +1,10 @@
import { output } from 'nx/src/devkit-exports';
export function logShowProjectCommand(projectName: string): void {
output.log({
title: `👀 View Details of ${projectName}`,
bodyLines: [
`Run "nx show project ${projectName}" to view details about this project.`,
],
});
}
+27
View File
@@ -0,0 +1,27 @@
import { relative } from 'path';
import { visitNotIgnoredFiles } from '../generators/visit-not-ignored-files';
import { normalizePath, Tree } from 'nx/src/devkit-exports';
/**
* Analogous to cp -r oldDir newDir
*/
export function moveFilesToNewDirectory(
tree: Tree,
oldDir: string,
newDir: string
): void {
oldDir = normalizePath(oldDir);
newDir = normalizePath(newDir);
visitNotIgnoredFiles(tree, oldDir, (file) => {
try {
tree.rename(file, `${newDir}/${relative(oldDir, file)}`);
} catch (e) {
if (!tree.exists(oldDir)) {
console.warn(`Path ${oldDir} does not exist`);
} else if (!tree.exists(newDir)) {
console.warn(`Path ${newDir} does not exist`);
}
}
});
}
+52
View File
@@ -0,0 +1,52 @@
import { names } from './names';
describe('names', () => {
it('should support class names', () => {
expect(names('foo-bar').className).toEqual('FooBar');
expect(names('foo_bar').className).toEqual('FooBar');
expect(names('fooBar').className).toEqual('FooBar');
expect(names('FooBar').className).toEqual('FooBar');
expect(names('[fooBar]').className).toEqual('FooBar');
expect(names('[...fooBar]').className).toEqual('FooBar');
expect(names('foo-@bar').className).toEqual('FooBar');
expect(names(' foo bar').className).toEqual('FooBar');
expect(names('_foo_bar').className).toEqual('FooBar');
});
it('should support property names', () => {
expect(names('foo-bar').propertyName).toEqual('fooBar');
expect(names('foo_bar').propertyName).toEqual('fooBar');
expect(names('fooBar').propertyName).toEqual('fooBar');
expect(names('FooBar').propertyName).toEqual('fooBar');
expect(names('[fooBar]').propertyName).toEqual('fooBar');
expect(names('[...fooBar]').propertyName).toEqual('fooBar');
expect(names('foo-@bar').propertyName).toEqual('fooBar');
expect(names(' foo bar').propertyName).toEqual('fooBar');
expect(names('_foo_bar').propertyName).toEqual('fooBar');
});
it('should support file names', () => {
expect(names('foo-bar').fileName).toEqual('foo-bar');
expect(names('foo_bar').fileName).toEqual('foo-bar');
expect(names('fooBar').fileName).toEqual('foo-bar');
expect(names('FooBar').fileName).toEqual('foo-bar');
expect(names('[fooBar]').fileName).toEqual('[foo-bar]');
expect(names('[...fooBar]').fileName).toEqual('[...foo-bar]');
expect(names('foo-@bar').fileName).toEqual('foo-@bar');
expect(names(' foo bar').fileName).toEqual('-foo-bar');
expect(names('_foo_bar').fileName).toEqual('_foo-bar');
});
it('should support constant names', () => {
expect(names('foo-bar').constantName).toEqual('FOO_BAR');
expect(names('foo_bar').constantName).toEqual('FOO_BAR');
expect(names('fooBar').constantName).toEqual('FOO_BAR');
expect(names('FooBar').constantName).toEqual('FOO_BAR');
expect(names('[fooBar]').constantName).toEqual('FOO_BAR');
expect(names('[...fooBar]').constantName).toEqual('FOO_BAR');
expect(names('foo-@bar').constantName).toEqual('FOO_BAR');
expect(names(' foo bar').constantName).toEqual('FOO_BAR');
expect(names('_foo_bar').constantName).toEqual('FOO_BAR');
expect(names('FOO_BAR').constantName).toEqual('FOO_BAR');
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Util function to generate different strings based off the provided name.
*
* Examples:
*
* ```typescript
* names("my-name") // {name: 'my-name', className: 'MyName', propertyName: 'myName', constantName: 'MY_NAME', fileName: 'my-name'}
* names("myName") // {name: 'myName', className: 'MyName', propertyName: 'myName', constantName: 'MY_NAME', fileName: 'my-name'}
* ```
* @param name
*/
export function names(name: string): {
name: string;
className: string;
propertyName: string;
constantName: string;
fileName: string;
} {
return {
name,
className: toClassName(name),
propertyName: toPropertyName(name),
constantName: toConstantName(name),
fileName: toFileName(name),
};
}
/**
* Hyphenated to UpperCamelCase
*/
function toClassName(str: string): string {
return toCapitalCase(toPropertyName(str));
}
/**
* Hyphenated to lowerCamelCase
*/
function toPropertyName(s: string): string {
return s
.replace(/([^a-zA-Z0-9])+(.)?/g, (_, __, chr) =>
chr ? chr.toUpperCase() : ''
)
.replace(/[^a-zA-Z\d]/g, '')
.replace(/^([A-Z])/, (m) => m.toLowerCase());
}
/**
* Hyphenated to CONSTANT_CASE
*/
function toConstantName(s: string): string {
const normalizedS = s.toUpperCase() === s ? s.toLowerCase() : s;
return toFileName(toPropertyName(normalizedS))
.replace(/([^a-zA-Z0-9])/g, '_')
.toUpperCase();
}
/**
* Upper camelCase to lowercase, hyphenated
*/
function toFileName(s: string): string {
return s
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
.toLowerCase()
.replace(/(?!^[_])[ _]/g, '-');
}
/**
* Capitalizes the first letter of a string
*/
function toCapitalCase(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
@@ -0,0 +1,28 @@
import { offsetFromRoot } from './offset-from-root';
describe('offsetFromRoot', () => {
it('should work for normal paths', () => {
const result = offsetFromRoot('apps/appname');
expect(result).toBe('../../');
});
it('should work for paths with a trailing slash', () => {
const result = offsetFromRoot('apps/appname/');
expect(result).toBe('../../');
});
it('should work for deep paths', () => {
const result = offsetFromRoot('apps/dirname/appname');
expect(result).toBe('../../../');
});
it('should work for deep paths with a trailing slash', () => {
const result = offsetFromRoot('apps/dirname/appname/');
expect(result).toBe('../../../');
});
it('should work for root', () => {
const result = offsetFromRoot('.');
expect(result).toBe('./');
});
});
@@ -0,0 +1,26 @@
import { normalize, sep } from 'path';
/**
* Calculates an offset from the root of the workspace, which is useful for
* constructing relative URLs.
*
* Examples:
*
* ```typescript
* offsetFromRoot("apps/mydir/myapp/") // returns "../../../"
* ```
*
* @param fullPathToDir - directory path
*/
export function offsetFromRoot(fullPathToDir: string): string {
if (fullPathToDir === '.') return './';
const parts = normalize(fullPathToDir).split(sep);
let offset = '';
for (let i = 0; i < parts.length; ++i) {
if (parts[i].length > 0) {
offset += '../';
}
}
return offset;
}
@@ -0,0 +1,701 @@
import * as devkitExports from 'nx/src/devkit-exports';
import { createTree } from 'nx/src/generators/testing-utils/create-tree';
import type { Tree } from 'nx/src/generators/tree';
import { readJson, writeJson } from 'nx/src/generators/utils/json';
import { addDependenciesToPackageJson, ensurePackage } from './package-json';
// Mock fs for catalog tests
jest.mock('fs', () => require('memfs').fs);
jest.mock('node:fs', () => require('memfs').fs);
// Mock yaml reading functions
jest.mock('nx/src/devkit-internals', () => ({
...jest.requireActual('nx/src/devkit-internals'),
readYamlFile: jest.fn((path: string) => {
const { vol } = require('memfs');
try {
const content = vol.readFileSync(path, 'utf8');
return require('@zkochan/js-yaml').load(content);
} catch (error) {
throw new Error(`Cannot read YAML file at ${path}`);
}
}),
}));
describe('addDependenciesToPackageJson', () => {
let tree: Tree;
beforeEach(() => {
tree = createTree();
writeJson(tree, 'package.json', {
dependencies: {
react: 'latest',
},
devDependencies: {
jest: 'latest',
},
});
});
it('should not add dependency if it is not greater', () => {
writeJson(tree, 'package.json', {
dependencies: {
tslib: '^2.0.0',
},
devDependencies: {
jest: '28.1.3',
},
});
const installTask = addDependenciesToPackageJson(
tree,
{
tslib: '^2.3.0',
},
{ jest: '28.1.1' }
);
expect(readJson(tree, 'package.json')).toEqual({
dependencies: { tslib: '^2.3.0' },
devDependencies: { jest: '28.1.3' },
});
expect(installTask).toBeDefined();
});
it('should add dependencies to the package.json', () => {
const installTask = addDependenciesToPackageJson(
tree,
{
'react-dom': 'latest',
},
{}
);
expect(readJson(tree, 'package.json').dependencies).toEqual({
react: 'latest',
'react-dom': 'latest',
});
expect(installTask).toBeDefined();
});
it('should overwrite existing dependencies in the package.json if the version tag is greater', () => {
const installTask = addDependenciesToPackageJson(
tree,
{
react: 'next',
},
{}
);
expect(readJson(tree, 'package.json').dependencies).toEqual({
react: 'next',
});
expect(installTask).toBeDefined();
});
it('should add devDependencies to the package.json', () => {
const installTask = addDependenciesToPackageJson(
tree,
{},
{
'@nx/react': 'latest',
}
);
expect(readJson(tree, 'package.json').devDependencies).toEqual({
jest: 'latest',
'@nx/react': 'latest',
});
expect(installTask).toBeDefined();
});
it('should overwrite existing devDependencies in the package.json if the version tag is greater', () => {
const installTask = addDependenciesToPackageJson(
tree,
{},
{
jest: 'next',
}
);
expect(readJson(tree, 'package.json').devDependencies).toEqual({
jest: 'next',
});
expect(installTask).toBeDefined();
});
it('should overwrite dependencies when they exist in devDependencies or vice versa and the version tag is greater', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': 'latest',
},
devDependencies: {
'@nx/next': 'latest',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/next': 'next',
},
{
'@nx/angular': 'next',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': 'next',
});
expect(devDependencies).toEqual({
'@nx/next': 'next',
});
expect(installTask).toBeDefined();
});
it('should not overwrite dependencies when they exist in devDependencies or vice versa and the version tag is lesser', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': 'next',
},
devDependencies: {
'@nx/next': 'next',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/next': 'latest',
},
{
'@nx/angular': 'latest',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': 'next',
});
expect(devDependencies).toEqual({
'@nx/next': 'next',
});
expect(installTask).toBeDefined();
});
it('should overwrite dependencies when they exist in devDependencies or vice versa and the version is greater', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': '14.0.0',
},
devDependencies: {
'@nx/next': '14.0.0',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/next': '14.1.0',
},
{
'@nx/angular': '14.1.0',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': '14.1.0',
});
expect(devDependencies).toEqual({
'@nx/next': '14.1.0',
});
expect(installTask).toBeDefined();
});
it('should not overwrite dependencies when they exist in devDependencies or vice versa and the version is lesser', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': '14.1.0',
},
devDependencies: {
'@nx/next': '14.1.0',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/next': '14.0.0',
},
{
'@nx/angular': '14.0.0',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': '14.1.0',
});
expect(devDependencies).toEqual({
'@nx/next': '14.1.0',
});
expect(installTask).toBeDefined();
});
it('should not overwrite dependencies when they exist in "dependencies" and one of the versions is lesser', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': '14.2.0',
'@nx/cypress': '14.1.1',
},
devDependencies: {
'@nx/next': '14.0.0',
'@nx/vite': '14.1.0',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/angular': '14.1.0',
},
{
'@nx/next': '14.1.0',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': '14.2.0',
'@nx/cypress': '14.1.1',
});
expect(devDependencies).toEqual({
'@nx/next': '14.1.0',
'@nx/vite': '14.1.0',
});
expect(installTask).toBeDefined();
});
it('should not overwrite dependencies when they exist in "devDependencies" and one of the versions is lesser', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': '14.0.0',
'@nx/cypress': '14.1.1',
},
devDependencies: {
'@nx/next': '14.2.0',
'@nx/vite': '14.1.0',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/angular': '14.1.0',
},
{
'@nx/next': '14.1.0',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': '14.1.0',
'@nx/cypress': '14.1.1',
});
expect(devDependencies).toEqual({
'@nx/next': '14.2.0',
'@nx/vite': '14.1.0',
});
expect(installTask).toBeDefined();
});
it('should only overwrite dependencies when their version is greater', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': '14.0.0',
},
devDependencies: {
'@nx/next': '14.1.0',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/next': '14.0.0',
},
{
'@nx/angular': '14.1.0',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': '14.1.0',
});
expect(devDependencies).toEqual({
'@nx/next': '14.1.0',
});
expect(installTask).toBeDefined();
});
it('should overwrite dependencies when their version is not in a semver format', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': 'github:reponame/packageNameOne',
'@nx/vite': 'git://github.com/npm/cli.git#v14.2.0', // this format is parsable
},
devDependencies: {
'@nx/next': '14.1.0',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/next': 'github:reponame/packageNameTwo',
'@nx/cypress':
'git+https://username@github.com/reponame/packagename.git',
'@nx/vite': '14.0.1',
},
{
'@nx/angular': '14.1.0',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': '14.1.0',
'@nx/cypress': 'git+https://username@github.com/reponame/packagename.git',
'@nx/vite': 'git://github.com/npm/cli.git#v14.2.0',
});
expect(devDependencies).toEqual({
'@nx/next': 'github:reponame/packageNameTwo',
});
expect(installTask).toBeDefined();
});
it('should add additional dependencies when they dont exist in devDependencies or vice versa and not update the ones that do exist', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
'@nx/angular': 'latest',
},
devDependencies: {
'@nx/next': 'latest',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
'@nx/next': 'next',
'@nx/cypress': 'latest',
},
{
'@nx/angular': 'next',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
'@nx/angular': 'next',
'@nx/cypress': 'latest',
});
expect(devDependencies).toEqual({
'@nx/next': 'next',
});
expect(installTask).toBeDefined();
});
it('should not overwrite dependencies when they exist in devDependencies or vice versa and the new version is tilde', () => {
// ARRANGE
writeJson(tree, 'package.json', {
dependencies: {
tslib: '2.4.0',
},
devDependencies: {
nx: '15.0.0',
},
});
// ACT
const installTask = addDependenciesToPackageJson(
tree,
{
tslib: '~2.3.0',
},
{
nx: '15.0.0',
}
);
// ASSERT
const { dependencies, devDependencies } = readJson(tree, 'package.json');
expect(dependencies).toEqual({
tslib: '2.4.0',
});
expect(devDependencies).toEqual({
nx: '15.0.0',
});
expect(installTask).toBeDefined();
});
it('should allow existing versions to be kept', () => {
writeJson(tree, 'package.json', {
dependencies: {
foo: '1.0.0',
},
});
addDependenciesToPackageJson(
tree,
{
foo: '2.0.0',
},
{},
undefined,
true
);
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({
foo: '1.0.0',
});
});
describe('catalog support', () => {
beforeEach(() => {
jest.spyOn(devkitExports, 'detectPackageManager').mockReturnValue('pnpm');
tree.root = '/test-workspace';
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should update existing catalog dependencies in pnpm workspace', () => {
tree.write(
'pnpm-workspace.yaml',
`
packages:
- packages/*
catalog:
react: ^18.0.0
`
);
writeJson(tree, 'package.json', {
dependencies: { react: 'catalog:' },
});
addDependenciesToPackageJson(tree, { react: '^18.2.0' }, {});
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({ react: 'catalog:' });
const workspace = tree.read('pnpm-workspace.yaml', 'utf-8');
expect(workspace).toContain('react: ^18.2.0');
});
it('should add new dependencies as regular dependencies when no existing catalog reference', () => {
writeJson(tree, 'package.json', { dependencies: {} });
addDependenciesToPackageJson(tree, { lodash: '^4.17.21' }, {});
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({ lodash: '^4.17.21' });
});
it('should use direct dependencies with unsupported package managers', () => {
jest.spyOn(devkitExports, 'detectPackageManager').mockReturnValue('npm');
writeJson(tree, 'package.json', {
dependencies: { react: 'catalog:' },
});
addDependenciesToPackageJson(tree, { react: '^18.0.0' }, {});
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({ react: '^18.0.0' });
});
it('should handle mixed catalog and direct dependencies', () => {
tree.write(
'pnpm-workspace.yaml',
`packages:\n - packages/*\ncatalog:\n react: ^18.0.0`
);
writeJson(tree, 'package.json', {
dependencies: { react: 'catalog:', lodash: '^4.17.20' },
});
addDependenciesToPackageJson(
tree,
{ react: '^18.2.0', lodash: '^4.17.21' },
{}
);
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({
react: 'catalog:',
lodash: '^4.17.21',
});
const workspace = tree.read('pnpm-workspace.yaml', 'utf-8');
expect(workspace).toContain('react: ^18.2.0');
});
it('should preserve existing catalog references when updating with direct versions', () => {
tree.write(
'pnpm-workspace.yaml',
`packages:\n - packages/*\ncatalog:\n react: ^18.0.0`
);
writeJson(tree, 'package.json', {
dependencies: { react: 'catalog:' },
});
addDependenciesToPackageJson(tree, { react: '^18.2.0' }, {});
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({ react: 'catalog:' });
const workspace = tree.read('pnpm-workspace.yaml', 'utf-8');
expect(workspace).toContain('react: ^18.2.0');
});
it('should update only the specific catalog when package exists in multiple catalogs', () => {
tree.write(
'pnpm-workspace.yaml',
`packages:\n - packages/*\ncatalog:\n react: ^18.0.0\ncatalogs:\n dev:\n react: ^17.0.0`
);
writeJson(tree, 'package.json', {
dependencies: { react: 'catalog:dev' },
});
addDependenciesToPackageJson(tree, { react: '^18.2.0' }, {});
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({ react: 'catalog:dev' });
const workspace = tree.read('pnpm-workspace.yaml', 'utf-8');
expect(workspace).toMatch(/catalogs:\s*dev:\s*react: "?\^18\.2\.0"?/);
expect(workspace).toMatch(/catalog:\s*react: "?\^18\.0\.0"?/);
});
it('should filter catalog dependencies using version comparison logic', () => {
tree.write(
'pnpm-workspace.yaml',
`packages:\n - packages/*\ncatalog:\n react: ^18.2.0`
);
writeJson(tree, 'package.json', {
dependencies: { react: 'catalog:' },
});
addDependenciesToPackageJson(tree, { react: '^18.1.0' }, {});
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({ react: 'catalog:' });
const workspace = tree.read('pnpm-workspace.yaml', 'utf-8');
expect(workspace).toContain('react: ^18.2.0');
});
it('should handle named catalog references', () => {
tree.write(
'pnpm-workspace.yaml',
`packages:\n - packages/*\ncatalogs:\n dev:\n jest: ^28.0.0`
);
writeJson(tree, 'package.json', {
devDependencies: { jest: 'catalog:dev' },
});
addDependenciesToPackageJson(tree, {}, { jest: '^29.0.0' });
const result = readJson(tree, 'package.json');
expect(result.devDependencies).toEqual({ jest: 'catalog:dev' });
const workspace = tree.read('pnpm-workspace.yaml', 'utf-8');
expect(workspace).toContain('jest: ^29.0.0');
});
it('should resolve catalog references for version comparison', () => {
tree.write(
'pnpm-workspace.yaml',
`packages:\n - packages/*\ncatalog:\n react: ^18.2.0`
);
writeJson(tree, 'package.json', {
dependencies: { react: 'catalog:' },
});
addDependenciesToPackageJson(tree, { react: '^18.1.0' }, {});
const result = readJson(tree, 'package.json');
expect(result.dependencies).toEqual({ react: 'catalog:' });
const workspace = tree.read('pnpm-workspace.yaml', 'utf-8');
expect(workspace).toContain('react: ^18.2.0');
});
it('should throw an error for invalid catalog references', () => {
tree.write(
'pnpm-workspace.yaml',
`packages:\n - packages/*\ncatalog: {}`
);
writeJson(tree, 'package.json', {
dependencies: { react: 'catalog:nonexistent' },
});
expect(() =>
addDependenciesToPackageJson(tree, { react: '^18.2.0' }, {})
).toThrow(
"Failed to resolve catalog reference 'catalog:nonexistent' for package 'react'"
);
});
});
});
describe('ensurePackage', () => {
let tree: Tree;
beforeEach(() => {
tree = createTree();
});
it('should return package when present', async () => {
writeJson(tree, 'package.json', {});
expect(ensurePackage('@nx/devkit', '>=15.0.0')).toEqual(
require('@nx/devkit')
); // return void
});
});
+973
View File
@@ -0,0 +1,973 @@
import { existsSync } from 'fs';
import { Module } from 'module';
import {
detectPackageManager,
type GeneratorCallback,
output,
readJson,
readJsonFile,
type Tree,
updateJson,
workspaceRoot,
} from 'nx/src/devkit-exports';
import { installPackageToTmp } from 'nx/src/devkit-internals';
import type {
PackageJson,
PackageJsonDependencySection,
} from 'nx/src/utils/package-json';
import { join, resolve } from 'path';
import { clean, coerce, gt } from 'semver';
import { installPackagesTask } from '../tasks/install-packages-task';
import {
getCatalogDependenciesFromPackageJson,
getCatalogManager,
} from './catalog';
const UNIDENTIFIED_VERSION = 'UNIDENTIFIED_VERSION';
const NON_SEMVER_TAGS = {
'*': 2,
[UNIDENTIFIED_VERSION]: 2,
next: 1,
latest: 0,
previous: -1,
legacy: -2,
};
/**
* Get the resolved version of a dependency from package.json.
*
* Retrieves a package version and automatically resolves PNPM catalog references
* (e.g., "catalog:default") to their actual version strings. By default, searches
* `dependencies` first, then falls back to `devDependencies`.
*
* **Tree-based usage** (generators and migrations):
* Use when you have a `Tree` object, which is typical in Nx generators and migrations.
*
* **Filesystem-based usage** (CLI commands and scripts):
* Use when reading directly from the filesystem without a `Tree` object.
*
* @example
* ```typescript
* // Tree-based - from root package.json (checks dependencies then devDependencies)
* const reactVersion = getDependencyVersionFromPackageJson(tree, 'react');
* // Returns: "^18.0.0" (resolves "catalog:default" if present)
*
* // Tree-based - check only dependencies section
* const version = getDependencyVersionFromPackageJson(
* tree,
* 'react',
* 'package.json',
* ['dependencies']
* );
*
* // Tree-based - check only devDependencies section
* const version = getDependencyVersionFromPackageJson(
* tree,
* 'jest',
* 'package.json',
* ['devDependencies']
* );
*
* // Tree-based - custom lookup order
* const version = getDependencyVersionFromPackageJson(
* tree,
* 'pkg',
* 'package.json',
* ['devDependencies', 'dependencies', 'peerDependencies']
* );
*
* // Tree-based - with pre-loaded package.json
* const packageJson = readJson(tree, 'package.json');
* const version = getDependencyVersionFromPackageJson(
* tree,
* 'react',
* packageJson,
* ['dependencies']
* );
* ```
*
* @example
* ```typescript
* // Filesystem-based - from current directory
* const reactVersion = getDependencyVersionFromPackageJson('react');
*
* // Filesystem-based - with workspace root
* const version = getDependencyVersionFromPackageJson('react', '/path/to/workspace');
*
* // Filesystem-based - with specific package.json and section
* const version = getDependencyVersionFromPackageJson(
* 'react',
* '/path/to/workspace',
* 'apps/my-app/package.json',
* ['dependencies']
* );
* ```
*
* @param dependencyLookup Array of dependency sections to check in order. Defaults to ['dependencies', 'devDependencies']
* @returns The resolved version string, or `null` if the package is not found in any of the specified sections
*/
export function getDependencyVersionFromPackageJson(
tree: Tree,
packageName: string,
packageJsonPath?: string,
dependencyLookup?: PackageJsonDependencySection[]
): string | null;
export function getDependencyVersionFromPackageJson(
tree: Tree,
packageName: string,
packageJson?: PackageJson,
dependencyLookup?: PackageJsonDependencySection[]
): string | null;
export function getDependencyVersionFromPackageJson(
packageName: string,
workspaceRootPath?: string,
packageJsonPath?: string,
dependencyLookup?: PackageJsonDependencySection[]
): string | null;
export function getDependencyVersionFromPackageJson(
packageName: string,
workspaceRootPath?: string,
packageJson?: PackageJson,
dependencyLookup?: PackageJsonDependencySection[]
): string | null;
export function getDependencyVersionFromPackageJson(
treeOrPackageName: Tree | string,
packageNameOrRoot?: string,
packageJsonPathOrObjectOrRoot?: string | PackageJson,
dependencyLookup?: PackageJsonDependencySection[]
): string | null {
if (typeof treeOrPackageName !== 'string') {
return getDependencyVersionFromPackageJsonFromTree(
treeOrPackageName,
packageNameOrRoot!,
packageJsonPathOrObjectOrRoot,
dependencyLookup
);
} else {
return getDependencyVersionFromPackageJsonFromFileSystem(
treeOrPackageName,
packageNameOrRoot,
packageJsonPathOrObjectOrRoot,
dependencyLookup
);
}
}
/**
* Tree-based implementation for getDependencyVersionFromPackageJson
*/
function getDependencyVersionFromPackageJsonFromTree(
tree: Tree,
packageName: string,
packageJsonPathOrObject: string | PackageJson = 'package.json',
dependencyLookup: PackageJsonDependencySection[] = [
'dependencies',
'devDependencies',
]
): string | null {
let packageJson: PackageJson;
if (typeof packageJsonPathOrObject === 'object') {
packageJson = packageJsonPathOrObject;
} else if (tree.exists(packageJsonPathOrObject)) {
packageJson = readJson(tree, packageJsonPathOrObject);
} else {
return null;
}
let version: string | null = null;
for (const section of dependencyLookup) {
const foundVersion = packageJson[section]?.[packageName];
if (foundVersion) {
version = foundVersion;
break;
}
}
// Resolve catalog reference if needed
const manager = getCatalogManager(tree.root);
if (version && manager?.isCatalogReference(version)) {
version = manager.resolveCatalogReference(tree, packageName, version);
}
return version;
}
/**
* Filesystem-based implementation for getDependencyVersionFromPackageJson
*/
function getDependencyVersionFromPackageJsonFromFileSystem(
packageName: string,
root: string = workspaceRoot,
packageJsonPathOrObject: string | PackageJson = 'package.json',
dependencyLookup: PackageJsonDependencySection[] = [
'dependencies',
'devDependencies',
]
): string | null {
let packageJson: PackageJson;
if (typeof packageJsonPathOrObject === 'object') {
packageJson = packageJsonPathOrObject;
} else {
const packageJsonPath = resolve(root, packageJsonPathOrObject);
if (existsSync(packageJsonPath)) {
packageJson = readJsonFile(packageJsonPath);
} else {
return null;
}
}
let version: string | null = null;
for (const section of dependencyLookup) {
const foundVersion = packageJson[section]?.[packageName];
if (foundVersion) {
version = foundVersion;
break;
}
}
// Resolve catalog reference if needed
const manager = getCatalogManager(root);
if (version && manager?.isCatalogReference(version)) {
version = manager.resolveCatalogReference(root, packageName, version);
}
return version;
}
function filterExistingDependencies(
dependencies: Record<string, string>,
existingAltDependencies: Record<string, string>
) {
if (!existingAltDependencies) {
return dependencies;
}
return Object.keys(dependencies ?? {})
.filter((d) => !existingAltDependencies[d])
.reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {});
}
function cleanSemver(tree: Tree, version: string, packageName: string) {
const manager = getCatalogManager(tree.root);
if (manager?.isCatalogReference(version)) {
const resolvedVersion = manager.resolveCatalogReference(
tree,
packageName,
version
);
if (!resolvedVersion) {
throw new Error(
`Failed to resolve catalog reference '${version}' for package '${packageName}'`
);
}
return clean(resolvedVersion) ?? coerce(resolvedVersion);
}
return clean(version) ?? coerce(version);
}
function isIncomingVersionGreater(
tree: Tree,
incomingVersion: string,
existingVersion: string,
packageName: string
) {
// the existing version might be a catalog reference, so we need to resolve
// it if that's the case
let resolvedExistingVersion = existingVersion;
const manager = getCatalogManager(tree.root);
if (manager?.isCatalogReference(existingVersion)) {
const resolved = manager.resolveCatalogReference(
tree,
packageName,
existingVersion
);
if (!resolved) {
// catalog is supported, but failed to resolve, we throw an error
throw new Error(
`Failed to resolve catalog reference '${existingVersion}' for package '${packageName}'`
);
}
resolvedExistingVersion = resolved;
}
// if version is in the format of "latest", "next" or similar - keep it, otherwise try to parse it
const incomingVersionCompareBy =
incomingVersion in NON_SEMVER_TAGS
? incomingVersion
: (cleanSemver(tree, incomingVersion, packageName)?.toString() ??
UNIDENTIFIED_VERSION);
const existingVersionCompareBy =
resolvedExistingVersion in NON_SEMVER_TAGS
? resolvedExistingVersion
: (cleanSemver(tree, resolvedExistingVersion, packageName)?.toString() ??
UNIDENTIFIED_VERSION);
if (
incomingVersionCompareBy in NON_SEMVER_TAGS &&
existingVersionCompareBy in NON_SEMVER_TAGS
) {
return (
NON_SEMVER_TAGS[incomingVersionCompareBy] >
NON_SEMVER_TAGS[existingVersionCompareBy]
);
}
if (
incomingVersionCompareBy in NON_SEMVER_TAGS ||
existingVersionCompareBy in NON_SEMVER_TAGS
) {
return true;
}
return gt(
cleanSemver(tree, incomingVersion, packageName),
cleanSemver(tree, resolvedExistingVersion, packageName)
);
}
function updateExistingAltDependenciesVersion(
tree: Tree,
dependencies: Record<string, string>,
existingAltDependencies: Record<string, string>,
workspaceRootPath: string
) {
return Object.keys(existingAltDependencies || {})
.filter((d) => {
if (!dependencies[d]) {
return false;
}
const incomingVersion = dependencies[d];
const existingVersion = existingAltDependencies[d];
return isIncomingVersionGreater(
tree,
incomingVersion,
existingVersion,
d
);
})
.reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {});
}
function updateExistingDependenciesVersion(
tree: Tree,
dependencies: Record<string, string>,
existingDependencies: Record<string, string> = {},
workspaceRootPath: string
) {
return Object.keys(dependencies)
.filter((d) => {
if (!existingDependencies[d]) {
return true;
}
const incomingVersion = dependencies[d];
const existingVersion = existingDependencies[d];
return isIncomingVersionGreater(
tree,
incomingVersion,
existingVersion,
d
);
})
.reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {});
}
/**
* Add Dependencies and Dev Dependencies to package.json
*
* For example:
* ```typescript
* addDependenciesToPackageJson(tree, { react: 'latest' }, { jest: 'latest' })
* ```
* This will **add** `react` and `jest` to the dependencies and devDependencies sections of package.json respectively.
*
* @param tree Tree representing file system to modify
* @param dependencies Dependencies to be added to the dependencies section of package.json
* @param devDependencies Dependencies to be added to the devDependencies section of package.json
* @param packageJsonPath Path to package.json
* @param keepExistingVersions If true, prevents existing dependencies from being bumped to newer versions
* @returns Callback to install dependencies only if necessary, no-op otherwise
*/
export function addDependenciesToPackageJson(
tree: Tree,
dependencies: Record<string, string>,
devDependencies: Record<string, string>,
packageJsonPath: string = 'package.json',
keepExistingVersions?: boolean
): GeneratorCallback {
const currentPackageJson = readJson(tree, packageJsonPath);
/** Dependencies to install that are not met in dev dependencies */
let filteredDependencies = filterExistingDependencies(
dependencies,
currentPackageJson.devDependencies
);
/** Dev dependencies to install that are not met in dependencies */
let filteredDevDependencies = filterExistingDependencies(
devDependencies,
currentPackageJson.dependencies
);
// filtered dependencies should consist of:
// - dependencies of the same type that are not present
// by default, filtered dependencies also include these (unless keepExistingVersions is true):
// - dependencies of the same type that have greater version
// - specified dependencies of the other type that have greater version and are already installed as current type
filteredDependencies = {
...updateExistingDependenciesVersion(
tree,
filteredDependencies,
currentPackageJson.dependencies,
tree.root
),
...updateExistingAltDependenciesVersion(
tree,
devDependencies,
currentPackageJson.dependencies,
tree.root
),
};
filteredDevDependencies = {
...updateExistingDependenciesVersion(
tree,
filteredDevDependencies,
currentPackageJson.devDependencies,
tree.root
),
...updateExistingAltDependenciesVersion(
tree,
dependencies,
currentPackageJson.devDependencies,
tree.root
),
};
if (keepExistingVersions) {
filteredDependencies = removeExistingDependencies(
filteredDependencies,
currentPackageJson.dependencies
);
filteredDevDependencies = removeExistingDependencies(
filteredDevDependencies,
currentPackageJson.devDependencies
);
} else {
filteredDependencies = removeLowerVersions(
tree,
filteredDependencies,
currentPackageJson.dependencies,
tree.root
);
filteredDevDependencies = removeLowerVersions(
tree,
filteredDevDependencies,
currentPackageJson.devDependencies,
tree.root
);
}
if (
requiresAddingOfPackages(
tree,
currentPackageJson,
filteredDependencies,
filteredDevDependencies,
tree.root
)
) {
const { catalogUpdates, directDependencies, directDevDependencies } =
splitDependenciesByCatalogType(
tree,
filteredDependencies,
filteredDevDependencies,
packageJsonPath
);
writeCatalogDependencies(tree, catalogUpdates);
writeDirectDependencies(
tree,
packageJsonPath,
directDependencies,
directDevDependencies
);
return (): void => {
installPackagesTask(tree);
};
}
return () => {};
}
interface DependencySplit {
catalogUpdates: Array<{
packageName: string;
version: string;
catalogName?: string;
}>;
directDependencies: Record<string, string>;
directDevDependencies: Record<string, string>;
}
function splitDependenciesByCatalogType(
tree: Tree,
filteredDependencies: Record<string, string>,
filteredDevDependencies: Record<string, string>,
packageJsonPath: string
): DependencySplit {
const allFilteredUpdates = {
...filteredDependencies,
...filteredDevDependencies,
};
const catalogUpdates: Array<{
packageName: string;
version: string;
catalogName?: string;
}> = [];
let directDependencies = { ...filteredDependencies };
let directDevDependencies = { ...filteredDevDependencies };
const manager = getCatalogManager(tree.root);
if (!manager) {
return {
catalogUpdates: [],
directDependencies: filteredDependencies,
directDevDependencies: filteredDevDependencies,
};
}
const existingCatalogDeps = getCatalogDependenciesFromPackageJson(
tree,
packageJsonPath,
manager
);
if (!existingCatalogDeps.size) {
return {
catalogUpdates: [],
directDependencies: filteredDependencies,
directDevDependencies: filteredDevDependencies,
};
}
// Check filtered results for catalog references or existing catalog dependencies
for (const [packageName, version] of Object.entries(allFilteredUpdates)) {
if (!existingCatalogDeps.has(packageName)) {
continue;
}
let catalogName = existingCatalogDeps.get(packageName)!;
const catalogRef = catalogName ? `catalog:${catalogName}` : 'catalog:';
try {
manager.validateCatalogReference(tree, packageName, catalogRef);
catalogUpdates.push({ packageName, version, catalogName });
// Remove from direct updates since this will be handled via catalog
delete directDependencies[packageName];
delete directDevDependencies[packageName];
} catch (error) {
output.error({
title: 'Invalid catalog reference',
bodyLines: [
`Invalid catalog reference "${catalogRef}" for package "${packageName}".`,
error.message,
],
});
throw new Error(
`Could not update "${packageName}" to version "${version}". See above for more details.`
);
}
}
return { catalogUpdates, directDependencies, directDevDependencies };
}
function writeCatalogDependencies(
tree: Tree,
catalogUpdates: Array<{
packageName: string;
version: string;
catalogName?: string;
}>
): void {
if (!catalogUpdates.length) {
return;
}
const manager = getCatalogManager(tree.root);
manager.updateCatalogVersions(tree, catalogUpdates);
}
function writeDirectDependencies(
tree: Tree,
packageJsonPath: string,
dependencies: Record<string, string>,
devDependencies: Record<string, string>
): void {
updateJson(tree, packageJsonPath, (json) => {
json.dependencies = {
...(json.dependencies || {}),
...dependencies,
};
json.devDependencies = {
...(json.devDependencies || {}),
...devDependencies,
};
json.dependencies = sortObjectByKeys(json.dependencies);
json.devDependencies = sortObjectByKeys(json.devDependencies);
return json;
});
}
/**
* @returns The the incoming dependencies that are higher than the existing verions
**/
function removeLowerVersions(
tree: Tree,
incomingDeps: Record<string, string>,
existingDeps: Record<string, string>,
workspaceRootPath: string
) {
return Object.keys(incomingDeps).reduce((acc, d) => {
if (
!existingDeps?.[d] ||
isIncomingVersionGreater(tree, incomingDeps[d], existingDeps[d], d)
) {
acc[d] = incomingDeps[d];
}
return acc;
}, {});
}
function removeExistingDependencies(
incomingDeps: Record<string, string>,
existingDeps: Record<string, string>
): Record<string, string> {
return Object.keys(incomingDeps).reduce((acc, d) => {
if (!existingDeps?.[d]) {
acc[d] = incomingDeps[d];
}
return acc;
}, {});
}
/**
* Remove Dependencies and Dev Dependencies from package.json
*
* For example:
* ```typescript
* removeDependenciesFromPackageJson(tree, ['react'], ['jest'])
* ```
* This will **remove** `react` and `jest` from the dependencies and devDependencies sections of package.json respectively.
*
* @param dependencies Dependencies to be removed from the dependencies section of package.json
* @param devDependencies Dependencies to be removed from the devDependencies section of package.json
* @returns Callback to uninstall dependencies only if necessary. undefined is returned if changes are not necessary.
*/
export function removeDependenciesFromPackageJson(
tree: Tree,
dependencies: string[],
devDependencies: string[],
packageJsonPath: string = 'package.json'
): GeneratorCallback {
const currentPackageJson = readJson(tree, packageJsonPath);
if (
requiresRemovingOfPackages(
currentPackageJson,
dependencies,
devDependencies
)
) {
updateJson(tree, packageJsonPath, (json) => {
if (json.dependencies) {
for (const dep of dependencies) {
delete json.dependencies[dep];
}
json.dependencies = sortObjectByKeys(json.dependencies);
}
if (json.devDependencies) {
for (const devDep of devDependencies) {
delete json.devDependencies[devDep];
}
json.devDependencies = sortObjectByKeys(json.devDependencies);
}
return json;
});
}
return (): void => {
installPackagesTask(tree);
};
}
function sortObjectByKeys<T>(obj: T): T {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return obj;
}
return Object.keys(obj)
.sort()
.reduce((result, key) => {
return {
...result,
[key]: obj[key],
};
}, {}) as T;
}
/**
* Verifies whether the given packageJson dependencies require an update
* given the deps & devDeps passed in
*/
function requiresAddingOfPackages(
tree: Tree,
packageJsonFile: PackageJson,
deps: Record<string, string>,
devDeps: Record<string, string>,
workspaceRootPath: string
): boolean {
let needsDepsUpdate = false;
let needsDevDepsUpdate = false;
packageJsonFile.dependencies = packageJsonFile.dependencies || {};
packageJsonFile.devDependencies = packageJsonFile.devDependencies || {};
if (Object.keys(deps).length > 0) {
needsDepsUpdate = Object.keys(deps).some((entry) => {
const incomingVersion = deps[entry];
if (packageJsonFile.dependencies[entry]) {
const existingVersion = packageJsonFile.dependencies[entry];
return isIncomingVersionGreater(
tree,
incomingVersion,
existingVersion,
entry
);
}
if (packageJsonFile.devDependencies[entry]) {
const existingVersion = packageJsonFile.devDependencies[entry];
return isIncomingVersionGreater(
tree,
incomingVersion,
existingVersion,
entry
);
}
return true;
});
}
if (Object.keys(devDeps).length > 0) {
needsDevDepsUpdate = Object.keys(devDeps).some((entry) => {
const incomingVersion = devDeps[entry];
if (packageJsonFile.devDependencies[entry]) {
const existingVersion = packageJsonFile.devDependencies[entry];
return isIncomingVersionGreater(
tree,
incomingVersion,
existingVersion,
entry
);
}
if (packageJsonFile.dependencies[entry]) {
const existingVersion = packageJsonFile.dependencies[entry];
return isIncomingVersionGreater(
tree,
incomingVersion,
existingVersion,
entry
);
}
return true;
});
}
return needsDepsUpdate || needsDevDepsUpdate;
}
/**
* Verifies whether the given packageJson dependencies require an update
* given the deps & devDeps passed in
*/
function requiresRemovingOfPackages(
packageJsonFile,
deps: string[],
devDeps: string[]
): boolean {
let needsDepsUpdate = false;
let needsDevDepsUpdate = false;
packageJsonFile.dependencies = packageJsonFile.dependencies || {};
packageJsonFile.devDependencies = packageJsonFile.devDependencies || {};
if (deps.length > 0) {
needsDepsUpdate = deps.some((entry) => packageJsonFile.dependencies[entry]);
}
if (devDeps.length > 0) {
needsDevDepsUpdate = devDeps.some(
(entry) => packageJsonFile.devDependencies[entry]
);
}
return needsDepsUpdate || needsDevDepsUpdate;
}
const packageMapCache = new Map<string, any>();
/**
* @typedef EnsurePackageOptions
* @type {object}
* @property {boolean} dev indicate if the package is a dev dependency
* @property {throwOnMissing} boolean throws an error when the package is missing
*/
/**
* @deprecated Use the other function signature without a Tree
*
* Use a package that has not been installed as a dependency.
*
* For example:
* ```typescript
* ensurePackage(tree, '@nx/jest', nxVersion)
* ```
* This install the @nx/jest@<nxVersion> and return the module
* When running with --dryRun, the function will throw when dependencies are missing.
* Returns null for ESM dependencies. Import them with a dynamic import instead.
*
* @param tree the file system tree
* @param pkg the package to check (e.g. @nx/jest)
* @param requiredVersion the version or semver range to check (e.g. ~1.0.0, >=1.0.0 <2.0.0)
* @param {EnsurePackageOptions} options?
*/
export function ensurePackage(
tree: Tree,
pkg: string,
requiredVersion: string,
options?: { dev?: boolean; throwOnMissing?: boolean }
): void;
/**
* Ensure that dependencies and devDependencies from package.json are installed at the required versions.
* Returns null for ESM dependencies. Import them with a dynamic import instead.
*
* For example:
* ```typescript
* ensurePackage('@nx/jest', nxVersion)
* ```
*
* @param pkg the package to install and require
* @param version the version to install if the package doesn't exist already
*/
export function ensurePackage<T extends any = any>(
pkg: string,
version: string
): T;
export function ensurePackage<T extends any = any>(
pkgOrTree: string | Tree,
requiredVersionOrPackage: string,
maybeRequiredVersion?: string,
_?: never
): T {
let pkg: string;
let requiredVersion: string;
if (typeof pkgOrTree === 'string') {
pkg = pkgOrTree;
requiredVersion = requiredVersionOrPackage;
} else {
// Old Signature
pkg = requiredVersionOrPackage;
requiredVersion = maybeRequiredVersion;
}
if (packageMapCache.has(pkg)) {
return packageMapCache.get(pkg) as T;
}
try {
return require(pkg);
} catch (e) {
if (e.code === 'ERR_REQUIRE_ESM') {
// The package is installed, but is an ESM package.
// The consumer of this function can import it as needed.
return null;
} else if (e.code !== 'MODULE_NOT_FOUND') {
throw e;
}
}
if (process.env.NX_DRY_RUN && process.env.NX_DRY_RUN !== 'false') {
throw new Error(
'NOTE: This generator does not support --dry-run. If you are running this in Nx Console, it should execute fine once you hit the "Generate" button.\n'
);
}
const { tempDir } = installPackageToTmp(
pkg,
requiredVersion,
detectPackageManager(workspaceRoot)
);
addToNodePath(join(workspaceRoot, 'node_modules'));
addToNodePath(join(tempDir, 'node_modules'));
// Re-initialize the added paths into require
(Module as any)._initPaths();
try {
const result = require(
require.resolve(pkg, {
paths: [tempDir],
})
);
packageMapCache.set(pkg, result);
return result;
} catch (e) {
if (e.code === 'ERR_REQUIRE_ESM') {
// The package is installed, but is an ESM package.
// The consumer of this function can import it as needed.
packageMapCache.set(pkg, null);
return null;
}
throw e;
}
}
function addToNodePath(dir: string) {
// NODE_PATH is a delimited list of paths.
// The delimiter is different for windows.
const delimiter = require('os').platform() === 'win32' ? ';' : ':';
const paths = process.env.NODE_PATH
? process.env.NODE_PATH.split(delimiter)
: [];
// The path is already in the node path
if (paths.includes(dir)) {
return;
}
// Add the tmp path
paths.push(dir);
// Update the env variable.
process.env.NODE_PATH = paths.join(delimiter);
}
function getInstalledPackageModuleVersion(pkg: string): string {
return require(join(pkg, 'package.json')).version;
}
/**
* @description The version of Nx used by the workspace. Returns null if no version is found.
*/
export const NX_VERSION = getInstalledPackageModuleVersion('nx');
@@ -0,0 +1,451 @@
import {
addProjectConfiguration,
CreateNodes,
readProjectConfiguration,
Tree,
} from 'nx/src/devkit-exports';
import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace';
import { replaceProjectConfigurationsWithPlugin } from './replace-project-configuration-with-plugin';
describe('replaceProjectConfigurationsWithPlugin', () => {
let tree: Tree;
let createNodes: CreateNodes;
beforeEach(async () => {
tree = createTreeWithEmptyWorkspace();
tree.write('proj/file.txt', '');
createNodes = [
'proj/file.txt',
(configFiles) =>
configFiles.map((configFile) => [
configFile,
{
projects: {
proj: {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: ['^build-base'],
inputs: ['default', '^default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
configurations: {
production: {
configFile: 'file.prod.txt',
},
},
},
},
},
},
},
]),
];
});
it('should not update the target when it uses a different executor', async () => {
const buildTarget = {
executor: 'nx:run-script',
inputs: ['default', '^default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
};
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: buildTarget,
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual(
buildTarget
);
});
describe('options', () => {
it('should be removed when there are no other options', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default', '^default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(
readProjectConfiguration(tree, 'proj').targets.build
).toBeUndefined();
});
it('should not be removed when there are other options', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default', '^default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
watch: false,
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
options: {
watch: false,
},
});
});
});
describe('inputs', () => {
it('should not be removed if there are additional inputs', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default', '^default', '{workspaceRoot}/file.txt'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
inputs: ['default', '^default', '{workspaceRoot}/file.txt'],
});
});
it('should not be removed if there are additional inputs which are objects', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: [
'default',
'^default',
{
env: 'HOME',
},
],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
inputs: [
'default',
'^default',
{
env: 'HOME',
},
],
});
});
it('should not be removed if there are less inputs', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default'],
outputs: ['{options.output}', '{projectRoot}/outputs'],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
inputs: ['default'],
});
});
});
describe('outputs', () => {
it('should not be removed if there are additional outputs', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
inputs: ['default', '^default'],
outputs: [
'{options.output}',
'{projectRoot}/outputs',
'{projectRoot}/more-outputs',
],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
outputs: [
'{options.output}',
'{projectRoot}/outputs',
'{projectRoot}/more-outputs',
],
});
});
it('should not be removed if there are less outputs', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
outputs: ['{options.output}'],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
outputs: ['{options.output}'],
});
});
});
describe('dependsOn', () => {
it('should be removed when it is the same', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: ['^build-base'],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(
readProjectConfiguration(tree, 'proj').targets.build
).toBeUndefined();
});
it('should not be removed when there are more dependent tasks', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: ['^build-base', 'prebuild'],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
dependsOn: ['^build-base', 'prebuild'],
});
});
it('should not be removed when there are less dependent tasks', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
dependsOn: [],
options: {
configFile: 'file.txt',
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
dependsOn: [],
});
});
});
describe('defaultConfiguration', () => {
it('should not be removed when the defaultConfiguration is different', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
options: {
configFile: 'file.txt',
},
defaultConfiguration: 'other',
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
defaultConfiguration: 'other',
});
});
});
describe('configurations', () => {
it('should not be removed when an additional configuration is defined', async () => {
addProjectConfiguration(tree, 'proj', {
root: 'proj',
targets: {
build: {
executor: 'nx:run-commands',
options: {
configFile: 'file.txt',
},
configurations: {
other: {
configFile: 'other-file.txt',
},
},
},
},
});
await replaceProjectConfigurationsWithPlugin(
tree,
new Map([['proj', 'proj']]),
'plugin-path',
createNodes,
{}
);
expect(readProjectConfiguration(tree, 'proj').targets.build).toEqual({
configurations: {
other: {
configFile: 'other-file.txt',
},
},
});
});
});
});
@@ -0,0 +1,218 @@
import {
CreateNodes,
glob,
ProjectConfiguration,
readNxJson,
readProjectConfiguration,
TargetConfiguration,
Tree,
updateNxJson,
updateProjectConfiguration,
} from 'nx/src/devkit-exports';
import { findProjectForPath, hashObject } from 'nx/src/devkit-internals';
export async function replaceProjectConfigurationsWithPlugin<T = unknown>(
tree: Tree,
rootMappings: Map<string, string>,
pluginPath: string,
createNodes: CreateNodes<T>,
pluginOptions: T
): Promise<void> {
const nxJson = readNxJson(tree);
const hasPlugin = nxJson.plugins?.some((p) =>
typeof p === 'string' ? p === pluginPath : p.plugin === pluginPath
);
if (hasPlugin) {
return;
}
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: pluginPath,
options: pluginOptions,
});
updateNxJson(tree, nxJson);
const [pluginGlob, createNodesFunction] = createNodes;
const configFiles = glob(tree, [pluginGlob]);
const results = await createNodesFunction(configFiles, pluginOptions, {
workspaceRoot: tree.root,
nxJsonConfiguration: readNxJson(tree),
});
for (const [configFile, nodes] of results) {
try {
const projectName = findProjectForPath(configFile, rootMappings);
const projectConfig = readProjectConfiguration(tree, projectName);
const node = nodes.projects[Object.keys(nodes.projects)[0]];
for (const [targetName, targetConfig] of Object.entries(
node.targets ?? {}
)) {
const targetFromProjectConfig = projectConfig.targets[targetName];
if (
targetFromProjectConfig?.executor !==
(targetConfig as TargetConfiguration).executor
) {
continue;
}
const targetFromCreateNodes = node.targets[targetName];
removeConfigurationDefinedByPlugin(
targetName,
targetFromProjectConfig,
targetFromCreateNodes,
projectConfig
);
}
updateProjectConfiguration(tree, projectName, projectConfig);
} catch (e) {
console.error(e);
}
}
}
function removeConfigurationDefinedByPlugin<T>(
targetName: string,
targetFromProjectConfig: TargetConfiguration<T>,
targetFromCreateNodes: TargetConfiguration<T>,
projectConfig: ProjectConfiguration
) {
// Executor
delete targetFromProjectConfig.executor;
// Default Configuration
if (
targetFromProjectConfig.defaultConfiguration ===
targetFromCreateNodes.defaultConfiguration
) {
delete targetFromProjectConfig.defaultConfiguration;
}
// Cache
if (targetFromProjectConfig.cache === targetFromCreateNodes.cache) {
delete targetFromProjectConfig.cache;
}
// Depends On
if (
targetFromProjectConfig.dependsOn &&
shouldRemoveArrayProperty(
targetFromProjectConfig.dependsOn,
targetFromCreateNodes.dependsOn
)
) {
delete targetFromProjectConfig.dependsOn;
}
// Outputs
if (
targetFromProjectConfig.outputs &&
shouldRemoveArrayProperty(
targetFromProjectConfig.outputs,
targetFromCreateNodes.outputs
)
) {
delete targetFromProjectConfig.outputs;
}
// Inputs
if (
targetFromProjectConfig.inputs &&
shouldRemoveArrayProperty(
targetFromProjectConfig.inputs,
targetFromCreateNodes.inputs
)
) {
delete targetFromProjectConfig.inputs;
}
// Options
for (const [optionName, optionValue] of Object.entries(
targetFromProjectConfig.options ?? {}
)) {
if (equals(targetFromCreateNodes.options[optionName], optionValue)) {
delete targetFromProjectConfig.options[optionName];
}
}
if (Object.keys(targetFromProjectConfig.options).length === 0) {
delete targetFromProjectConfig.options;
}
// Configurations
for (const [configName, configOptions] of Object.entries(
targetFromProjectConfig.configurations ?? {}
)) {
for (const [optionName, optionValue] of Object.entries(configOptions)) {
if (
targetFromCreateNodes.configurations?.[configName]?.[optionName] ===
optionValue
) {
delete targetFromProjectConfig.configurations[configName][optionName];
}
}
if (Object.keys(configOptions).length === 0) {
delete targetFromProjectConfig.configurations[configName];
}
}
if (Object.keys(targetFromProjectConfig.configurations ?? {}).length === 0) {
delete targetFromProjectConfig.configurations;
}
if (Object.keys(targetFromProjectConfig).length === 0) {
delete projectConfig.targets[targetName];
}
}
function equals<T extends unknown>(a: T, b: T) {
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
if (typeof a === 'object' && typeof b === 'object') {
return hashObject(a) === hashObject(b);
}
return a === b;
}
function shouldRemoveArrayProperty(
arrayValuesFromProjectConfiguration: (object | string)[],
arrayValuesFromCreateNodes: (object | string)[]
) {
const setOfArrayValuesFromProjectConfiguration = new Set(
arrayValuesFromProjectConfiguration
);
loopThroughArrayValuesFromCreateNodes: for (const arrayValueFromCreateNodes of arrayValuesFromCreateNodes) {
if (typeof arrayValueFromCreateNodes === 'string') {
if (
!setOfArrayValuesFromProjectConfiguration.has(arrayValueFromCreateNodes)
) {
// If the inputs from the project configuration is missing an input from createNodes it was removed
return false;
} else {
setOfArrayValuesFromProjectConfiguration.delete(
arrayValueFromCreateNodes
);
}
} else {
for (const arrayValue of setOfArrayValuesFromProjectConfiguration.values()) {
if (
typeof arrayValue !== 'string' &&
hashObject(arrayValue) === hashObject(arrayValueFromCreateNodes)
) {
setOfArrayValuesFromProjectConfiguration.delete(arrayValue);
// Continue the outer loop, breaking out of this loop
continue loopThroughArrayValuesFromCreateNodes;
}
}
// If an input was not matched, that means the input was removed
return false;
}
}
// If there are still inputs in the project configuration, they have added additional inputs
return setOfArrayValuesFromProjectConfiguration.size === 0;
}
+357
View File
@@ -0,0 +1,357 @@
// Remove this if https://github.com/benlesh/rxjs-for-await/issues/15 is addressed
import type { Observable } from 'rxjs';
export class Deferred<T> {
resolve: (value: T | PromiseLike<T>) => void = null!;
reject: (reason?: any) => void = null!;
promise = new Promise<T>((a, b) => {
this.resolve = a;
this.reject = b;
});
}
const RESOLVED = Promise.resolve();
/**
* Will subscribe to the `source` observable provided,
*
* Allowing a `for await..of` loop to iterate over every
* value that the source emits.
*
* **WARNING**: If the async loop is slower than the observable
* producing values, the values will build up in a buffer
* and you could experience an out of memory error.
*
* This is a lossless subscription method. No value
* will be missed or duplicated.
*
* Example usage:
*
* ```ts
* async function test() {
* const source$ = getSomeObservable();
*
* for await(const value of eachValueFrom(source$)) {
* console.log(value);
* }
* }
* ```
*
* @param source the Observable source to await values from
*/
export async function* eachValueFrom<T>(
source: Observable<T>
): AsyncIterableIterator<T> {
const deferreds: Deferred<IteratorResult<T>>[] = [];
const values: T[] = [];
let hasError = false;
let error: any = null;
let completed = false;
const subs = source.subscribe({
next: (value) => {
if (deferreds.length > 0) {
deferreds.shift()!.resolve({ value, done: false });
} else {
values.push(value);
}
},
error: (err) => {
hasError = true;
error = err;
while (deferreds.length > 0) {
deferreds.shift()!.reject(err);
}
},
complete: () => {
completed = true;
while (deferreds.length > 0) {
deferreds.shift()!.resolve({ value: undefined, done: true });
}
},
});
try {
while (true) {
if (values.length > 0) {
yield values.shift()!;
} else if (completed) {
return;
} else if (hasError) {
throw error;
} else {
const d = new Deferred<IteratorResult<T>>();
deferreds.push(d);
const result = await d.promise;
if (result.done) {
return;
} else {
yield result.value;
}
}
}
} catch (err) {
throw err;
} finally {
subs.unsubscribe();
}
}
/**
* Will subscribe to the `source` observable provided
* and build the emitted values up in a buffer. Allowing
* `for await..of` loops to iterate and get the buffer
* on each loop.
*
* This is a lossless subscription method. No value
* will be missed or duplicated.
*
* Example usage:
*
* ```ts
* async function test() {
* const source$ = getSomeObservable();
*
* for await(const buffer of bufferedValuesFrom(source$)) {
* for (const value of buffer) {
* console.log(value);
* }
* }
* }
* ```
*
* @param source the Observable source to await values from
*/
export async function* bufferedValuesFrom<T>(
source: Observable<T>
): AsyncGenerator<T[]> {
let deferred: Deferred<IteratorResult<T[]>> | null = null;
const buffer: T[] = [];
let hasError = false;
let error: any = null;
let completed = false;
const subs = source.subscribe({
next: (value) => {
if (deferred) {
deferred.resolve(
RESOLVED.then(() => {
const bufferCopy = buffer.slice();
buffer.length = 0;
return { value: bufferCopy, done: false };
})
);
deferred = null;
}
buffer.push(value);
},
error: (err) => {
hasError = true;
error = err;
if (deferred) {
deferred.reject(err);
deferred = null;
}
},
complete: () => {
completed = true;
if (deferred) {
deferred.resolve({ value: undefined, done: true });
deferred = null;
}
},
});
try {
while (true) {
if (buffer.length > 0) {
const bufferCopy = buffer.slice();
buffer.length = 0;
yield bufferCopy;
} else if (completed) {
return;
} else if (hasError) {
throw error;
} else {
deferred = new Deferred<IteratorResult<T[]>>();
const result = await deferred.promise;
if (result.done) {
return;
} else {
yield result.value;
}
}
}
} catch (err) {
throw err;
} finally {
subs.unsubscribe();
}
}
/**
* Will subscribe to the provided `source` observable,
* allowing `for await..of` loops to iterate and get the
* most recent value that was emitted. Will not iterate out
* the same emission twice.
*
* This is a lossy subscription method. Do not use if
* every value is important.
*
* Example usage:
*
* ```ts
* async function test() {
* const source$ = getSomeObservable();
*
* for await(const value of latestValueFrom(source$)) {
* console.log(value);
* }
* }
* ```
*
* @param source the Observable source to await values from
*/
export async function* latestValueFrom<T>(
source: Observable<T>
): AsyncGenerator<T> {
let deferred: Deferred<IteratorResult<T>> | undefined = undefined;
let latestValue: T;
let hasLatestValue = false;
let hasError = false;
let error: any = null;
let completed = false;
const subs = source.subscribe({
next: (value) => {
hasLatestValue = true;
latestValue = value;
if (deferred) {
deferred.resolve(
RESOLVED.then(() => {
hasLatestValue = false;
return { value: latestValue, done: false };
})
);
}
},
error: (err) => {
hasError = true;
error = err;
if (deferred) {
deferred.reject(err);
}
},
complete: () => {
completed = true;
if (deferred) {
hasLatestValue = false;
deferred.resolve({ value: undefined, done: true });
}
},
});
try {
while (true) {
if (hasLatestValue) {
await RESOLVED;
const value = latestValue!;
hasLatestValue = false;
yield value;
} else if (completed) {
return;
} else if (hasError) {
throw error;
} else {
deferred = new Deferred<IteratorResult<T>>();
const result = await deferred.promise;
if (result.done) {
return;
} else {
yield result.value;
}
}
}
} catch (err) {
throw err;
} finally {
subs.unsubscribe();
}
}
/**
* Subscribes to the provided `source` observable and allows
* `for await..of` loops to iterate over it, such that
* all values are dropped until the iteration occurs, then
* the very next value that arrives is provided to the
* `for await` loop.
*
* This is a lossy subscription method. Do not use if
* every value is important.
*
* Example usage:
*
* ```ts
* async function test() {
* const source$ = getSomeObservable();
*
* for await(const value of nextValueFrom(source$)) {
* console.log(value);
* }
* }
* ```
*
* @param source the Observable source to await values from
*/
export async function* nextValueFrom<T>(
source: Observable<T>
): AsyncGenerator<T, void, void> {
let deferred: Deferred<IteratorResult<T>> | undefined = undefined;
let hasError = false;
let error: any = null;
let completed = false;
const subs = source.subscribe({
next: (value) => {
if (deferred) {
deferred.resolve({ value, done: false });
}
},
error: (err) => {
hasError = true;
error = err;
if (deferred) {
deferred.reject(err);
}
},
complete: () => {
completed = true;
if (deferred) {
deferred.resolve({ value: undefined, done: true });
}
},
});
try {
while (true) {
if (completed) {
return;
} else if (hasError) {
throw error;
} else {
deferred = new Deferred<IteratorResult<T>>();
const result = await deferred.promise;
if (result.done) {
return;
} else {
yield result.value;
}
}
}
} catch (err) {
throw err;
} finally {
subs.unsubscribe();
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { Tree } from 'nx/src/generators/tree';
import { TempFs } from '../../internal-testing-utils';
import { createTreeWithEmptyWorkspace } from '../../testing';
import { checkAndCleanWithSemver } from './semver';
describe('checkAndCleanWithSemver', () => {
let tree: Tree;
let tempFs: TempFs;
beforeEach(() => {
tempFs = new TempFs('semver-test');
tree = createTreeWithEmptyWorkspace();
tree.root = tempFs.tempDir;
tempFs.createFileSync('pnpm-lock.yaml', 'lockfileVersion: 9.0');
});
afterEach(() => {
tempFs.cleanup();
});
it('should validate and clean semver versions', () => {
// Test with caret prefix
expect(checkAndCleanWithSemver('package', '^1.2.3')).toBe('1.2.3');
// Test with tilde prefix
expect(checkAndCleanWithSemver('package', '~1.2.3')).toBe('1.2.3');
// Test with valid semver
expect(checkAndCleanWithSemver('package', '1.2.3')).toBe('1.2.3');
// Test invalid version throws error
expect(() => checkAndCleanWithSemver('package', 'invalid')).toThrow(
'The package.json lists a version of package that Nx is unable to validate'
);
});
it('should resolve catalog references before validating semver', () => {
const yamlContent = `
packages:
- packages/*
catalog:
react: ^18.2.0
lodash: ~4.17.21
catalogs:
testing:
jest: ^29.0.0
`;
tree.write('pnpm-workspace.yaml', yamlContent);
// Test default catalog reference
expect(checkAndCleanWithSemver(tree, 'react', 'catalog:')).toBe('18.2.0');
// Test default catalog with tilde prefix
expect(checkAndCleanWithSemver(tree, 'lodash', 'catalog:')).toBe('4.17.21');
// Test named catalog reference
expect(checkAndCleanWithSemver(tree, 'jest', 'catalog:testing')).toBe(
'29.0.0'
);
// Test invalid catalog reference throws error
expect(() =>
checkAndCleanWithSemver(tree, 'nonexistent', 'catalog:')
).toThrow('The catalog reference for nonexistent is invalid');
// Test invalid named catalog throws error
expect(() =>
checkAndCleanWithSemver(tree, 'package', 'catalog:nonexistent')
).toThrow('The catalog reference for package is invalid');
});
});
+67
View File
@@ -0,0 +1,67 @@
import { workspaceRoot, type Tree } from 'nx/src/devkit-exports';
import { valid } from 'semver';
import { getCatalogManager } from './catalog';
export function checkAndCleanWithSemver(
pkgName: string,
version: string
): string;
export function checkAndCleanWithSemver(
tree: Tree,
pkgName: string,
version: string
): string;
export function checkAndCleanWithSemver(
treeOrPkgName: Tree | string,
pkgNameOrVersion: string,
version?: string
): string {
const tree = typeof treeOrPkgName === 'string' ? undefined : treeOrPkgName;
const root = tree?.root ?? workspaceRoot;
const pkgName =
typeof treeOrPkgName === 'string' ? treeOrPkgName : pkgNameOrVersion;
let newVersion =
typeof treeOrPkgName === 'string' ? pkgNameOrVersion : version!;
const manager = getCatalogManager(root);
if (manager?.isCatalogReference(newVersion)) {
try {
if (tree) {
manager.validateCatalogReference(tree, pkgName, newVersion);
} else {
manager.validateCatalogReference(root, pkgName, newVersion);
}
} catch (error) {
throw new Error(
`The catalog reference for ${pkgName} is invalid - (${newVersion})\n${error.message}`
);
}
const resolvedVersion = tree
? manager.resolveCatalogReference(tree, pkgName, newVersion)
: manager.resolveCatalogReference(root, pkgName, newVersion);
if (!resolvedVersion) {
throw new Error(
`Could not resolve catalog reference for package ${pkgName}@${newVersion}.`
);
}
newVersion = resolvedVersion;
}
if (valid(newVersion)) {
return newVersion;
}
if (newVersion.startsWith('~') || newVersion.startsWith('^')) {
newVersion = newVersion.substring(1);
}
if (!valid(newVersion)) {
throw new Error(
`The package.json lists a version of ${pkgName} that Nx is unable to validate - (${newVersion})`
);
}
return newVersion;
}
@@ -0,0 +1,238 @@
import { applyChangesToString, ChangeType } from './string-change';
describe('applyChangesToString', () => {
it('should insert text', () => {
const original = 'Original Text';
const result = applyChangesToString(original, [
{
type: ChangeType.Insert,
index: 0,
text: 'Start | ',
},
{
type: ChangeType.Insert,
index: 13,
text: ' | End',
},
]);
expect(result).toEqual('Start | Original Text | End');
});
it('should sort addition changes', () => {
const original = 'Original Text';
const result = applyChangesToString(original, [
{
type: ChangeType.Insert,
index: 13,
text: ' | End',
},
{
type: ChangeType.Insert,
index: 0,
text: 'Start | ',
},
]);
expect(result).toEqual('Start | Original Text | End');
});
it('should delete text', () => {
const original = 'Start | Original Text | End';
const result = applyChangesToString(original, [
{
type: ChangeType.Delete,
start: 0,
length: 8,
},
{
type: ChangeType.Delete,
start: 21,
length: 6,
},
]);
expect(result).toEqual('Original Text');
});
it('should sort deletion changes', () => {
const original = 'Start | Original Text | End';
const result = applyChangesToString(original, [
{
type: ChangeType.Delete,
start: 21,
length: 6,
},
{
type: ChangeType.Delete,
start: 0,
length: 8,
},
]);
expect(result).toEqual('Original Text');
});
it('should handle both addition and deletion changes', () => {
const original = 'Start | Original Text';
const result = applyChangesToString(original, [
{
type: ChangeType.Delete,
start: 0,
length: 8,
},
{
type: ChangeType.Insert,
index: 21,
text: ' | End',
},
]);
expect(result).toEqual('Original Text | End');
});
it('should sort both addition and deletion changes', () => {
const original = 'Start | Original Text';
const result = applyChangesToString(original, [
{
type: ChangeType.Insert,
index: 21,
text: ' | End',
},
{
type: ChangeType.Delete,
start: 0,
length: 8,
},
]);
expect(result).toEqual('Original Text | End');
});
it('should be able to replace text', () => {
const original = 'Original Text';
const result = applyChangesToString(original, [
{
type: ChangeType.Insert,
index: 0,
text: 'Updated',
},
{
type: ChangeType.Delete,
start: 0,
length: 8,
},
]);
expect(result).toEqual('Updated Text');
});
it('should be able to replace text twice', () => {
const original = 'Original Text';
const result = applyChangesToString(original, [
{
type: ChangeType.Delete,
start: 0,
length: 8,
},
{
type: ChangeType.Insert,
index: 0,
text: 'Updated',
},
{
type: ChangeType.Delete,
start: 9,
length: 4,
},
{
type: ChangeType.Insert,
index: 9,
text: 'Updated',
},
]);
expect(result).toEqual('Updated Updated');
});
it('should sort changes when replacing text', () => {
const original = 'Original Text';
const result = applyChangesToString(original, [
{
type: ChangeType.Delete,
start: 0,
length: 8,
},
{
type: ChangeType.Insert,
index: 0,
text: 'Updated',
},
]);
expect(result).toEqual('Updated Text');
});
it('should handle complex cases', () => {
const code = `bootstrap({
target: document.querySelector('#app')
})`;
const indexOfPropertyName = 14; // Usually determined by analyzing an AST.
const updatedCode = applyChangesToString(code, [
{
type: ChangeType.Insert,
index: indexOfPropertyName,
text: 'element',
},
{
type: ChangeType.Delete,
start: indexOfPropertyName,
length: 6,
},
]);
expect(updatedCode).toMatchInlineSnapshot(`
"bootstrap({
element: document.querySelector('#app')
})"
`);
});
it('should throw an error if a number is not passed', () => {
expect(() => {
const original = 'Original Text';
applyChangesToString(original, [
{
type: ChangeType.Insert,
index: undefined,
text: 'a',
},
]);
}).toThrow();
});
it('should throw an error if a negative number is passed', () => {
expect(() => {
const original = 'Original Text';
applyChangesToString(original, [
{
type: ChangeType.Insert,
index: -2,
text: 'a',
},
]);
}).toThrow();
});
});
+138
View File
@@ -0,0 +1,138 @@
export enum ChangeType {
Delete = 'Delete',
Insert = 'Insert',
}
export interface StringDeletion {
type: ChangeType.Delete;
/**
* Place in the original text to start deleting characters
*/
start: number;
/**
* Number of characters to delete
*/
length: number;
}
export interface StringInsertion {
type: ChangeType.Insert;
/**
* Text to insert into the original text
*/
text: string;
/**
* Place in the original text to insert new text
*/
index: number;
}
/**
* A change to be made to a string
*/
export type StringChange = StringInsertion | StringDeletion;
/**
* Applies a list of changes to a string's original value.
*
* This is useful when working with ASTs.
*
* For Example, to rename a property in a method's options:
*
* ```typescript
* const code = `bootstrap({
* target: document.querySelector('#app')
* })`;
*
* const indexOfPropertyName = 13; // Usually determined by analyzing an AST.
* const updatedCode = applyChangesToString(code, [
* {
* type: ChangeType.Insert,
* index: indexOfPropertyName,
* text: 'element'
* },
* {
* type: ChangeType.Delete,
* start: indexOfPropertyName,
* length: 6
* },
* ]);
*
* bootstrap({
* element: document.querySelector('#app')
* });
* ```
*/
export function applyChangesToString(
text: string,
changes: StringChange[]
): string {
assertChangesValid(changes);
const sortedChanges = changes.sort((a, b) => {
const diff = getChangeIndex(a) - getChangeIndex(b);
if (diff === 0) {
if (a.type === b.type) {
return 0;
} else {
// When at the same place, Insert before Delete
return isStringInsertion(a) ? -1 : 1;
}
}
return diff;
});
let offset = 0;
for (const change of sortedChanges) {
const index = getChangeIndex(change) + offset;
if (isStringInsertion(change)) {
text = text.slice(0, index) + change.text + text.slice(index);
offset += change.text.length;
} else {
text = text.slice(0, index) + text.slice(index + change.length);
offset -= change.length;
}
}
return text;
}
function assertChangesValid(
changes: Array<StringInsertion | StringDeletion>
): void {
for (const change of changes) {
if (isStringInsertion(change)) {
if (!Number.isInteger(change.index)) {
throw new TypeError(`${change.index} must be an integer.`);
}
if (change.index < 0) {
throw new Error(`${change.index} must be a positive integer.`);
}
if (typeof change.text !== 'string') {
throw new Error(`${change.text} must be a string.`);
}
} else {
if (!Number.isInteger(change.start)) {
throw new TypeError(`${change.start} must be an integer.`);
}
if (change.start < 0) {
throw new Error(`${change.start} must be a positive integer.`);
}
if (!Number.isInteger(change.length)) {
throw new TypeError(`${change.length} must be an integer.`);
}
if (change.length < 0) {
throw new Error(`${change.length} must be a positive integer.`);
}
}
}
}
function getChangeIndex(change: StringChange): number {
if (isStringInsertion(change)) {
return change.index;
} else {
return change.start;
}
}
function isStringInsertion(change: StringChange): change is StringInsertion {
return change.type === ChangeType.Insert;
}
@@ -0,0 +1,63 @@
import { classify, dasherize } from './string-utils';
describe('String utils', () => {
describe('dasherize', () => {
it('should format camel casing', () => {
expect(dasherize('twoWords')).toEqual('two-words');
});
it('should camel casing with abbreviations', () => {
expect(dasherize('twoWORDS')).toEqual('two-words');
});
it('should format spaces', () => {
expect(dasherize('two words')).toEqual('two-words');
});
it('should format underscores', () => {
expect(dasherize('two_words')).toEqual('two-words');
});
it('should format periods', () => {
expect(dasherize('two.words')).toEqual('two-words');
});
it('should format dashes', () => {
expect(dasherize('two-words')).toEqual('two-words');
});
it('should return single words', () => {
expect(dasherize('word')).toEqual('word');
});
});
describe('classify', () => {
it('should format camel casing', () => {
expect(classify('twoWords')).toEqual('TwoWords');
});
it('should camel casing with abbreviations', () => {
expect(classify('twoWORDS')).toEqual('TwoWORDS');
});
it('should format spaces', () => {
expect(classify('two words')).toEqual('TwoWords');
});
it('should format underscores', () => {
expect(classify('two_words')).toEqual('TwoWords');
});
it('should format periods', () => {
expect(classify('two.words')).toEqual('Two.Words');
});
it('should format dashes', () => {
expect(classify('two-words')).toEqual('TwoWords');
});
it('should return single words', () => {
expect(classify('word')).toEqual('Word');
});
});
});

Some files were not shown because too many files have changed in this diff Show More