From 6adc47d676b5a2aecabe1de49aa60bc8bc8d4449 Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 18 Oct 2022 13:58:06 -0700 Subject: [PATCH] feat(core): add generator to migrate workspace generators to a local plugin --- docs/generated/packages/workspace.json | 9 + packages/devkit/testing.ts | 4 + packages/nx-plugin/.eslintrc.json | 3 +- packages/nx-plugin/generators.json | 5 + .../files/generators.json__tmpl__ | 16 ++ .../local-plugin-from-tools/generator.spec.ts | 170 ++++++++++++++++++ .../local-plugin-from-tools/generator.ts | 135 ++++++++++++++ .../local-plugin-from-tools/schema.d.ts | 5 + .../local-plugin-from-tools/schema.json | 24 +++ .../generators/move/lib/normalize-schema.ts | 3 +- .../src/generators/move/lib/update-imports.ts | 8 +- .../src/generators/move/lib/utils.ts | 9 +- .../workspace/src/generators/move/schema.d.ts | 3 +- .../workspace/src/generators/move/schema.json | 9 + 14 files changed, 393 insertions(+), 10 deletions(-) create mode 100644 packages/nx-plugin/src/generators/local-plugin-from-tools/files/generators.json__tmpl__ create mode 100644 packages/nx-plugin/src/generators/local-plugin-from-tools/generator.spec.ts create mode 100644 packages/nx-plugin/src/generators/local-plugin-from-tools/generator.ts create mode 100644 packages/nx-plugin/src/generators/local-plugin-from-tools/schema.d.ts create mode 100644 packages/nx-plugin/src/generators/local-plugin-from-tools/schema.json diff --git a/docs/generated/packages/workspace.json b/docs/generated/packages/workspace.json index a61a8e6ba93da7..c7df6ab85e7a24 100644 --- a/docs/generated/packages/workspace.json +++ b/docs/generated/packages/workspace.json @@ -203,11 +203,20 @@ "description": "The name of the project to move.", "x-dropdown": "projects" }, + "newProjectName": { + "type": "string", + "description": "The name for the project after moving. Overrides the new inferred name if provided." + }, "destination": { "type": "string", "description": "The folder to move the project into.", "$default": { "$source": "argv", "index": 0 } }, + "destinationRelativeToRoot": { + "type": "boolean", + "description": "If true, the provided destination route is relative to the workspace root.", + "default": false + }, "importPath": { "type": "string", "description": "The new import path to use in the `tsconfig.base.json`." diff --git a/packages/devkit/testing.ts b/packages/devkit/testing.ts index 4f483d5f166a5e..02ff0c5b86f634 100644 --- a/packages/devkit/testing.ts +++ b/packages/devkit/testing.ts @@ -3,3 +3,7 @@ export { createTreeWithEmptyV1Workspace, } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace'; export { createTree } from 'nx/src/generators/testing-utils/create-tree'; + +export function uniq(prefix: string): string { + return `${prefix}${Math.floor(Math.random() * 10000000)}`; +} diff --git a/packages/nx-plugin/.eslintrc.json b/packages/nx-plugin/.eslintrc.json index cc515a27eb399b..48c388aedd5a72 100644 --- a/packages/nx-plugin/.eslintrc.json +++ b/packages/nx-plugin/.eslintrc.json @@ -23,8 +23,7 @@ "error", "@angular-devkit/architect", "@angular-devkit/core", - "@angular-devkit/schematics", - "@nrwl/workspace" + "@angular-devkit/schematics" ] } }, diff --git a/packages/nx-plugin/generators.json b/packages/nx-plugin/generators.json index b889b233cf3b86..456e88aab7f4cb 100644 --- a/packages/nx-plugin/generators.json +++ b/packages/nx-plugin/generators.json @@ -61,6 +61,11 @@ "factory": "./src/generators/executor/executor#executorSchematic", "schema": "./src/generators/executor/schema.json", "description": "Create an executor for an Nx Plugin." + }, + "local-plugin-from-tools": { + "factory": "./src/generators/local-plugin-from-tools/generator", + "schema": "./src/generators/local-plugin-from-tools/schema.json", + "description": "Migrate existing workspace-generators to a workspace-tools plugin" } } } diff --git a/packages/nx-plugin/src/generators/local-plugin-from-tools/files/generators.json__tmpl__ b/packages/nx-plugin/src/generators/local-plugin-from-tools/files/generators.json__tmpl__ new file mode 100644 index 00000000000000..221a89c6354510 --- /dev/null +++ b/packages/nx-plugin/src/generators/local-plugin-from-tools/files/generators.json__tmpl__ @@ -0,0 +1,16 @@ +{ + "name": "<%= importPath %>", + "generators": { + <% generators.forEach((generator, idx) => { -%> + "<%= generator.name %>": { + "implementation": "<%= generator.implementation %>", + "schema": "<%= generator.schema %>", + <% if( generator.description ) { -%> + "description": "<%= generator.description %>" + <%_ } -%> + <% if( generator.cli ) { -%> + "cli": "<%= generator.cli %>" + <%_ } -%> + }<%= (idx < generators.length - 1) ? "," : "" %><% }) -%> + } +} diff --git a/packages/nx-plugin/src/generators/local-plugin-from-tools/generator.spec.ts b/packages/nx-plugin/src/generators/local-plugin-from-tools/generator.spec.ts new file mode 100644 index 00000000000000..5bd3106d0e2c78 --- /dev/null +++ b/packages/nx-plugin/src/generators/local-plugin-from-tools/generator.spec.ts @@ -0,0 +1,170 @@ +import { createTreeWithEmptyWorkspace, uniq } from '@nrwl/devkit/testing'; +import { + Tree, + readProjectConfiguration, + readJson, + joinPathFragments, + GeneratorsJson, + ProjectConfiguration, + getProjects, +} from '@nrwl/devkit'; + +import generator from './generator'; +import workspaceGeneratorGenerator from '@nrwl/workspace/src/generators/workspace-generator/workspace-generator'; +import { LocalPluginFromToolsGeneratorSchema } from './schema'; + +describe('local-plugin-from-tools generator', () => { + let tree: Tree; + + const createOptions = ( + overrides?: Partial + ): Partial => ({ + ...overrides, + }); + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should find single workspace generator successfully', async () => { + await workspaceGeneratorGenerator(tree, { + name: 'my-generator', + skipFormat: false, + }); + await generator(tree, createOptions()); + + const config = readProjectConfiguration(tree, 'workspace-plugin'); + expect(config.root).toEqual('tools/workspace-plugin'); + + assertValidGenerator(tree, config, 'my-generator'); + }); + + it('should convert multiple workspace generators successfully', async () => { + const generators = [...new Array(10)].map((x) => uniq('generator')); + for (const name of generators) { + await workspaceGeneratorGenerator(tree, { + name, + skipFormat: false, + }); + } + + await generator(tree, createOptions()); + + const config = readProjectConfiguration(tree, 'workspace-plugin'); + expect(config.root).toEqual('tools/workspace-plugin'); + + for (const generator of generators) { + assertValidGenerator(tree, config, generator); + } + }); + + describe('--plugin-name', () => { + it('should obey project name', async () => { + await workspaceGeneratorGenerator(tree, { + name: 'my-generator', + skipFormat: false, + }); + await generator( + tree, + createOptions({ + pluginName: 'workspace-tools', + }) + ); + + const config = readProjectConfiguration(tree, 'workspace-tools'); + expect(config.root).toEqual('tools/workspace-tools'); + + assertValidGenerator(tree, config, 'my-generator'); + }); + }); + + describe('--tools-project-root', () => { + it('should place plugin in specified root', async () => { + await workspaceGeneratorGenerator(tree, { + name: 'my-generator', + skipFormat: false, + }); + await generator( + tree, + createOptions({ + toolsProjectRoot: 'libs/workspace-plugin', + }) + ); + const config = readProjectConfiguration(tree, 'workspace-plugin'); + expect(config.root).toEqual('libs/workspace-plugin'); + + assertValidGenerator(tree, config, 'my-generator'); + }); + }); + + describe('--import-path', () => { + it('should support basic import paths', async () => { + await workspaceGeneratorGenerator(tree, { + name: 'my-generator', + skipFormat: false, + }); + await generator( + tree, + createOptions({ + importPath: 'workspace-tools', + }) + ); + + const config = readProjectConfiguration(tree, 'workspace-plugin'); + expect(config.root).toEqual('tools/workspace-plugin'); + expect( + readJson(tree, 'tsconfig.base.json').compilerOptions.paths[ + 'workspace-tools' + ] + ).toEqual(['tools/workspace-plugin/src/index.ts']); + + assertValidGenerator(tree, config, 'my-generator'); + }); + + it('should support scoped import paths', async () => { + await workspaceGeneratorGenerator(tree, { + name: 'my-generator', + skipFormat: false, + }); + await generator( + tree, + createOptions({ + importPath: '@workspace/plugin', + }) + ); + + const config = readProjectConfiguration(tree, 'workspace-plugin'); + expect(config.root).toEqual('tools/workspace-plugin'); + expect( + readJson(tree, 'tsconfig.base.json').compilerOptions.paths[ + '@workspace/plugin' + ] + ).toEqual(['tools/workspace-plugin/src/index.ts']); + + assertValidGenerator(tree, config, 'my-generator'); + }); + }); +}); + +function assertValidGenerator( + tree: Tree, + config: ProjectConfiguration, + generator: string +) { + const generatorsJson = readJson( + tree, + joinPathFragments(config.root, 'generators.json') + ); + expect(generatorsJson.generators).toHaveProperty(generator); + const generatorImplPath = joinPathFragments( + config.root, + generatorsJson.generators[generator].implementation, + 'index.ts' + ); + expect(tree.exists(generatorImplPath)).toBeTruthy(); + const generatorSchemaPath = joinPathFragments( + config.root, + generatorsJson.generators[generator].schema + ); + expect(tree.exists(generatorSchemaPath)).toBeTruthy(); +} diff --git a/packages/nx-plugin/src/generators/local-plugin-from-tools/generator.ts b/packages/nx-plugin/src/generators/local-plugin-from-tools/generator.ts new file mode 100644 index 00000000000000..950b9150cc6f2d --- /dev/null +++ b/packages/nx-plugin/src/generators/local-plugin-from-tools/generator.ts @@ -0,0 +1,135 @@ +import { + formatFiles, + generateFiles, + getWorkspaceLayout, + joinPathFragments, + readJson, + readProjectConfiguration, + Tree, +} from '@nrwl/devkit'; +import pluginGenerator from '../plugin/plugin'; +import * as path from 'path'; +import { LocalPluginFromToolsGeneratorSchema } from './schema'; +import { Linter } from '@nrwl/linter'; +import { GeneratorsJsonEntry } from 'nx/src/config/misc-interfaces'; +import { moveGenerator } from '@nrwl/workspace'; + +function normalizeOptions( + tree: Tree, + schema: Partial +): LocalPluginFromToolsGeneratorSchema { + const { npmScope } = getWorkspaceLayout(tree); + const pluginName = schema.pluginName ?? 'workspace-plugin'; + + return { + importPath: schema.importPath ?? `@${npmScope}/${pluginName}`, + pluginName, + toolsProjectRoot: + schema.toolsProjectRoot ?? joinPathFragments('tools', pluginName), + }; +} + +function addFiles( + tree: Tree, + options: LocalPluginFromToolsGeneratorSchema, + generators: (GeneratorsJsonEntry & { name: string })[] +) { + const templateOptions = { + ...options, + generators, + tmpl: '', + }; + generateFiles( + tree, + path.join(__dirname, 'files'), + options.toolsProjectRoot, + templateOptions + ); +} + +export default async function ( + tree: Tree, + options: Partial +) { + const normalizedOptions = normalizeOptions(tree, options); + await pluginGenerator(tree, { + minimal: true, + name: normalizedOptions.pluginName, + importPath: normalizedOptions.importPath, + skipTsConfig: false, + compiler: 'tsc', + linter: Linter.EsLint, + skipFormat: true, + skipLintChecks: false, + unitTestRunner: 'jest', + }); + await moveGeneratedPlugin(tree, normalizedOptions); + addFiles( + tree, + normalizedOptions, + collectAndMoveGenerators(tree, normalizedOptions) + ); + await formatFiles(tree); +} + +// Inspired by packages/nx/src/command-line/workspace-generators.ts +function collectAndMoveGenerators( + tree: Tree, + options: LocalPluginFromToolsGeneratorSchema +) { + const generators: ({ + name: string; + } & GeneratorsJsonEntry)[] = []; + const generatorsDir = 'tools/generators'; + const destinationDir = joinPathFragments( + readProjectConfiguration(tree, options.pluginName).root, + 'src', + 'generators' + ); + for (const c of tree.children('tools/generators')) { + const childDir = path.join(generatorsDir, c); + const schemaPath = joinPathFragments(childDir, 'schema.json'); + if (tree.exists(schemaPath)) { + const schema = readJson(tree, schemaPath); + generators.push({ + name: c, + implementation: `./src/generators/${c}`, + schema: `./src/generators/${joinPathFragments(c, 'schema.json')}`, + description: schema.description ?? `Generator ${c}`, + }); + moveFolder(tree, childDir, joinPathFragments(destinationDir, c)); + } + } + return generators; +} + +function moveFolder(tree: Tree, source: string, destination: string) { + for (const child of tree.children(source)) { + const existingPath = joinPathFragments(source, child); + const newPath = joinPathFragments(destination, child); + if (tree.isFile(existingPath)) { + tree.write(newPath, tree.read(existingPath)); + tree.delete(existingPath); + } else { + moveFolder(tree, existingPath, newPath); + } + } + tree.delete(source); +} + +function moveGeneratedPlugin( + tree: Tree, + options: LocalPluginFromToolsGeneratorSchema +) { + const config = readProjectConfiguration(tree, options.pluginName); + if (config.root !== options.toolsProjectRoot) { + return moveGenerator(tree, { + destination: options.toolsProjectRoot, + projectName: options.pluginName, + newProjectName: options.pluginName, + updateImportPath: true, + destinationRelativeToRoot: true, + importPath: options.importPath, + }); + } +} diff --git a/packages/nx-plugin/src/generators/local-plugin-from-tools/schema.d.ts b/packages/nx-plugin/src/generators/local-plugin-from-tools/schema.d.ts new file mode 100644 index 00000000000000..8491134980d80d --- /dev/null +++ b/packages/nx-plugin/src/generators/local-plugin-from-tools/schema.d.ts @@ -0,0 +1,5 @@ +export interface LocalPluginFromToolsGeneratorSchema { + pluginName: string; + importPath: string; + toolsProjectRoot: string; +} diff --git a/packages/nx-plugin/src/generators/local-plugin-from-tools/schema.json b/packages/nx-plugin/src/generators/local-plugin-from-tools/schema.json new file mode 100644 index 00000000000000..6676f0902d4e0b --- /dev/null +++ b/packages/nx-plugin/src/generators/local-plugin-from-tools/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "$id": "LocalPluginFromTools", + "title": "", + "type": "object", + "description": "Creates a local plugin project from existing workspace generators.", + "properties": { + "pluginName": { + "type": "string", + "description": "The name of the plugin created to contain the workspace generators.", + "default": "nx-plugin" + }, + "importPath": { + "type": "string", + "description": "The library name used to import it, like @myorg/my-awesome-lib." + }, + "toolsProjectRoot": { + "type": "string", + "description": "Where the new file should be located. Defaults to tools/workspace-tools." + } + }, + "required": ["pluginName"] +} diff --git a/packages/workspace/src/generators/move/lib/normalize-schema.ts b/packages/workspace/src/generators/move/lib/normalize-schema.ts index f4ef1f1a631cc2..8ba5bd2cb49e71 100644 --- a/packages/workspace/src/generators/move/lib/normalize-schema.ts +++ b/packages/workspace/src/generators/move/lib/normalize-schema.ts @@ -11,7 +11,8 @@ export function normalizeSchema( const destination = schema.destination.startsWith('/') ? normalizeSlashes(schema.destination.slice(1)) : schema.destination; - const newProjectName = getNewProjectName(destination); + const newProjectName = + schema.newProjectName ?? getNewProjectName(destination); const { npmScope } = getWorkspaceLayout(tree); return { diff --git a/packages/workspace/src/generators/move/lib/update-imports.ts b/packages/workspace/src/generators/move/lib/update-imports.ts index 286a24aace67c0..63252c5950fc94 100644 --- a/packages/workspace/src/generators/move/lib/update-imports.ts +++ b/packages/workspace/src/generators/move/lib/update-imports.ts @@ -81,7 +81,9 @@ export function updateImports( } const projectRoot = { - from: project.root.slice(libsDir.length).replace(/^\/|\\/, ''), + from: schema.destinationRelativeToRoot + ? project.root + : project.root.slice(libsDir.length).replace(/^\/|\\/, ''), to: schema.destination, }; @@ -101,7 +103,9 @@ export function updateImports( if (schema.updateImportPath) { tsConfig.compilerOptions.paths[projectRef.to] = updatedPath; - delete tsConfig.compilerOptions.paths[projectRef.from]; + if (projectRef.from !== projectRef.to) { + delete tsConfig.compilerOptions.paths[projectRef.from]; + } } else { tsConfig.compilerOptions.paths[projectRef.from] = updatedPath; } diff --git a/packages/workspace/src/generators/move/lib/utils.ts b/packages/workspace/src/generators/move/lib/utils.ts index 26b5453c777b96..914068aabe00d9 100644 --- a/packages/workspace/src/generators/move/lib/utils.ts +++ b/packages/workspace/src/generators/move/lib/utils.ts @@ -23,10 +23,11 @@ export function getDestination( const workspaceLayout = getWorkspaceLayout(host); - let rootFolder = workspaceLayout.libsDir; - if (projectType === 'application') { - rootFolder = workspaceLayout.appsDir; - } + const rootFolder = schema.destinationRelativeToRoot + ? '.' + : projectType === 'application' + ? workspaceLayout.appsDir + : workspaceLayout.libsDir; return joinPathFragments(rootFolder, schema.destination); } diff --git a/packages/workspace/src/generators/move/schema.d.ts b/packages/workspace/src/generators/move/schema.d.ts index 53a360d1709dd3..24c54abdf49b19 100644 --- a/packages/workspace/src/generators/move/schema.d.ts +++ b/packages/workspace/src/generators/move/schema.d.ts @@ -4,10 +4,11 @@ export interface Schema { importPath?: string; updateImportPath: boolean; skipFormat?: boolean; + destinationRelativeToRoot?: boolean; + newProjectName?: string; } export interface NormalizedSchema extends Schema { importPath: string; - newProjectName: string; relativeToRootDestination: string; } diff --git a/packages/workspace/src/generators/move/schema.json b/packages/workspace/src/generators/move/schema.json index 350e90f90ad808..60b9f426e61a74 100644 --- a/packages/workspace/src/generators/move/schema.json +++ b/packages/workspace/src/generators/move/schema.json @@ -18,6 +18,10 @@ "description": "The name of the project to move.", "x-dropdown": "projects" }, + "newProjectName": { + "type": "string", + "description": "The name for the project after moving. Overrides the new inferred name if provided." + }, "destination": { "type": "string", "description": "The folder to move the project into.", @@ -26,6 +30,11 @@ "index": 0 } }, + "destinationRelativeToRoot": { + "type": "boolean", + "description": "If true, the provided destination route is relative to the workspace root.", + "default": false + }, "importPath": { "type": "string", "description": "The new import path to use in the `tsconfig.base.json`."