packages/monorepo/src/components/nx-configurator.ts (378 lines of code) (raw):

/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ import * as path from "path"; import { Component, IniFile, JsonFile, License, Project, Task, TextFile, YamlFile, } from "projen"; import { JavaProject } from "projen/lib/java"; import { NodePackageManager, NodeProject } from "projen/lib/javascript"; import { Poetry, PythonProject } from "projen/lib/python"; import { NxProject } from "./nx-project"; import { NxWorkspace } from "./nx-workspace"; import { Nx } from "../nx-types"; import { NodePackageUtils, ProjectUtils } from "../utils"; const DEFAULT_PYTHON_VERSION = "3"; const DEFAULT_LICENSE = "Apache-2.0"; /** * Options for overriding nx build tasks * @internal */ interface OverrideNxBuildTaskOptions { /** * Force unlocking task (eg: build task is locked) */ readonly force?: boolean; /** * Disable further resets of the task by other components in further lifecycle stages * (eg eslint resets during preSynthesize) */ readonly disableReset?: boolean; } /** * Interface that all NXProject implementations should implement. */ export interface INxProjectCore { /** * Return the NxWorkspace instance. This should be implemented using a getter. */ readonly nx: NxWorkspace; /** * Helper to format `npx nx run-many ...` style command execution in package manager. * @param options */ execNxRunManyCommand(options: Nx.RunManyOptions): string; /** * Helper to format `npx nx run-many ...` style command * @param options */ composeNxRunManyCommand(options: Nx.RunManyOptions): string[]; /** * Add project task that executes `npx nx run-many ...` style command. * * @param name task name * @param options options */ addNxRunManyTask(name: string, options: Nx.RunManyOptions): Task; /** * Create an implicit dependency between two Projects. This is typically * used in polygot repos where a Typescript project wants a build dependency * on a Python project as an example. * * @param dependent project you want to have the dependency. * @param dependee project you wish to depend on. * @throws error if this is called on a dependent which does not have a NXProject component attached. */ addImplicitDependency(dependent: Project, dependee: Project | string): void; /** * Adds a dependency between two Java Projects in the monorepo. * @param dependent project you want to have the dependency * @param dependee project you wish to depend on */ addJavaDependency(dependent: JavaProject, dependee: JavaProject): void; /** * Adds a dependency between two Python Projects in the monorepo. The dependent must have Poetry enabled. * @param dependent project you want to have the dependency (must be a Poetry Python Project) * @param dependee project you wish to depend on * @throws error if the dependent does not have Poetry enabled */ addPythonPoetryDependency( dependent: PythonProject, dependee: PythonProject ): void; } /** * License options. * */ export interface LicenseOptions { /** * License type (SPDX). * * @see https://github.com/projen/projen/tree/main/license-text for list of supported licenses */ readonly spdx?: string; /** * Copyright owner. * * If the license text for the given spdx has $copyright_owner, this option must be specified. */ readonly copyrightOwner?: string; /** * Arbitrary license text. */ readonly licenseText?: string; /** * Whether to disable the generation of default licenses. * * @default false */ readonly disableDefaultLicenses?: boolean; } /** * NXConfigurator options. */ export interface NxConfiguratorOptions { /** * Branch that NX affected should run against. */ readonly defaultReleaseBranch?: string; /** * Default package license config. * * If nothing is specified, all packages will default to Apache-2.0 (unless they have their own License component). */ readonly licenseOptions?: LicenseOptions; } /** * Configues common NX related tasks and methods. */ export class NxConfigurator extends Component implements INxProjectCore { public readonly nx: NxWorkspace; private readonly licenseOptions?: LicenseOptions; private nxPlugins: { [dep: string]: string } = {}; constructor(project: Project, options?: NxConfiguratorOptions) { super(project); project.addGitIgnore(".nx/*"); project.addGitIgnore("!.nx/plugins"); project.addTask("run-many", { receiveArgs: true, exec: NodePackageUtils.command.exec( NodePackageUtils.getNodePackageManager( this.project, NodePackageManager.NPM ), "nx", "run-many" ), description: "Run task against multiple workspace projects", }); project.addTask("graph", { receiveArgs: true, exec: NodePackageUtils.command.exec( NodePackageUtils.getNodePackageManager( this.project, NodePackageManager.NPM ), "nx", "graph" ), description: "Generate dependency graph for monorepo", }); this.licenseOptions = options?.licenseOptions; this.nx = NxWorkspace.of(project) || new NxWorkspace(project); this.nx.affected.defaultBase = options?.defaultReleaseBranch ?? "mainline"; } public patchPoetryEnv(project: PythonProject): void { // Since the root monorepo is a poetry project and sets the VIRTUAL_ENV, and poetry env info -p will print // the virtual env set in the VIRTUAL_ENV variable if set, we need to unset it to ensure the local project's // env is used. if (ProjectUtils.isNamedInstanceOf(project.depsManager as any, Poetry)) { ["install", "install:ci"].forEach((t) => { const task = project.tasks.tryFind(t); // Setup env const createVenvCmd = "poetry env use python$PYTHON_VERSION"; !task?.steps.find((s) => s.exec === createVenvCmd) && task?.prependExec(createVenvCmd); // Ensure the projen & pdk bins are removed from the venv as we always want to use the npx variant const removeBinsCmd = "rm -f `poetry env info -p`/bin/projen `poetry env info -p`/bin/pdk"; !task?.steps.find((s) => s.exec === removeBinsCmd) && task?.exec(removeBinsCmd); const pythonVersion = project.deps.tryGetDependency("python")?.version; task!.env( "PYTHON_VERSION", pythonVersion && !pythonVersion?.startsWith("^") ? pythonVersion : `$(pyenv latest ${ pythonVersion?.substring(1).split(".")[0] || DEFAULT_PYTHON_VERSION } | cut -d '.' -f 1,2 || echo '')` ); }); project.tasks.addEnvironment( "VIRTUAL_ENV", "$(env -u VIRTUAL_ENV poetry env info -p || echo '')" ); project.tasks.addEnvironment( "PATH", "$(echo $(env -u VIRTUAL_ENV poetry env info -p || echo '')/bin:$PATH)" ); } } public patchPythonProjects(projects: Project[]): void { projects.forEach((p) => { if (ProjectUtils.isNamedInstanceOf(p, PythonProject)) { this.patchPoetryEnv(p); } this.patchPythonProjects(p.subprojects); }); } /** * Overrides "build" related project tasks (build, compile, test, etc.) with `npx nx run-many` format. * @param task - The task or task name to override * @param options - Nx run-many options * @param overrideOptions - Options for overriding the task * @returns - The task that was overridden * @internal */ public _overrideNxBuildTask( task: Task | string | undefined, options: Nx.RunManyOptions, overrideOptions?: OverrideNxBuildTaskOptions ): Task | undefined { if (typeof task === "string") { task = this.project.tasks.tryFind(task); } if (task == null) { return; } if (overrideOptions?.force) { // @ts-ignore - private property task._locked = false; } task.reset(this.execNxRunManyCommand(options), { receiveArgs: true, }); task.description += " for all affected projects"; if (overrideOptions?.disableReset) { // Prevent any further resets of the task to force it to remain as the overridden nx build task task.reset = () => {}; } return task; } /** * Adds a command to upgrade all python subprojects to the given task * @param monorepo the monorepo project * @param task the task to add the command to * @internal */ public _configurePythonSubprojectUpgradeDeps(monorepo: Project, task: Task) { // Upgrade deps for const pythonSubprojects = monorepo.subprojects.filter((p) => ProjectUtils.isNamedInstanceOf(p, PythonProject) ); if (pythonSubprojects.length > 0) { task.exec( this.execNxRunManyCommand({ target: "install", // TODO: remove in favour of the upgrade task if ever implemented for python projects: pythonSubprojects.map((p) => p.name), }), { receiveArgs: true } ); } } /** * Returns the install task or creates one with nx installation command added. * * Note: this should only be called from non-node projects * * @param nxPlugins additional plugins to install * @returns install task */ public ensureNxInstallTask(nxPlugins: { [key: string]: string }): Task { this.nxPlugins = nxPlugins; const installTask = this.project.tasks.tryFind("install") ?? this.project.addTask("install"); installTask.exec("pnpm i --no-frozen-lockfile"); ( this.project.tasks.tryFind("install:ci") ?? this.project.addTask("install:ci") ).exec("pnpm i --frozen-lockfile"); return installTask; } /** * Helper to format `npx nx run-many ...` style command execution in package manager. * @param options */ public execNxRunManyCommand(options: Nx.RunManyOptions): string { return NodePackageUtils.command.exec( NodePackageUtils.getNodePackageManager( this.project, NodePackageManager.NPM ), ...this.composeNxRunManyCommand(options) ); } /** * Helper to format `npx nx run-many ...` style command * @param options */ public composeNxRunManyCommand(options: Nx.RunManyOptions): string[] { const args: string[] = []; if (options.configuration) { args.push(`--configuration=${options.configuration}`); } if (options.runner) { args.push(`--runner=${options.runner}`); } if (options.parallel) { args.push(`--parallel=${options.parallel}`); } if (options.skipCache) { args.push("--skip-nx-cache"); } if (options.ignoreCycles) { args.push("--nx-ignore-cycles"); } if (options.noBail !== true) { args.push("--nx-bail"); } if (options.projects && options.projects.length) { args.push(`--projects=${options.projects.join(",")}`); } if (options.exclude) { args.push(`--exclude=${options.exclude}`); } if (options.verbose) { args.push("--verbose"); } return [ "nx", "run-many", `--target=${options.target}`, `--output-style=${options.outputStyle || "stream"}`, ...args, ]; } /** * Add project task that executes `npx nx run-many ...` style command. */ public addNxRunManyTask(name: string, options: Nx.RunManyOptions): Task { return this.project.addTask(name, { receiveArgs: true, exec: this.execNxRunManyCommand(options), }); } /** * Create an implicit dependency between two Projects. This is typically * used in polygot repos where a Typescript project wants a build dependency * on a Python project as an example. * * @param dependent project you want to have the dependency. * @param dependee project you wish to depend on. * @throws error if this is called on a dependent which does not have a NXProject component attached. */ public addImplicitDependency(dependent: Project, dependee: Project | string) { NxProject.ensure(dependent).addImplicitDependency(dependee); } /** * Adds a dependency between two Java Projects in the monorepo. * @param dependent project you want to have the dependency * @param dependee project you wish to depend on */ public addJavaDependency(dependent: JavaProject, dependee: JavaProject) { NxProject.ensure(dependent).addJavaDependency(dependee); } /** * Adds a dependency between two Python Projects in the monorepo. The dependent must have Poetry enabled. * @param dependent project you want to have the dependency (must be a Poetry Python Project) * @param dependee project you wish to depend on * @throws error if the dependent does not have Poetry enabled */ public addPythonPoetryDependency( dependent: PythonProject, dependee: PythonProject ) { NxProject.ensure(dependent).addPythonPoetryDependency(dependee); } /** * Ensures that all non-root projects have NxProject applied. * @internal */ protected _ensureNxProjectGraph(): void { function _ensure(_project: Project) { if (_project.root === _project) return; NxProject.ensure(_project); _project.subprojects.forEach((p) => { _ensure(p); }); } this.project.subprojects.forEach(_ensure); } /** * Emits package.json for non-node NX monorepos. * @internal */ private _emitPackageJson() { if ( !ProjectUtils.isNamedInstanceOf(this.project, NodeProject) && !this.project.tryFindFile("package.json") ) { new JsonFile(this.project, "package.json", { obj: { devDependencies: { ...this.nxPlugins, nx: "^19", "@nx/devkit": "^19", }, private: true, engines: { node: ">=16", pnpm: ">=8 <9", }, scripts: Object.fromEntries( this.project.tasks.all .filter((t) => t.name !== "install") .map((c) => [ c.name, !this.project.ejected ? NodePackageUtils.command.projen( NodePackageManager.PNPM, c.name ) : `scripts/run-task ${c.name}`, ]) ), }, }).synthesize(); } if ( !ProjectUtils.isNamedInstanceOf(this.project, NodeProject) && !this.project.tryFindFile("pnpm-workspace.yaml") ) { new YamlFile(this.project, "pnpm-workspace.yaml", { obj: { packages: this.project.subprojects .filter((p) => ProjectUtils.isNamedInstanceOf(p, NodeProject)) .map((p) => path.relative(this.project.outdir, p.outdir)), }, }).synthesize(); } if ( !ProjectUtils.isNamedInstanceOf(this.project, NodeProject) && !this.project.tryFindFile(".npmrc") ) { new IniFile(this.project, ".npmrc", { obj: { "resolution-mode": "highest", yes: "true", "prefer-workspace-packages": "true", "link-workspace-packages": "true", }, }).synthesize(); } else if ( ProjectUtils.isNamedInstanceOf(this.project, NodeProject) && this.project.package.packageManager === NodePackageManager.PNPM ) { this.project.npmrc.addConfig("prefer-workspace-packages", "true"); this.project.npmrc.addConfig("link-workspace-packages", "true"); this.project.npmrc.addConfig("yes", "true"); } } private _invokeInstallCITasks() { const cmd = NodePackageUtils.command.exec( ProjectUtils.isNamedInstanceOf(this.project, NodeProject) ? this.project.package.packageManager : NodePackageManager.NPM, ...this.composeNxRunManyCommand({ target: "install:ci", }) ); const task = this.project.tasks.tryFind("install:ci"); task?.steps?.length && task.steps.length > 0 && !task?.steps.find((s) => s.exec === cmd) && task?.exec(cmd, { receiveArgs: true }); } /** * Add licenses to any subprojects which don't already have a license. */ private _addLicenses() { [this.project, ...this.project.subprojects] .filter(() => !this.licenseOptions?.disableDefaultLicenses) .forEach((p) => { p.tryRemoveFile("LICENSE"); if (!this.licenseOptions) { new License(p, { spdx: DEFAULT_LICENSE, }); if (ProjectUtils.isNamedInstanceOf(p, JavaProject)) { // Force all Java projects to use Apache 2.0 p.tryFindObjectFile("pom.xml")?.addOverride("project.licenses", [ { license: { name: "Apache License 2.0", url: "https://www.apache.org/licenses/LICENSE-2.0", distribution: "repo", comments: "An OSI-approved license", }, }, ]); } } else if (!!this.licenseOptions?.licenseText) { new TextFile(p, "LICENSE", { marker: false, committed: true, lines: this.licenseOptions.licenseText.split("\n"), }); } else if (this.licenseOptions.spdx) { new License(p, { spdx: this.licenseOptions.spdx, copyrightOwner: this.licenseOptions?.copyrightOwner, }); } else { throw new Error("Either spdx or licenseText must be specified."); } }); } preSynthesize(): void { this._ensureNxProjectGraph(); this._emitPackageJson(); this._invokeInstallCITasks(); this.patchPythonProjects([this.project]); this._addLicenses(); } /** * @inheritDoc */ synth() { this.resetDefaultTask(); } /** * Ensures subprojects don't have a default task */ private resetDefaultTask() { this.project.subprojects.forEach((subProject: any) => { // Disable default task on subprojects as this isn't supported in a monorepo subProject.defaultTask?.reset(); }); } }