chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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(`
|
||||
"[1mEncountered the following while migrating '@nx/vite:serve':
|
||||
[22m • 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.
|
||||
[1mAffected Projects[22m
|
||||
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(`
|
||||
"[1mEncountered the following while migrating '@nx/vite:serve':
|
||||
[22m • 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.
|
||||
[1mAffected Projects[22m
|
||||
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.
|
||||
[1mAffected Projects[22m
|
||||
shop-app
|
||||
[1mEncountered the following while migrating '@nx/vite:build':
|
||||
[22m • 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.
|
||||
[1mAffected Projects[22m
|
||||
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(/\/$/, '');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+1
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user