459 lines
13 KiB
TypeScript
459 lines
13 KiB
TypeScript
import { prompt } from 'enquirer';
|
|
import { join, relative } from 'path';
|
|
import {
|
|
extractLayoutDirectory,
|
|
getWorkspaceLayout,
|
|
} from '../utils/get-workspace-layout';
|
|
import { names } from '../utils/names';
|
|
|
|
import {
|
|
joinPathFragments,
|
|
logger,
|
|
normalizePath,
|
|
output,
|
|
ProjectType,
|
|
readJson,
|
|
stripIndents,
|
|
Tree,
|
|
workspaceRoot,
|
|
} from 'nx/src/devkit-exports';
|
|
|
|
export type ProjectNameAndRootFormat = 'as-provided' | 'derived';
|
|
export type ProjectGenerationOptions = {
|
|
name: string;
|
|
projectType: ProjectType;
|
|
callingGenerator: string | null;
|
|
directory?: string;
|
|
importPath?: string;
|
|
projectNameAndRootFormat?: ProjectNameAndRootFormat;
|
|
rootProject?: boolean;
|
|
};
|
|
|
|
export type ProjectNameAndRootOptions = {
|
|
/**
|
|
* Normalized full project name, including scope if name was provided with
|
|
* scope (e.g., `@scope/name`, only available when `projectNameAndRootFormat`
|
|
* is `as-provided`).
|
|
*/
|
|
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;
|
|
};
|
|
|
|
type ProjectNameAndRootFormats = {
|
|
'as-provided': ProjectNameAndRootOptions;
|
|
derived?: ProjectNameAndRootOptions;
|
|
};
|
|
|
|
export async function determineProjectNameAndRootOptions(
|
|
tree: Tree,
|
|
options: ProjectGenerationOptions
|
|
): Promise<
|
|
ProjectNameAndRootOptions & {
|
|
projectNameAndRootFormat: ProjectNameAndRootFormat;
|
|
}
|
|
> {
|
|
if (
|
|
!options.projectNameAndRootFormat &&
|
|
(process.env.NX_INTERACTIVE !== 'true' || !isTTY())
|
|
) {
|
|
options.projectNameAndRootFormat = 'derived';
|
|
}
|
|
|
|
validateName(options.name, options.projectNameAndRootFormat);
|
|
const formats = getProjectNameAndRootFormats(tree, options);
|
|
|
|
const format =
|
|
options.projectNameAndRootFormat ?? (await determineFormat(formats));
|
|
|
|
if (format === 'derived' && options.callingGenerator) {
|
|
logDeprecationMessage(options.callingGenerator, formats);
|
|
}
|
|
|
|
return {
|
|
...formats[format],
|
|
projectNameAndRootFormat: format,
|
|
};
|
|
}
|
|
|
|
function validateName(
|
|
name: string,
|
|
projectNameAndRootFormat?: ProjectNameAndRootFormat
|
|
): void {
|
|
if (projectNameAndRootFormat === 'derived' && name.startsWith('@')) {
|
|
throw new Error(
|
|
`The project name "${name}" cannot start with "@" when the "projectNameAndRootFormat" is "derived".`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Matches two types of project names:
|
|
*
|
|
* 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 (!validationRegex.test(name)) {
|
|
throw new Error(
|
|
`The project name should match the pattern "${pattern}". The provided value "${name}" does not match.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function logDeprecationMessage(
|
|
callingGenerator: string,
|
|
formats: ProjectNameAndRootFormats
|
|
) {
|
|
logger.warn(stripIndents`
|
|
In Nx 20, generating projects will no longer derive the name and root.
|
|
Please provide the exact project name and root in the future.
|
|
Example: nx g ${callingGenerator} ${formats['derived'].projectName} --directory ${formats['derived'].projectRoot}
|
|
`);
|
|
}
|
|
|
|
async function determineFormat(
|
|
formats: ProjectNameAndRootFormats
|
|
): Promise<ProjectNameAndRootFormat> {
|
|
if (!formats.derived) {
|
|
return 'as-provided';
|
|
}
|
|
|
|
const asProvidedDescription = `As provided:
|
|
Name: ${formats['as-provided'].projectName}
|
|
Root: ${formats['as-provided'].projectRoot}`;
|
|
const asProvidedSelectedValue = `${formats['as-provided'].projectName} @ ${formats['as-provided'].projectRoot}`;
|
|
const derivedDescription = `Derived:
|
|
Name: ${formats['derived'].projectName}
|
|
Root: ${formats['derived'].projectRoot}`;
|
|
const derivedSelectedValue = `${formats['derived'].projectName} @ ${formats['derived'].projectRoot}`;
|
|
|
|
if (asProvidedSelectedValue === derivedSelectedValue) {
|
|
return 'as-provided';
|
|
}
|
|
|
|
const result = await prompt<{ format: ProjectNameAndRootFormat }>({
|
|
type: 'select',
|
|
name: 'format',
|
|
message:
|
|
'What should be the project name and where should it be generated?',
|
|
choices: [
|
|
{
|
|
message: asProvidedDescription,
|
|
name: asProvidedSelectedValue,
|
|
},
|
|
{
|
|
message: derivedDescription,
|
|
name: derivedSelectedValue,
|
|
},
|
|
],
|
|
initial: 0,
|
|
}).then(({ format }) =>
|
|
format === asProvidedSelectedValue ? 'as-provided' : 'derived'
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
function getProjectNameAndRootFormats(
|
|
tree: Tree,
|
|
options: ProjectGenerationOptions
|
|
): ProjectNameAndRootFormats {
|
|
const directory = options.directory
|
|
? normalizePath(options.directory.replace(/^\.?\//, ''))
|
|
: undefined;
|
|
|
|
const { name: asProvidedParsedName, directory: asProvidedParsedDirectory } =
|
|
parseNameForAsProvided(options.name);
|
|
|
|
if (asProvidedParsedDirectory && 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 ?? asProvidedParsedDirectory,
|
|
name: asProvidedParsedName,
|
|
});
|
|
|
|
if (options.projectNameAndRootFormat === 'as-provided') {
|
|
return {
|
|
'as-provided': asProvidedOptions,
|
|
derived: undefined,
|
|
};
|
|
}
|
|
|
|
if (asProvidedOptions.projectName.startsWith('@')) {
|
|
if (!options.projectNameAndRootFormat) {
|
|
output.warn({
|
|
title: `The provided name "${options.name}" contains a scoped project name and this is not supported by the "${options.callingGenerator}" when using the "derived" format.`,
|
|
bodyLines: [
|
|
`The generator will try to generate the project "${asProvidedOptions.projectName}" using the "as-provided" format at "${asProvidedOptions.projectRoot}".`,
|
|
],
|
|
});
|
|
|
|
return {
|
|
'as-provided': asProvidedOptions,
|
|
derived: undefined,
|
|
};
|
|
}
|
|
|
|
throw new Error(
|
|
`The provided name "${options.name}" contains a scoped project name and this is not supported by the "${options.callingGenerator}" when using the "derived" format. ` +
|
|
`Please provide a name without "@" or use the "as-provided" format.`
|
|
);
|
|
}
|
|
|
|
const { name: derivedParsedName, directory: derivedParsedDirectory } =
|
|
parseNameForDerived(options.name);
|
|
const derivedOptions = getDerivedOptions(tree, {
|
|
...options,
|
|
directory: directory ?? derivedParsedDirectory,
|
|
name: derivedParsedName,
|
|
});
|
|
|
|
return {
|
|
'as-provided': asProvidedOptions,
|
|
derived: derivedOptions,
|
|
};
|
|
}
|
|
|
|
function getAsProvidedOptions(
|
|
tree: Tree,
|
|
options: ProjectGenerationOptions
|
|
): ProjectNameAndRootOptions {
|
|
let projectSimpleName: string;
|
|
let projectFileName: string;
|
|
if (options.name.startsWith('@')) {
|
|
const [_scope, ...rest] = options.name.split('/');
|
|
projectFileName = rest.join('-');
|
|
projectSimpleName = rest.pop();
|
|
} else {
|
|
projectSimpleName = options.name;
|
|
projectFileName = options.name;
|
|
}
|
|
|
|
let projectRoot: string;
|
|
const relativeCwd = getRelativeCwd();
|
|
|
|
if (options.directory) {
|
|
// append the directory to the current working directory if it doesn't start with it
|
|
if (
|
|
options.directory === relativeCwd ||
|
|
options.directory.startsWith(`${relativeCwd}/`)
|
|
) {
|
|
projectRoot = options.directory;
|
|
} else {
|
|
projectRoot = joinPathFragments(relativeCwd, options.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(options.name)) {
|
|
projectRoot = joinPathFragments(relativeCwd, options.name);
|
|
}
|
|
}
|
|
|
|
let importPath: string | undefined = undefined;
|
|
if (options.projectType === 'library') {
|
|
importPath = options.importPath;
|
|
|
|
if (!importPath) {
|
|
if (options.name.startsWith('@')) {
|
|
importPath = options.name;
|
|
} else {
|
|
const npmScope = getNpmScope(tree);
|
|
importPath =
|
|
projectRoot === '.'
|
|
? readJson<{ name?: string }>(tree, 'package.json').name ??
|
|
getImportPath(npmScope, options.name)
|
|
: getImportPath(npmScope, options.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
projectName: options.name,
|
|
names: {
|
|
projectSimpleName,
|
|
projectFileName,
|
|
},
|
|
importPath,
|
|
projectRoot,
|
|
};
|
|
}
|
|
|
|
function getDerivedOptions(
|
|
tree: Tree,
|
|
options: ProjectGenerationOptions
|
|
): ProjectNameAndRootOptions {
|
|
const name = names(options.name).fileName;
|
|
let { projectDirectory, layoutDirectory } = getDirectories(
|
|
tree,
|
|
options.directory,
|
|
options.projectType
|
|
);
|
|
const projectDirectoryWithoutLayout = projectDirectory
|
|
? `${names(projectDirectory).fileName}/${name}`
|
|
: options.rootProject
|
|
? '.'
|
|
: name;
|
|
// the project name uses the directory without the layout directory
|
|
const projectName =
|
|
projectDirectoryWithoutLayout === '.'
|
|
? name
|
|
: projectDirectoryWithoutLayout.replace(/\//g, '-');
|
|
const projectSimpleName = name;
|
|
let projectRoot = projectDirectoryWithoutLayout;
|
|
if (projectDirectoryWithoutLayout !== '.') {
|
|
// prepend the layout directory
|
|
projectRoot = joinPathFragments(layoutDirectory, projectRoot);
|
|
}
|
|
|
|
let importPath: string;
|
|
if (options.projectType === 'library') {
|
|
importPath = options.importPath;
|
|
if (!importPath) {
|
|
const npmScope = getNpmScope(tree);
|
|
importPath =
|
|
projectRoot === '.'
|
|
? readJson<{ name?: string }>(tree, 'package.json').name ??
|
|
getImportPath(npmScope, projectName)
|
|
: getImportPath(npmScope, projectDirectoryWithoutLayout);
|
|
}
|
|
}
|
|
|
|
return {
|
|
projectName,
|
|
names: {
|
|
projectSimpleName,
|
|
projectFileName: projectName,
|
|
},
|
|
importPath,
|
|
projectRoot,
|
|
};
|
|
}
|
|
|
|
function getDirectories(
|
|
tree: Tree,
|
|
directory: string | undefined,
|
|
projectType: ProjectType
|
|
): {
|
|
projectDirectory: string;
|
|
layoutDirectory: string;
|
|
} {
|
|
let { projectDirectory, layoutDirectory } = extractLayoutDirectory(directory);
|
|
if (!layoutDirectory) {
|
|
const { appsDir, libsDir } = getWorkspaceLayout(tree);
|
|
layoutDirectory = projectType === 'application' ? appsDir : libsDir;
|
|
}
|
|
|
|
return { projectDirectory, layoutDirectory };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function isTTY(): boolean {
|
|
return !!process.stdout.isTTY && process.env['CI'] !== 'true';
|
|
}
|
|
|
|
/**
|
|
* 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(/\/$/, '');
|
|
}
|
|
|
|
/**
|
|
* Function for setting cwd during testing
|
|
*/
|
|
export function setCwd(path: string): void {
|
|
process.env.INIT_CWD = join(workspaceRoot, path);
|
|
}
|
|
|
|
function parseNameForAsProvided(rawName: string): {
|
|
name: string;
|
|
directory: string | undefined;
|
|
} {
|
|
const directory = normalizePath(rawName);
|
|
|
|
if (rawName.includes('@')) {
|
|
const index = directory.lastIndexOf('@');
|
|
|
|
if (index === 0) {
|
|
return { name: rawName, directory: undefined };
|
|
}
|
|
|
|
const name = directory.substring(index);
|
|
|
|
return { name, directory };
|
|
}
|
|
|
|
if (rawName.includes('/')) {
|
|
const index = directory.lastIndexOf('/');
|
|
const name = directory.substring(index + 1);
|
|
|
|
return { name, directory };
|
|
}
|
|
|
|
return { name: rawName, directory: undefined };
|
|
}
|
|
|
|
function parseNameForDerived(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 };
|
|
}
|