From f1d6e1218a79c3fa1949954319c32dc81e7f5542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 10 Oct 2023 15:17:40 +0200 Subject: [PATCH] feat(angular): support generating artifacts using options as provided --- .../angular/generators/component.json | 43 +- packages/angular/generators.json | 2 +- .../src/generators/component/component.ts | 16 +- .../src/generators/component/lib/module.ts | 4 +- .../component/lib/normalize-options.ts | 28 +- .../component/lib/validate-options.ts | 8 +- .../src/generators/component/schema.d.ts | 21 +- .../src/generators/component/schema.json | 45 +- .../src/generators/utils/find-module.ts | 2 + .../artifact-name-and-directory-utils.spec.ts | 862 ++++++++++++++++++ .../artifact-name-and-directory-utils.ts | 402 ++++++++ packages/nx/src/utils/params.ts | 4 +- 12 files changed, 1378 insertions(+), 59 deletions(-) create mode 100644 packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts create mode 100644 packages/devkit/src/generators/artifact-name-and-directory-utils.ts diff --git a/docs/generated/packages/angular/generators/component.json b/docs/generated/packages/angular/generators/component.json index 1d786ff1a5a8be..7ff1ba7388052b 100644 --- a/docs/generated/packages/angular/generators/component.json +++ b/docs/generated/packages/angular/generators/component.json @@ -1,32 +1,44 @@ { "name": "component", - "factory": "./src/generators/component/component", + "factory": "./src/generators/component/component#componentGeneratorInternal", "schema": { "$schema": "http://json-schema.org/draft-07/schema", "$id": "SchematicsAngularComponent", "title": "Angular Component Schema", "cli": "nx", "type": "object", - "description": "Creates a new, generic Angular component definition in the given or default project.", + "description": "Creates a new Angular component.", "additionalProperties": false, "properties": { + "name": { + "type": "string", + "description": "The name of the component.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like to use for the component?" + }, + "directory": { + "type": "string", + "description": "The directory at which to create the component file. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.", + "aliases": ["dir"], + "x-priority": "important" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the component in the directory as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, "path": { "type": "string", - "format": "path", - "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.", - "visible": false + "description": "The directory at which to create the component file. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.", + "visible": false, + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18." }, "project": { "type": "string", "description": "The name of the project.", "$default": { "$source": "projectName" }, - "x-dropdown": "projects" - }, - "name": { - "type": "string", - "description": "The name of the component.", - "$default": { "$source": "argv", "index": 0 }, - "x-prompt": "What name would you like to use for the component?" + "x-dropdown": "projects", + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. The project will be determined from the directory provided. It will be removed in Nx v18." }, "prefix": { "type": "string", @@ -89,7 +101,8 @@ "flat": { "type": "boolean", "description": "Create the new files at the top level of the current project.", - "default": false + "default": false, + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18." }, "skipImport": { "type": "boolean", @@ -124,13 +137,13 @@ "x-priority": "internal" } }, - "required": ["name", "project"], + "required": ["name"], "examplesFile": "## Examples\n\n{% tabs %}\n{% tab label=\"Simple Component\" %}\n\nCreate a component named `my-component`:\n\n```bash\nnx g @nx/angular:component my-component\n```\n\n{% /tab %}\n\n{% tab label=\"Standalone Component\" %}\n\nCreate a standalone component named `my-component`:\n\n```bash\nnx g @nx/angular:component my-component --standalone\n```\n\n{% /tab %}\n\n{% tab label=\"Single File Component\" %}\n\nCreate a component named `my-component` with inline styles and inline template:\n\n```bash\nnx g @nx/angular:component my-component --inlineStyle --inlineTemplate\n```\n\n{% /tab %}\n\n{% tab label=\"Component with OnPush Change Detection Strategy\" %}\n\nCreate a component named `my-component` with OnPush Change Detection Strategy:\n\n```bash\nnx g @nx/angular:component my-component --changeDetection=OnPush\n```\n\n{% /tab %}\n", "presets": [] }, "aliases": ["c"], "description": "Generate an Angular Component.", - "implementation": "/packages/angular/src/generators/component/component.ts", + "implementation": "/packages/angular/src/generators/component/component#componentGeneratorInternal.ts", "hidden": false, "path": "/packages/angular/src/generators/component/schema.json", "type": "generator" diff --git a/packages/angular/generators.json b/packages/angular/generators.json index 1f4ffbe4ea583b..5d632cdaec3985 100644 --- a/packages/angular/generators.json +++ b/packages/angular/generators.json @@ -165,7 +165,7 @@ "description": "Creates an Angular application." }, "component": { - "factory": "./src/generators/component/component", + "factory": "./src/generators/component/component#componentGeneratorInternal", "schema": "./src/generators/component/schema.json", "aliases": ["c"], "description": "Generate an Angular Component." diff --git a/packages/angular/src/generators/component/component.ts b/packages/angular/src/generators/component/component.ts index 3b76b3cc0c5a40..88bf8e235a7512 100644 --- a/packages/angular/src/generators/component/component.ts +++ b/packages/angular/src/generators/component/component.ts @@ -15,8 +15,18 @@ import { import type { Schema } from './schema'; export async function componentGenerator(tree: Tree, rawOptions: Schema) { + await componentGeneratorInternal(tree, { + nameAndDirectoryFormat: 'derived', + ...rawOptions, + }); +} + +export async function componentGeneratorInternal( + tree: Tree, + rawOptions: Schema +) { validateOptions(tree, rawOptions); - const options = normalizeOptions(tree, rawOptions); + const options = await normalizeOptions(tree, rawOptions); const componentNames = names(options.name); const typeNames = names(options.type); @@ -78,13 +88,13 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) { ); addToNgModule( tree, - options.path, + options.directory, modulePath, componentNames.fileName, `${componentNames.className}${typeNames.className}`, `${componentNames.fileName}.${typeNames.fileName}`, 'declarations', - options.flat, + true, options.export ); } diff --git a/packages/angular/src/generators/component/lib/module.ts b/packages/angular/src/generators/component/lib/module.ts index e6648666f531bd..57120371f91666 100644 --- a/packages/angular/src/generators/component/lib/module.ts +++ b/packages/angular/src/generators/component/lib/module.ts @@ -16,11 +16,11 @@ export function findModuleFromOptions( if (!options.module) { return normalizePath(findModule(tree, options.directory, projectRoot)); } else { - const modulePath = joinPathFragments(options.path, options.module); + const modulePath = joinPathFragments(options.directory, options.module); const componentPath = options.directory; const moduleBaseName = basename(modulePath); - const candidateSet = new Set([options.path]); + const candidateSet = new Set([options.directory]); const projectRootParent = dirname(projectRoot); for (let dir = modulePath; dir !== projectRootParent; dir = dirname(dir)) { diff --git a/packages/angular/src/generators/component/lib/normalize-options.ts b/packages/angular/src/generators/component/lib/normalize-options.ts index 5ca348798e115a..8cf2645f79d97c 100644 --- a/packages/angular/src/generators/component/lib/normalize-options.ts +++ b/packages/angular/src/generators/component/lib/normalize-options.ts @@ -1,21 +1,30 @@ import type { Tree } from '@nx/devkit'; import { readProjectConfiguration } from '@nx/devkit'; +import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; import type { AngularProjectConfiguration } from '../../../utils/types'; -import { normalizeNameAndPaths } from '../../utils/path'; import { buildSelector } from '../../utils/selector'; import type { NormalizedSchema, Schema } from '../schema'; -export function normalizeOptions( +export async function normalizeOptions( tree: Tree, options: Schema -): NormalizedSchema { +): Promise { options.type ??= 'component'; - const { directory, filePath, name, path, root, sourceRoot } = - normalizeNameAndPaths(tree, options); + const { directory, file, name, project } = + await determineArtifactNameAndDirectoryOptions(tree, { + artifactName: 'component', + callingGenerator: '@nx/angular:component', + name: options.name, + directory: options.directory ?? options.path, + flat: options.flat, + nameAndDirectoryFormat: options.nameAndDirectoryFormat, + project: options.project, + suffix: options.type ?? 'component', + }); - const { prefix } = readProjectConfiguration( + const { prefix, root, sourceRoot } = readProjectConfiguration( tree, - options.project + project ) as AngularProjectConfiguration; const selector = @@ -25,12 +34,11 @@ export function normalizeOptions( return { ...options, name, + project, changeDetection: options.changeDetection ?? 'Default', style: options.style ?? 'css', - flat: options.flat ?? false, directory, - filePath, - path, + filePath: file.path, projectSourceRoot: sourceRoot, projectRoot: root, selector, diff --git a/packages/angular/src/generators/component/lib/validate-options.ts b/packages/angular/src/generators/component/lib/validate-options.ts index 01ce0032013ff1..d51a443cd84fbc 100644 --- a/packages/angular/src/generators/component/lib/validate-options.ts +++ b/packages/angular/src/generators/component/lib/validate-options.ts @@ -1,13 +1,7 @@ import type { Tree } from '@nx/devkit'; -import { - validatePathIsUnderProjectRoot, - validateProject, - validateStandaloneOption, -} from '../../utils/validations'; +import { validateStandaloneOption } from '../../utils/validations'; import type { Schema } from '../schema'; export function validateOptions(tree: Tree, options: Schema): void { - validateProject(tree, options.project); - validatePathIsUnderProjectRoot(tree, options.project, options.path); validateStandaloneOption(tree, options.standalone); } diff --git a/packages/angular/src/generators/component/schema.d.ts b/packages/angular/src/generators/component/schema.d.ts index 510d1c099bf70f..dac9546810fda8 100644 --- a/packages/angular/src/generators/component/schema.d.ts +++ b/packages/angular/src/generators/component/schema.d.ts @@ -1,7 +1,9 @@ +import type { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + export interface Schema { name: string; - project: string; - path?: string; + directory?: string; + nameAndDirectoryFormat?: NameAndDirectoryFormat; displayBlock?: boolean; inlineStyle?: boolean; inlineTemplate?: boolean; @@ -11,7 +13,6 @@ export interface Schema { style?: 'css' | 'scss' | 'sass' | 'less' | 'none'; skipTests?: boolean; type?: string; - flat?: boolean; skipImport?: boolean; selector?: string; module?: string; @@ -19,11 +20,25 @@ export interface Schema { export?: boolean; prefix?: string; skipFormat?: boolean; + + /** + * @deprecated Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18. + */ + flat?: boolean; + /** + * @deprecated Provide the `directory` option instead. It will be removed in Nx v18. + */ + path?: string; + /** + * @deprecated Provide the `directory` option instead. The project will be determined from the directory provided. It will be removed in Nx v18. + */ + project?: string; } export interface NormalizedSchema extends Schema { directory: string; filePath: string; + project: string; projectSourceRoot: string; projectRoot: string; selector: string; diff --git a/packages/angular/src/generators/component/schema.json b/packages/angular/src/generators/component/schema.json index 03823ac4d84e9e..198a3b6af523fb 100644 --- a/packages/angular/src/generators/component/schema.json +++ b/packages/angular/src/generators/component/schema.json @@ -4,14 +4,34 @@ "title": "Angular Component Schema", "cli": "nx", "type": "object", - "description": "Creates a new, generic Angular component definition in the given or default project.", + "description": "Creates a new Angular component.", "additionalProperties": false, "properties": { + "name": { + "type": "string", + "description": "The name of the component.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the component?" + }, + "directory": { + "type": "string", + "description": "The directory at which to create the component file. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.", + "aliases": ["dir"], + "x-priority": "important" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the component in the directory as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, "path": { "type": "string", - "format": "path", - "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.", - "visible": false + "description": "The directory at which to create the component file. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.", + "visible": false, + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18." }, "project": { "type": "string", @@ -19,16 +39,8 @@ "$default": { "$source": "projectName" }, - "x-dropdown": "projects" - }, - "name": { - "type": "string", - "description": "The name of the component.", - "$default": { - "$source": "argv", - "index": 0 - }, - "x-prompt": "What name would you like to use for the component?" + "x-dropdown": "projects", + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. The project will be determined from the directory provided. It will be removed in Nx v18." }, "prefix": { "type": "string", @@ -91,7 +103,8 @@ "flat": { "type": "boolean", "description": "Create the new files at the top level of the current project.", - "default": false + "default": false, + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18." }, "skipImport": { "type": "boolean", @@ -126,6 +139,6 @@ "x-priority": "internal" } }, - "required": ["name", "project"], + "required": ["name"], "examplesFile": "../../../docs/component-examples.md" } diff --git a/packages/angular/src/generators/utils/find-module.ts b/packages/angular/src/generators/utils/find-module.ts index 0d7af2b46b3f2a..aae9d9c69a9339 100644 --- a/packages/angular/src/generators/utils/find-module.ts +++ b/packages/angular/src/generators/utils/find-module.ts @@ -58,6 +58,8 @@ export function addToNgModule( className: string, fileName: string, ngModuleProperty: ngModuleDecoratorProperty, + // TODO(leo): remove once all consumers are updated + // // check if exported in the public api isFlat = true, isExported = false ) { diff --git a/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts b/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts new file mode 100644 index 00000000000000..6e7bd05de3f63c --- /dev/null +++ b/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts @@ -0,0 +1,862 @@ +import * as enquirer from 'enquirer'; +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 { workspaceRoot } from 'nx/src/utils/workspace-root'; +import { join } from 'path'; +import { determineArtifactNameAndDirectoryOptions } from './artifact-name-and-directory-utils'; + +describe('determineArtifactNameAndDirectoryOptions', () => { + let tree: Tree; + let originalInteractiveValue; + let originalCIValue; + let originalIsTTYValue; + let originalInitCwd; + + function ensureInteractiveMode() { + process.env.NX_INTERACTIVE = 'true'; + process.env.CI = 'false'; + process.stdout.isTTY = true; + } + + function restoreOriginalInteractiveMode() { + process.env.NX_INTERACTIVE = originalInteractiveValue; + process.env.CI = originalCIValue; + process.stdout.isTTY = originalIsTTYValue; + } + + function setCwd(path: string) { + process.env.INIT_CWD = join(workspaceRoot, path); + } + + function restoreCwd() { + if (originalInitCwd === undefined) { + delete process.env.INIT_CWD; + } else { + process.env.INIT_CWD = originalInitCwd; + } + } + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + jest.clearAllMocks(); + + originalInteractiveValue = process.env.NX_INTERACTIVE; + originalCIValue = process.env.CI; + originalIsTTYValue = process.stdout.isTTY; + originalInitCwd = process.env.INIT_CWD; + }); + + it('should throw an error when the resolver directory is not under any project root', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + setCwd('some/path'); + + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"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 throw an error when the provided project does not exist', async () => { + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The provided project "app1" does not exist! Please provide an existing project name."` + ); + }); + + it('should throw when receiving a path as the name and a directory', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/foo/bar/myComponent', + directory: 'foo/bar', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"You can't specify both a directory (foo/bar) and a name with a directory path (apps/app1/foo/bar/myComponent). Please specify either a directory or a name with a directory path."` + ); + }); + + describe('as-provided', () => { + it('should return options as provided 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, { + name: 'myComponent', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1', + file: { + baseName: 'myComponent.ts', + name: 'myComponent', + path: 'apps/app1/myComponent.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + + restoreCwd(); + }); + + it('should return the options as provided when directory is provided', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1', + file: { + baseName: 'myComponent.ts', + name: 'myComponent', + path: 'apps/app1/myComponent.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it(`should handle window's style paths correctly`, async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + directory: 'apps\\app1', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1', + file: { + baseName: 'myComponent.ts', + name: 'myComponent', + path: 'apps/app1/myComponent.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should ignore the project and use the provided directory', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + addProjectConfiguration(tree, 'app2', { + root: 'apps/app2', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app2', + directory: 'apps/app1/foo/bar', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/foo/bar', + file: { + baseName: 'myComponent.ts', + name: 'myComponent', + path: 'apps/app1/foo/bar/myComponent.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should support receiving a path as the name', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/foo/bar/myComponent', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/foo/bar', + file: { + baseName: 'myComponent.ts', + name: 'myComponent', + path: 'apps/app1/foo/bar/myComponent.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should support receiving a suffix', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + suffix: 'component', + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1', + file: { + baseName: 'myComponent.component.ts', + name: 'myComponent.component', + path: 'apps/app1/myComponent.component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should support receiving a fileName', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + fileName: 'myComponent.component', + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1', + file: { + baseName: 'myComponent.component.ts', + name: 'myComponent.component', + path: 'apps/app1/myComponent.component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should ignore "--pascalCaseFile"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + pascalCaseFile: true, + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1', + file: { + baseName: 'myComponent.ts', + name: 'myComponent', + path: 'apps/app1/myComponent.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should ignore "--pascalCaseDirectory"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + pascalCaseDirectory: true, + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1', + file: { + baseName: 'myComponent.ts', + name: 'myComponent', + path: 'apps/app1/myComponent.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should support receiving a different file extension', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + fileExtension: 'tsx', + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1', + file: { + baseName: 'myComponent.tsx', + name: 'myComponent', + path: 'apps/app1/myComponent.tsx', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + }); + + describe('derived', () => { + it('should infer project and return options when project is not provided and there is a project at the cwd', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + setCwd('apps/app1'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/src/app/my-component', + file: { + baseName: 'my-component.ts', + name: 'my-component', + path: 'apps/app1/src/app/my-component/my-component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + + restoreCwd(); + }); + + it('should support receiving a directory correctly under the inferred project root', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + directory: 'apps/app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/my-component', + file: { + baseName: 'my-component.ts', + name: 'my-component', + path: 'apps/app1/my-component/my-component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it(`should handle window's style paths correctly`, async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + directory: 'apps\\app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/my-component', + file: { + baseName: 'my-component.ts', + name: 'my-component', + path: 'apps/app1/my-component/my-component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support receiving a project', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/src/app/my-component', + file: { + baseName: 'my-component.ts', + name: 'my-component', + path: 'apps/app1/src/app/my-component/my-component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should throw when the provided directory is not under the provided project root', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + directory: 'foo/bar', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The provided directory "foo/bar" is not under the provided project root "apps/app1". Please provide a directory that is under the provided project root or use the "as-provided" format and only provide the directory."` + ); + }); + + it('should support receiving a path as the name', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/foo/bar/myComponent', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/foo/bar/my-component', + file: { + baseName: 'my-component.ts', + name: 'my-component', + path: 'apps/app1/foo/bar/my-component/my-component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should throw when `--disallowPathInNameForDerived` and receiving a path as the name', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/foo/bar/myComponent', + disallowPathInNameForDerived: true, + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The provided name "apps/app1/foo/bar/myComponent" contains a path and this is not supported by the "@my-org/my-plugin:component" when using the "derived" format. Please provide a name without a path or use the "as-provided" format."` + ); + }); + + it('should support "--flat"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + flat: true, + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/src/app', + file: { + baseName: 'my-component.ts', + name: 'my-component', + path: 'apps/app1/src/app/my-component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support receiving a suffix', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + suffix: 'component', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/src/app/my-component', + file: { + baseName: 'my-component.component.ts', + name: 'my-component.component', + path: 'apps/app1/src/app/my-component/my-component.component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support receiving a fileName', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + fileName: 'myComponent.component', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/src/app/my-component', + file: { + baseName: 'myComponent.component.ts', + name: 'myComponent.component', + path: 'apps/app1/src/app/my-component/myComponent.component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support "--pascalCaseFile"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + pascalCaseFile: true, + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/src/app/my-component', + file: { + baseName: 'MyComponent.ts', + name: 'MyComponent', + path: 'apps/app1/src/app/my-component/MyComponent.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support "--pascalCaseDirectory"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + pascalCaseDirectory: true, + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/src/app/MyComponent', + file: { + baseName: 'my-component.ts', + name: 'my-component', + path: 'apps/app1/src/app/MyComponent/my-component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support receiving a different file extension', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + fileExtension: 'tsx', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + name: 'myComponent', + directory: 'apps/app1/src/app/my-component', + file: { + baseName: 'my-component.tsx', + name: 'my-component', + path: 'apps/app1/src/app/my-component/my-component.tsx', + }, + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + }); + + describe('no format', () => { + it('should prompt for the format to use', async () => { + // simulate interactive mode + ensureInteractiveMode(); + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + const promptSpy = jest + .spyOn(enquirer, 'prompt') + .mockImplementation(() => Promise.resolve({ format: 'as-provided' })); + + await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).toHaveBeenCalled(); + const promptCallOptions = promptSpy.mock.calls[0][0] as any; + expect(promptCallOptions.choices).toStrictEqual([ + { + message: 'As provided: myComponent.ts', + // as-provided ignores the project and uses cwd + directory + // in this case, both are empty + name: 'myComponent.ts', + }, + { + message: + 'Derived: apps/app1/src/app/my-component/my-component.ts', + name: 'apps/app1/src/app/my-component/my-component.ts', + }, + ]); + + // restore original interactive mode + restoreOriginalInteractiveMode(); + }); + + it('should not prompt and default to "derived" format when running in a non-interactive env', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result.nameAndDirectoryFormat).toBe('derived'); + }); + + it('should not prompt and default to "as-provided" format when providing a directory in the name is disallowed', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/myComponent', + project: 'app1', + disallowPathInNameForDerived: true, + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result.nameAndDirectoryFormat).toBe('as-provided'); + }); + + it('should not prompt and default to "as-provided" format when the directory is not under the provided project root', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + addProjectConfiguration(tree, 'app2', { + root: 'apps/app2', + projectType: 'application', + }); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + directory: 'apps/app2', + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result.nameAndDirectoryFormat).toBe('as-provided'); + }); + + it('should not prompt when the resulting name and directory are the same for both formats', async () => { + // simulate interactive mode + ensureInteractiveMode(); + addProjectConfiguration(tree, 'app1', { + root: '.', + projectType: 'application', + }); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'my-component', + directory: 'src/app', + flat: true, + artifactName: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + name: 'my-component', + directory: 'src/app', + file: { + baseName: 'my-component.ts', + name: 'my-component', + path: 'src/app/my-component.ts', + }, + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + + // restore original interactive mode + restoreOriginalInteractiveMode(); + }); + }); +}); diff --git a/packages/devkit/src/generators/artifact-name-and-directory-utils.ts b/packages/devkit/src/generators/artifact-name-and-directory-utils.ts new file mode 100644 index 00000000000000..8d778c8c7d6d05 --- /dev/null +++ b/packages/devkit/src/generators/artifact-name-and-directory-utils.ts @@ -0,0 +1,402 @@ +import { prompt } from 'enquirer'; +import type { Tree } from 'nx/src/generators/tree'; +import { relative } from 'path'; +import { requireNx } from '../../nx'; +import { names } from '../utils/names'; + +const { + getProjects, + joinPathFragments, + logger, + normalizePath, + output, + workspaceRoot, +} = requireNx(); + +export type NameAndDirectoryFormat = 'as-provided' | 'derived'; +export type ArtifactGenerationOptions = { + artifactName: string; + callingGenerator: string | null; + name: string; + directory?: string; + disallowPathInNameForDerived?: boolean; + fileExtension?: 'js' | 'jsx' | 'ts' | 'tsx'; + fileName?: string; + flat?: boolean; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + pascalCaseDirectory?: boolean; + pascalCaseFile?: boolean; + project?: string; + suffix?: string; +}; + +export type NameAndDirectoryOptions = { + directory: string; + file: { + baseName: string; + name: string; + path: string; + }; + name: string; + project: string; +}; + +type NameAndDirectoryFormats = { + 'as-provided': NameAndDirectoryOptions | undefined; + derived?: NameAndDirectoryOptions | undefined; +}; + +export async function determineArtifactNameAndDirectoryOptions( + tree: Tree, + options: ArtifactGenerationOptions +): Promise< + NameAndDirectoryOptions & { + nameAndDirectoryFormat: NameAndDirectoryFormat; + } +> { + const formats = getNameAndDirectoryOptionFormats(tree, options); + const format = + options.nameAndDirectoryFormat ?? (await determineFormat(formats, options)); + + validateResolvedProject( + formats[format]?.project, + options, + formats[format]?.directory + ); + + return { + ...formats[format], + nameAndDirectoryFormat: format, + }; +} + +async function determineFormat( + formats: NameAndDirectoryFormats, + options: ArtifactGenerationOptions +): Promise { + if (!formats.derived) { + return 'as-provided'; + } + if (!formats['as-provided']) { + const deprecationMessage = getDeprecationMessage(options, formats); + logger.warn(deprecationMessage); + + return 'derived'; + } + + if (process.env.NX_INTERACTIVE !== 'true' || !isTTY()) { + return 'derived'; + } + + const asProvidedDescription = `As provided: ${formats['as-provided'].file.path}`; + const asProvidedSelectedValue = formats['as-provided'].file.path; + const derivedDescription = `Derived: ${formats['derived'].file.path}`; + const derivedSelectedValue = formats['derived'].file.path; + + if (asProvidedSelectedValue === derivedSelectedValue) { + return 'as-provided'; + } + + const result = await prompt<{ format: NameAndDirectoryFormat }>({ + type: 'select', + name: 'format', + message: `Where should the ${options.artifactName} be generated?`, + choices: [ + { + message: asProvidedDescription, + name: asProvidedSelectedValue, + }, + { + message: derivedDescription, + name: derivedSelectedValue, + }, + ], + initial: 'as-provided' as any, + }).then(({ format }) => + format === asProvidedSelectedValue ? 'as-provided' : 'derived' + ); + + if (result === 'derived' && options.callingGenerator) { + const deprecationMessage = getDeprecationMessage(options, formats); + logger.warn(deprecationMessage); + } + + return result; +} + +function getDeprecationMessage( + options: ArtifactGenerationOptions, + formats: NameAndDirectoryFormats +) { + return ` +In Nx 18, generating a ${options.artifactName} will no longer support providing a project and deriving the directory. +Please provide the exact directory in the future. +Example: nx g ${options.callingGenerator} ${formats['derived'].name} --directory ${formats['derived'].directory} +`; +} + +function getNameAndDirectoryOptionFormats( + tree: Tree, + options: ArtifactGenerationOptions +): NameAndDirectoryFormats { + const directory = options.directory + ? normalizePath(options.directory.replace(/^\.?\//, '')) + : undefined; + const fileExtension = options.fileExtension ?? 'ts'; + const { name: extractedName, directory: extractedDirectory } = + extractNameAndDirectoryFromName(options.name); + + if (extractedDirectory && directory) { + throw new Error( + `You can't specify both a directory (${options.directory}) and a name with a directory path (${options.name}). ` + + `Please specify either a directory or a name with a directory path.` + ); + } + + const asProvidedOptions = getAsProvidedOptions(tree, { + ...options, + directory: directory ?? extractedDirectory, + fileExtension, + name: extractedName, + }); + + if (!options.project) { + validateResolvedProject( + asProvidedOptions.project, + options, + asProvidedOptions.directory + ); + } + + if (options.nameAndDirectoryFormat === 'as-provided') { + return { + 'as-provided': asProvidedOptions, + derived: undefined, + }; + } + + if (options.disallowPathInNameForDerived && options.name.includes('/')) { + if (!options.nameAndDirectoryFormat) { + output.warn({ + title: `The provided name "${options.name}" contains a path and this is not supported by the "${options.callingGenerator}" when using the "derived" format.`, + bodyLines: [ + `The generator will try to generate the ${options.artifactName} using the "as-provided" format at "${asProvidedOptions.file.path}".`, + ], + }); + + return { + 'as-provided': asProvidedOptions, + derived: undefined, + }; + } + + throw new Error( + `The provided name "${options.name}" contains a path and this is not supported by the "${options.callingGenerator}" when using the "derived" format. ` + + `Please provide a name without a path or use the "as-provided" format.` + ); + } + + const derivedOptions = getDerivedOptions( + tree, + { + ...options, + directory: + directory ?? + (!options.disallowPathInNameForDerived && extractedDirectory + ? extractedDirectory + : undefined), + fileExtension, + name: extractedName, + }, + asProvidedOptions + ); + + return { + 'as-provided': asProvidedOptions, + derived: derivedOptions, + }; +} + +function getAsProvidedOptions( + tree: Tree, + options: ArtifactGenerationOptions +): NameAndDirectoryOptions { + const relativeCwd = getRelativeCwd(); + + const asProvidedDirectory = options.directory + ? joinPathFragments(relativeCwd, options.directory) + : relativeCwd; + const asProvidedProject = findProjectFromPath(tree, asProvidedDirectory); + + const asProvidedFileName = + options.fileName ?? + (options.suffix ? `${options.name}.${options.suffix}` : options.name); + const asProvidedBaseName = `${asProvidedFileName}.${options.fileExtension}`; + const asProvidedFilePath = joinPathFragments( + asProvidedDirectory, + asProvidedBaseName + ); + + return { + directory: asProvidedDirectory, + file: { + baseName: asProvidedBaseName, + name: asProvidedFileName, + path: asProvidedFilePath, + }, + name: options.name, + project: asProvidedProject, + }; +} + +function getDerivedOptions( + tree: Tree, + options: ArtifactGenerationOptions, + asProvidedOptions: NameAndDirectoryOptions +): NameAndDirectoryOptions | undefined { + const projects = getProjects(tree); + if (options.project && !projects.has(options.project)) { + throw new Error( + `The provided project "${options.project}" does not exist! Please provide an existing project name.` + ); + } + + const projectName = options.project ?? asProvidedOptions.project; + const project = projects.get(projectName); + const derivedName = options.name; + const baseDirectory = options.directory + ? names(options.directory).fileName + : joinPathFragments( + project.sourceRoot ?? joinPathFragments(project.root, 'src'), + project.projectType === 'application' ? 'app' : 'lib' + ); + const derivedDirectory = options.flat + ? normalizePath(baseDirectory) + : joinPathFragments( + baseDirectory, + options.pascalCaseDirectory + ? names(derivedName).className + : names(derivedName).fileName + ); + + if ( + options.directory && + !isDirectoryUnderProjectRoot(derivedDirectory, project.root) + ) { + if (!options.nameAndDirectoryFormat) { + output.warn({ + title: `The provided directory "${options.directory}" is not under the provided project root "${project.root}".`, + bodyLines: [ + `The generator will try to generate the ${options.artifactName} using the "as-provided" format.`, + `With the "as-provided" format, the "project" option is ignored and the ${options.artifactName} will be generated at "${asProvidedOptions.file.path}" (/).`, + ], + }); + + return undefined; + } + + throw new Error( + `The provided directory "${options.directory}" is not under the provided project root "${project.root}". ` + + `Please provide a directory that is under the provided project root or use the "as-provided" format and only provide the directory.` + ); + } + + let derivedFileName = options.fileName; + if (!derivedFileName) { + derivedFileName = options.suffix + ? `${derivedName}.${options.suffix}` + : derivedName; + derivedFileName = options.pascalCaseFile + ? names(derivedFileName).className + : names(derivedFileName).fileName; + } + const derivedBaseName = `${derivedFileName}.${options.fileExtension}`; + const derivedFilePath = joinPathFragments(derivedDirectory, derivedBaseName); + + return { + directory: derivedDirectory, + file: { + baseName: derivedBaseName, + name: derivedFileName, + path: derivedFilePath, + }, + name: derivedName, + project: projectName, + }; +} + +function validateResolvedProject( + project: string | undefined, + options: ArtifactGenerationOptions, + normalizedDirectory: string +): void { + if (project) { + return; + } + + if (options.directory) { + 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.` + ); + } + + throw new Error( + `The current working directory "${ + getRelativeCwd() || '.' + }" 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 projects = getProjects(tree); + for (const [projectName, project] of projects) { + if (isDirectoryUnderProjectRoot(path, project.root)) { + return projectName; + } + } + + return null; +} + +function isDirectoryUnderProjectRoot( + directory: string, + projectRoot: string +): boolean { + const normalizedDirectory = joinPathFragments(workspaceRoot, directory); + const normalizedProjectRoot = joinPathFragments( + workspaceRoot, + projectRoot + ).replace(/\/$/, ''); + + return ( + normalizedDirectory === normalizedProjectRoot || + normalizedDirectory.startsWith(`${normalizedProjectRoot}/`) + ); +} + +function isTTY(): boolean { + return !!process.stdout.isTTY && process.env['CI'] !== 'true'; +} + +function getRelativeCwd(): string { + return normalizePath(relative(workspaceRoot, getCwd())); +} + +function getCwd(): string { + return process.env.INIT_CWD?.startsWith(workspaceRoot) + ? process.env.INIT_CWD + : process.cwd(); +} + +function extractNameAndDirectoryFromName(rawName: string): { + name: string; + directory: string | undefined; +} { + const parsedName = normalizePath(rawName).split('/'); + const name = parsedName.pop(); + const directory = parsedName.length ? parsedName.join('/') : undefined; + + return { name, directory }; +} diff --git a/packages/nx/src/utils/params.ts b/packages/nx/src/utils/params.ts index 1488ff3e7d2e50..7fb629e79b8527 100644 --- a/packages/nx/src/utils/params.ts +++ b/packages/nx/src/utils/params.ts @@ -633,6 +633,8 @@ export async function combineOptionsForGenerator( schema, false ); + + warnDeprecations(combined, schema); convertSmartDefaultsIntoNamedParams( combined, schema, @@ -644,9 +646,7 @@ export async function combineOptionsForGenerator( combined = await promptForValues(combined, schema, projectsConfigurations); } - warnDeprecations(combined, schema); setDefaults(combined, schema); - validateOptsAgainstSchema(combined, schema); applyVerbosity(combined, schema, isVerbose); return combined;