diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 7e894af363..5aae8ec520 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -1024,6 +1024,14 @@ "path": "/technologies/typescript/api/executors", "name": "executors", "children": [ + { + "id": "copy-workspace-modules", + "path": "/technologies/typescript/api/executors/copy-workspace-modules", + "name": "copy-workspace-modules", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, { "id": "tsc", "path": "/technologies/typescript/api/executors/tsc", diff --git a/docs/generated/manifests/new-nx-api.json b/docs/generated/manifests/new-nx-api.json index a1667b6f2e..6e622f49b9 100644 --- a/docs/generated/manifests/new-nx-api.json +++ b/docs/generated/manifests/new-nx-api.json @@ -2260,6 +2260,15 @@ "root": "/packages/js", "source": "/packages/js/src", "executors": { + "/technologies/typescript/api/executors/copy-workspace-modules": { + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "file": "generated/packages/js/executors/copy-workspace-modules.json", + "hidden": false, + "name": "copy-workspace-modules", + "originalFilePath": "/packages/js/src/executors/copy-workspace-modules/schema.json", + "path": "/technologies/typescript/api/executors/copy-workspace-modules", + "type": "executor" + }, "/technologies/typescript/api/executors/tsc": { "description": "Build a project using TypeScript.", "file": "generated/packages/js/executors/tsc.json", diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 2eedb3ee53..8fcccbe1be 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -2455,6 +2455,15 @@ } ], "executors": [ + { + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "file": "generated/packages/js/executors/copy-workspace-modules.json", + "hidden": false, + "name": "copy-workspace-modules", + "originalFilePath": "/packages/js/src/executors/copy-workspace-modules/schema.json", + "path": "js/executors/copy-workspace-modules", + "type": "executor" + }, { "description": "Build a project using TypeScript.", "file": "generated/packages/js/executors/tsc.json", diff --git a/docs/generated/packages/js/executors/copy-workspace-modules.json b/docs/generated/packages/js/executors/copy-workspace-modules.json new file mode 100644 index 0000000000..83c43c71e6 --- /dev/null +++ b/docs/generated/packages/js/executors/copy-workspace-modules.json @@ -0,0 +1,30 @@ +{ + "name": "copy-workspace-modules", + "implementation": "/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts", + "schema": { + "version": 2, + "outputCapture": "direct-nodejs", + "title": "Copy Workspace Modules", + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "cli": "nx", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "The build target that produces the output directory to transform.", + "default": "build" + }, + "outputPath": { + "type": "string", + "description": "The output path to transform. Usually inferred from the outputs of the buildTarget." + } + }, + "required": ["buildTarget"], + "presets": [] + }, + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "aliases": [], + "hidden": false, + "path": "/packages/js/src/executors/copy-workspace-modules/schema.json", + "type": "executor" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 4896f6a98c..6ec7c02d25 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -121,6 +121,7 @@ - [Use JavaScript instead TypeScript](/technologies/typescript/recipes/js-and-ts) - [API](/technologies/typescript/api) - [executors](/technologies/typescript/api/executors) + - [copy-workspace-modules](/technologies/typescript/api/executors/copy-workspace-modules) - [tsc](/technologies/typescript/api/executors/tsc) - [swc](/technologies/typescript/api/executors/swc) - [node](/technologies/typescript/api/executors/node) diff --git a/e2e/js/src/js-executor-copy-workspace-modules.test.ts b/e2e/js/src/js-executor-copy-workspace-modules.test.ts new file mode 100644 index 0000000000..1e07c66c1d --- /dev/null +++ b/e2e/js/src/js-executor-copy-workspace-modules.test.ts @@ -0,0 +1,254 @@ +import { + checkFilesExist, + cleanupProject, + newProject, + readFile, + readJson, + runCLI, + uniq, + updateJson, + updateFile, + runCommand, +} from '@nx/e2e/utils'; + +describe('@nx/js:copy-workspace-modules', () => { + let scope: string; + + beforeAll(() => { + scope = newProject({ + packages: ['@nx/node', '@nx/js'], + preset: 'ts', + packageManager: 'pnpm', + }); + }); + + afterAll(() => { + cleanupProject(); + }); + + it('should copy a single workspace library to build output directory', async () => { + const nodeapp = uniq('nodeapp'); + const nodelib = uniq('nodelib'); + + // Generate a node application + runCLI( + `generate @nx/node:app ${nodeapp} --linter=eslint --unitTestRunner=jest` + ); + + // Generate a workspace library + runCLI( + `generate @nx/js:lib ${nodelib} --bundler=tsc --linter=eslint --unitTestRunner=jest` + ); + + // Update the library to export something testable + updateFile( + `${nodelib}/src/lib/${nodelib}.ts`, + `export function ${nodelib}() { + return '${nodelib} works!'; +}` + ); + + updateFile( + `${nodelib}/src/index.ts`, + `export * from './lib/${nodelib}.js';` + ); + + // Add workspace dependency to the app's package.json + updateJson(`${nodeapp}/package.json`, (json) => { + json.dependencies = { + ...json.dependencies, + [`@${scope}/${nodelib}`]: 'workspace:*', + }; + return json; + }); + runCommand(`pnpm install`); + + // Update the app to use the library + updateFile( + `${nodeapp}/src/main.ts`, + `import { ${nodelib} } from '@${scope}/${nodelib}'; +console.log('Hello World!'); +console.log(${nodelib}());` + ); + + // Build the application first (required for copy-workspace-modules) + runCLI(`build ${nodeapp}`); + + // Verify build output exists (should be in {projectRoot}/dist) + checkFilesExist(`${nodeapp}/dist/main.js`); + + // Add copy-workspace-modules target to the app's package.json nx targets + updateJson(`${nodeapp}/package.json`, (json) => { + if (!json.nx) { + json.nx = {}; + } + if (!json.nx.targets) { + json.nx.targets = {}; + } + json.nx.targets['copy-workspace-modules'] = { + executor: '@nx/js:copy-workspace-modules', + options: { + buildTarget: 'build', + }, + }; + return json; + }); + + // Run the copy-workspace-modules executor + const result = runCLI(`run ${nodeapp}:copy-workspace-modules`); + expect(result).toContain('Success!'); + + // Verify workspace_modules directory was created in the build output + checkFilesExist(`${nodeapp}/dist/workspace_modules`); + + // Verify the library was copied to workspace_modules + checkFilesExist( + `${nodeapp}/dist/workspace_modules/@${scope}/${nodelib}/package.json` + ); + }, 300_000); + + it('should copy multiple workspace libraries correctly', async () => { + const nodeapp = uniq('nodeapp'); + const nodelib1 = uniq('nodelib1'); + const nodelib2 = uniq('nodelib2'); + + // Generate a node application + runCLI( + `generate @nx/node:app ${nodeapp} --linter=eslint --unitTestRunner=jest` + ); + + // Generate workspace libraries + runCLI( + `generate @nx/js:lib ${nodelib1} --bundler=tsc --linter=eslint --unitTestRunner=jest` + ); + runCLI( + `generate @nx/js:lib ${nodelib2} --bundler=tsc --linter=eslint --unitTestRunner=jest` + ); + + // Update libraries to export something testable + updateFile( + `${nodelib1}/src/lib/${nodelib1}.ts`, + `export function ${nodelib1}() { + return '${nodelib1} works!'; +}` + ); + updateFile( + `${nodelib1}/src/index.ts`, + `export * from './lib/${nodelib1}.js';` + ); + + updateFile( + `${nodelib2}/src/lib/${nodelib2}.ts`, + `export function ${nodelib2}() { + return '${nodelib2} works!'; +}` + ); + updateFile( + `${nodelib2}/src/index.ts`, + `export * from './lib/${nodelib2}.js';` + ); + + // Add workspace dependencies to the app's package.json + updateJson(`${nodeapp}/package.json`, (json) => { + json.dependencies = { + ...json.dependencies, + [`@${scope}/${nodelib1}`]: 'workspace:*', + [`@${scope}/${nodelib2}`]: 'workspace:*', + }; + return json; + }); + + // Update the app to use both libraries + updateFile( + `${nodeapp}/src/main.ts`, + `import { ${nodelib1} } from '@${scope}/${nodelib1}'; +import { ${nodelib2} } from '@${scope}/${nodelib2}'; +console.log('Hello World!'); +console.log(${nodelib1}()); +console.log(${nodelib2}());` + ); + runCommand(`pnpm install`); + + // Build the application + runCLI(`build ${nodeapp}`); + + // Add copy-workspace-modules target + updateJson(`${nodeapp}/package.json`, (json) => { + if (!json.nx) { + json.nx = {}; + } + if (!json.nx.targets) { + json.nx.targets = {}; + } + json.nx.targets['copy-workspace-modules'] = { + executor: '@nx/js:copy-workspace-modules', + options: { + buildTarget: 'build', + }, + }; + return json; + }); + + // Run the copy-workspace-modules executor + const result = runCLI(`run ${nodeapp}:copy-workspace-modules`); + expect(result).toContain('Success!'); + + // Verify both libraries were copied + checkFilesExist( + `${nodeapp}/dist/workspace_modules/@${scope}/${nodelib1}/package.json`, + `${nodeapp}/dist/workspace_modules/@${scope}/${nodelib2}/package.json` + ); + }, 300_000); + + it('should handle file: protocol dependencies', async () => { + const nodeapp = uniq('nodeapp'); + const nodelib = uniq('nodelib'); + + // Generate a node application and library + runCLI( + `generate @nx/node:app ${nodeapp} --linter=eslint --unitTestRunner=jest` + ); + runCLI( + `generate @nx/js:lib ${nodelib} --bundler=tsc --linter=eslint --unitTestRunner=jest` + ); + + // Add file: protocol dependency + updateJson(`${nodeapp}/package.json`, (json) => { + json.dependencies = { + ...json.dependencies, + [`@${scope}/${nodelib}`]: `file:../${nodelib}`, + }; + return json; + }); + runCommand(`pnpm install`); + + // Build the application + runCLI(`build ${nodeapp}`); + + // Add copy-workspace-modules target + updateJson(`${nodeapp}/package.json`, (json) => { + if (!json.nx) { + json.nx = {}; + } + if (!json.nx.targets) { + json.nx.targets = {}; + } + json.nx.targets['copy-workspace-modules'] = { + executor: '@nx/js:copy-workspace-modules', + options: { + buildTarget: 'build', + }, + }; + return json; + }); + + // Run the copy-workspace-modules executor + const result = runCLI(`run ${nodeapp}:copy-workspace-modules`); + expect(result).toContain('Success!'); + + // Verify library was copied + checkFilesExist( + `${nodeapp}/dist/workspace_modules/@${scope}/${nodelib}/package.json` + ); + }, 300_000); +}); diff --git a/packages/js/executors.json b/packages/js/executors.json index 248c555e9b..a2d4f51122 100644 --- a/packages/js/executors.json +++ b/packages/js/executors.json @@ -1,6 +1,11 @@ { "$schema": "https://json-schema.org/schema", "executors": { + "copy-workspace-modules": { + "implementation": "./src/executors/copy-workspace-modules/copy-workspace-modules", + "schema": "./src/executors/copy-workspace-modules/schema.json", + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives." + }, "tsc": { "implementation": "./src/executors/tsc/tsc.impl", "batchImplementation": "./src/executors/tsc/tsc.batch-impl", diff --git a/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts b/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts new file mode 100644 index 0000000000..cf86e81592 --- /dev/null +++ b/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts @@ -0,0 +1,131 @@ +import { + type ExecutorContext, + logger, + parseTargetString, + type ProjectGraph, + readJsonFile, + workspaceRoot, +} from '@nx/devkit'; +import { interpolate } from 'nx/src/tasks-runner/utils'; +import { type CopyWorkspaceModulesOptions } from './schema'; +import { cpSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'path'; +import { lstatSync } from 'fs'; +import { getWorkspacePackagesFromGraph } from '../../utils/package-json/get-workspace-packages-from-graph'; + +export default async function copyWorkspaceModules( + schema: CopyWorkspaceModulesOptions, + context: ExecutorContext +) { + logger.log('Copying Workspace Modules to Build Directory...'); + const outputDirectory = getOutputDir(schema, context); + const packageJson = getPackageJson(schema, context); + createWorkspaceModules(outputDirectory); + handleWorkspaceModules(outputDirectory, packageJson, context.projectGraph); + logger.log('Success!'); + return { success: true }; +} + +function handleWorkspaceModules( + outputDirectory: string, + packageJson: { + dependencies?: Record; + }, + projectGraph: ProjectGraph +) { + if (!packageJson.dependencies) { + return; + } + const workspaceModules = getWorkspacePackagesFromGraph(projectGraph); + + for (const [pkgName] of Object.entries(packageJson.dependencies)) { + if (workspaceModules.has(pkgName)) { + logger.verbose(`Copying ${pkgName}.`); + const workspaceModuleProject = workspaceModules.get(pkgName); + const workspaceModuleRoot = workspaceModuleProject.data.root; + const newWorkspaceModulePath = join( + outputDirectory, + 'workspace_modules', + pkgName + ); + mkdirSync(newWorkspaceModulePath, { recursive: true }); + cpSync(workspaceModuleRoot, newWorkspaceModulePath, { + filter: (src) => !src.includes('node_modules'), + recursive: true, + }); + logger.verbose(`Copied ${pkgName} successfully.`); + } + } +} + +function createWorkspaceModules(outputDirectory: string) { + mkdirSync(join(outputDirectory, 'workspace_modules'), { recursive: true }); +} + +function getPackageJson( + schema: CopyWorkspaceModulesOptions, + context: ExecutorContext +) { + const target = parseTargetString(schema.buildTarget, context); + const project = context.projectGraph.nodes[target.project].data; + const packageJsonPath = join(workspaceRoot, project.root, 'package.json'); + if (!existsSync(packageJsonPath)) { + throw new Error(`${packageJsonPath} does not exist.`); + } + + const packageJson = readJsonFile(packageJsonPath); + return packageJson; +} + +function getOutputDir( + schema: CopyWorkspaceModulesOptions, + context: ExecutorContext +) { + let outputDir = schema.outputPath; + if (outputDir) { + outputDir = normalizeOutputPath(outputDir); + if (existsSync(outputDir)) { + return outputDir; + } + } + const target = parseTargetString(schema.buildTarget, context); + const project = context.projectGraph.nodes[target.project].data; + const buildTarget = project.targets[target.target]; + let maybeOutputPath = + buildTarget.outputs?.[0] ?? + buildTarget.options.outputPath ?? + buildTarget.options.outputDir; + + if (!maybeOutputPath) { + throw new Error( + `Could not infer an output directory from the '${schema.buildTarget}' target. Please provide 'outputPath'.` + ); + } + + maybeOutputPath = interpolate(maybeOutputPath, { + workspaceRoot, + projectRoot: project.root, + projectName: project.name, + options: { + ...(buildTarget.options ?? {}), + }, + }); + + outputDir = normalizeOutputPath(maybeOutputPath); + if (!existsSync(outputDir)) { + throw new Error( + `The output directory '${outputDir}' inferred from the '${schema.buildTarget}' target does not exist.\nPlease ensure a build has run first, and that the path is correct. Otherwise, please provide 'outputPath'.` + ); + } + return outputDir; +} + +function normalizeOutputPath(outputPath: string) { + if (!outputPath.startsWith(workspaceRoot)) { + outputPath = join(workspaceRoot, outputPath); + } + if (!lstatSync(outputPath).isDirectory()) { + outputPath = dirname(outputPath); + } + return outputPath; +} diff --git a/packages/js/src/executors/copy-workspace-modules/schema.d.ts b/packages/js/src/executors/copy-workspace-modules/schema.d.ts new file mode 100644 index 0000000000..1292fdc822 --- /dev/null +++ b/packages/js/src/executors/copy-workspace-modules/schema.d.ts @@ -0,0 +1,4 @@ +export interface CopyWorkspaceModulesOptions { + buildTarget: string; + outputPath?: string; +} diff --git a/packages/js/src/executors/copy-workspace-modules/schema.json b/packages/js/src/executors/copy-workspace-modules/schema.json new file mode 100644 index 0000000000..58adf19277 --- /dev/null +++ b/packages/js/src/executors/copy-workspace-modules/schema.json @@ -0,0 +1,20 @@ +{ + "version": 2, + "outputCapture": "direct-nodejs", + "title": "Copy Workspace Modules", + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "cli": "nx", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "The build target that produces the output directory to transform.", + "default": "build" + }, + "outputPath": { + "type": "string", + "description": "The output path to transform. Usually inferred from the outputs of the buildTarget." + } + }, + "required": ["buildTarget"] +} diff --git a/packages/js/src/utils/package-json/get-workspace-packages-from-graph.ts b/packages/js/src/utils/package-json/get-workspace-packages-from-graph.ts new file mode 100644 index 0000000000..239d6d86c9 --- /dev/null +++ b/packages/js/src/utils/package-json/get-workspace-packages-from-graph.ts @@ -0,0 +1,12 @@ +import { type ProjectGraph, ProjectGraphProjectNode } from '@nx/devkit'; + +export function getWorkspacePackagesFromGraph(graph: ProjectGraph) { + const workspacePackages: Map = new Map(); + for (const [projectName, project] of Object.entries(graph.nodes)) { + const pkgName = project.data?.metadata?.js?.packageName; + if (pkgName) { + workspacePackages.set(pkgName, project); + } + } + return workspacePackages; +}