diff --git a/packages/devkit/src/generators/project-name-and-root-utils.spec.ts b/packages/devkit/src/generators/project-name-and-root-utils.spec.ts index c6f2fcc55c..ff4bbb9b0a 100644 --- a/packages/devkit/src/generators/project-name-and-root-utils.spec.ts +++ b/packages/devkit/src/generators/project-name-and-root-utils.spec.ts @@ -188,6 +188,54 @@ describe('determineProjectNameAndRootOptions', () => { } }); + 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'; @@ -212,10 +260,19 @@ describe('determineProjectNameAndRootOptions', () => { }); }); - it('should throw when an invalid directory is provided', async () => { + it('should throw when a name is not provided for a root project', async () => { await expect( determineProjectNameAndRootOptions(tree, { - directory: '!scope/lib-name', + 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/); @@ -231,23 +288,6 @@ describe('determineProjectNameAndRootOptions', () => { ).rejects.toThrow(/name should match/); }); - it('should handle providing a path including "@" with multiple segments as the project name', async () => { - const result = await determineProjectNameAndRootOptions(tree, { - directory: 'shared/@scope/lib-name/testing', - projectType: 'library', - }); - - expect(result).toEqual({ - projectName: '@scope/lib-name/testing', - names: { - projectSimpleName: 'testing', - projectFileName: 'lib-name-testing', - }, - importPath: '@scope/lib-name/testing', - projectRoot: 'shared/@scope/lib-name/testing', - }); - }); - it('should handle providing a path including multiple "@" as the project name', async () => { const result = await determineProjectNameAndRootOptions(tree, { directory: 'shared/@foo/@scope/libName', diff --git a/packages/devkit/src/generators/project-name-and-root-utils.ts b/packages/devkit/src/generators/project-name-and-root-utils.ts index ee1e449e0b..18d724472d 100644 --- a/packages/devkit/src/generators/project-name-and-root-utils.ts +++ b/packages/devkit/src/generators/project-name-and-root-utils.ts @@ -49,7 +49,12 @@ export async function determineProjectNameAndRootOptions( tree: Tree, options: ProjectGenerationOptions ): Promise { - validateOptions(options); + // 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 = @@ -57,6 +62,8 @@ export async function determineProjectNameAndRootOptions( directory.match(/(@[^@/]+(\/[^@/]+)+)/)?.[1] ?? directory.substring(directory.lastIndexOf('/') + 1); + validateOptions(options.name, name, options.directory); + let projectSimpleName: string; let projectFileName: string; if (name.startsWith('@')) { @@ -88,6 +95,12 @@ export async function determineProjectNameAndRootOptions( } } + 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); @@ -136,38 +149,34 @@ export async function ensureRootProjectName( } } -function validateOptions(options: ProjectGenerationOptions): void { - if (options.directory === '.') { - /** - * Root projects must provide name option - */ - if (!options.name) { - throw new Error(`Root projects must also specify name option.`); - } - } else { - /** - * Both directory and name (if present) 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 (options.name && !validationRegex.test(options.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 "${options.name}" does not match.` - ); - } - if (!validationRegex.test(options.directory)) { - throw new Error( - `The directory should match the pattern "${pattern}". The provided value "${options.directory}" does not match.` + `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.` + ); } }