diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 4a9a52d5f7fd8..d838753383ebd 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -5053,6 +5053,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "generate-package-json", + "path": "/packages/js/executors/generate-package-json", + "name": "generate-package-json", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/packages.json b/docs/generated/manifests/packages.json index b978288245a65..23913f7416e3d 100644 --- a/docs/generated/manifests/packages.json +++ b/docs/generated/manifests/packages.json @@ -1020,6 +1020,15 @@ "originalFilePath": "/packages/js/src/executors/verdaccio/schema.json", "path": "/packages/js/executors/verdaccio", "type": "executor" + }, + "/packages/js/executors/generate-package-json": { + "description": "Generates a `package.json` and pruned lock file (if generateLockfile not disabled) with the project's `node_module` dependencies populated for installing in a container. If a `package.json` exists in the project's directory, it will be reused with dependencies populated.", + "file": "generated/packages/js/executors/generate-package-json.json", + "hidden": false, + "name": "generate-package-json", + "originalFilePath": "/packages/js/src/executors/generate-package-json/schema.json", + "path": "/packages/js/executors/generate-package-json", + "type": "executor" } }, "generators": { diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 13950283cd3d2..bd81b6ff67230 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -1004,6 +1004,15 @@ "originalFilePath": "/packages/js/src/executors/verdaccio/schema.json", "path": "js/executors/verdaccio", "type": "executor" + }, + { + "description": "Generates a `package.json` and pruned lock file (if generateLockfile not disabled) with the project's `node_module` dependencies populated for installing in a container. If a `package.json` exists in the project's directory, it will be reused with dependencies populated.", + "file": "generated/packages/js/executors/generate-package-json.json", + "hidden": false, + "name": "generate-package-json", + "originalFilePath": "/packages/js/src/executors/generate-package-json/schema.json", + "path": "js/executors/generate-package-json", + "type": "executor" } ], "generators": [ diff --git a/docs/generated/packages/js/executors/generate-package-json.json b/docs/generated/packages/js/executors/generate-package-json.json new file mode 100644 index 0000000000000..9691765b7e40f --- /dev/null +++ b/docs/generated/packages/js/executors/generate-package-json.json @@ -0,0 +1,66 @@ +{ + "name": "generate-package-json", + "implementation": "/packages/js/src/executors/generate-package-json/generate-package-json.impl.ts", + "schema": { + "$schema": "http://json-schema.org/schema", + "outputCapture": "direct-nodejs", + "version": 2, + "cli": "nx", + "title": "GeneratePackageJson builder", + "description": "Generates a package.json file.", + "type": "object", + "properties": { + "main": { + "type": "string", + "description": "The path to the entry file, relative to project.", + "alias": "entryFile", + "x-completion-type": "file", + "x-completion-glob": "**/*@(.js|.ts)" + }, + "outputPath": { + "type": "string", + "description": "The output path of the generated files.", + "x-completion-type": "directory", + "x-priority": "important" + }, + "tsConfig": { + "type": "string", + "description": "The path to tsconfig file.", + "x-completion-type": "file", + "x-completion-glob": "tsconfig.*.json" + }, + "buildableProjectDepsInPackageJsonType": { + "type": "string", + "description": "The type of dependency to use for buildable project dependencies in the generated `package.json`.", + "enum": ["dependencies", "devDependencies"], + "default": "dependencies" + }, + "excludeLibsInPackageJson": { + "type": "boolean", + "description": "Exclude libraries in the `package.json` file. This is useful if you are using a `package.json` file in the project's directory.", + "default": true + }, + "generateLockfile": { + "type": "boolean", + "description": "Generate a lock file for the generated `package.json`.", + "default": true + }, + "format": { + "type": "string", + "description": "List of module formats to output. Defaults to matching format from tsconfig (e.g. CJS for CommonJS, and ESM otherwise).", + "alias": "f", + "enum": ["esm", "cjs"], + "default": "esm" + } + }, + "definitions": {}, + "required": ["main", "outputPath", "tsConfig"], + "examplesFile": "---\ntitle: Examples for the generate package json executor\ndescription: This page contains examples for the js @nx/js:generate-package-json executor.\n---\n\n`project.json`:\n\n```json\n{\n \"name\": \"api\",\n //...\n \"targets\": {\n \"build\": {\n \"dependsOn\": [\n { \"projects\": \"self\", \"target\": \"build-api\", \"params\": \"forward\" }\n ],\n \"executor\": \"@nx/js:generate-package-json\",\n \"options\": {\n \"outputPath\": \"dist/apps/api\",\n \"main\": \"apps/api/src/main.ts\",\n \"tsConfig\": \"apps/api/tsconfig.app.json\"\n }\n },\n \"build-api\": {\n \"executor\": \"@nx/vite:build\",\n //...\n \"configurations\": {\n \"production\": {\n \"mode\": \"production\"\n }\n }\n },\n \"serve\": {\n \"executor\": \"@nx/vite:dev-server\",\n //...\n \"configurations\": {\n \"production\": {\n \"buildTarget\": \"api:build:production\"\n }\n }\n }\n }\n}\n```\n\n```bash\nnx build api --prod\nnx build api --dev\n```\n", + "presets": [] + }, + "description": "Generates a `package.json` and pruned lock file (if generateLockfile not disabled) with the project's `node_module` dependencies populated for installing in a container. If a `package.json` exists in the project's directory, it will be reused with dependencies populated.", + "aliases": [], + "hidden": false, + "path": "/packages/js/src/executors/generate-package-json/schema.json", + "type": "executor" +} diff --git a/packages/esbuild/src/executors/esbuild/esbuild.impl.ts b/packages/esbuild/src/executors/esbuild/esbuild.impl.ts index 239b74828bd20..10b34b308de32 100644 --- a/packages/esbuild/src/executors/esbuild/esbuild.impl.ts +++ b/packages/esbuild/src/executors/esbuild/esbuild.impl.ts @@ -6,6 +6,7 @@ import { copyAssets, copyPackageJson, CopyPackageJsonOptions, + getExtraDependencies, printDiagnostics, runTypeCheck as _runTypeCheck, TypeCheckOptions, @@ -21,7 +22,6 @@ import { getOutExtension, getOutfile, } from './lib/build-esbuild-options'; -import { getExtraDependencies } from './lib/get-extra-dependencies'; import { DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils'; import { join } from 'path'; diff --git a/packages/js/docs/generate-package-json.examples.md b/packages/js/docs/generate-package-json.examples.md new file mode 100644 index 0000000000000..e3d629f81e40b --- /dev/null +++ b/packages/js/docs/generate-package-json.examples.md @@ -0,0 +1,49 @@ +--- +title: Examples for the generate package json executor +description: This page contains examples for the js @nx/js:generate-package-json executor. +--- + +`project.json`: + +```json +{ + "name": "api", + //... + "targets": { + "build": { + "dependsOn": [ + { "projects": "self", "target": "build-api", "params": "forward" } + ], + "executor": "@nx/js:generate-package-json", + "options": { + "outputPath": "dist/apps/api", + "main": "apps/api/src/main.ts", + "tsConfig": "apps/api/tsconfig.app.json" + } + }, + "build-api": { + "executor": "@nx/vite:build", + //... + "configurations": { + "production": { + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + //... + "configurations": { + "production": { + "buildTarget": "api:build:production" + } + } + } + } +} +``` + +```bash +nx build api --prod +nx build api --dev +``` diff --git a/packages/js/executors.json b/packages/js/executors.json index 4df6f6369d599..3b66c43119341 100644 --- a/packages/js/executors.json +++ b/packages/js/executors.json @@ -21,6 +21,11 @@ "implementation": "./src/executors/verdaccio/verdaccio.impl", "schema": "./src/executors/verdaccio/schema.json", "description": "Start local registry with verdaccio" + }, + "generate-package-json": { + "implementation": "./src/executors/generate-package-json/generate-package-json.impl", + "schema": "./src/executors/generate-package-json/schema.json", + "description": "Generates a `package.json` and pruned lock file (if generateLockfile not disabled) with the project's `node_module` dependencies populated for installing in a container. If a `package.json` exists in the project's directory, it will be reused with dependencies populated." } }, "builders": { diff --git a/packages/js/src/executors/generate-package-json/generate-package-json.impl.spec.ts b/packages/js/src/executors/generate-package-json/generate-package-json.impl.spec.ts new file mode 100644 index 0000000000000..36b0634f2e796 --- /dev/null +++ b/packages/js/src/executors/generate-package-json/generate-package-json.impl.spec.ts @@ -0,0 +1,199 @@ +import 'nx/src/utils/testing/mock-fs'; + +import { GeneratePackageJsonExecutorOptions } from './schema'; +import executor from './generate-package-json.impl'; +import { ExecutorContext } from '@nx/devkit'; +import { vol } from 'memfs'; +import { readFileSync, statSync } from 'fs'; +import { readdirSync } from 'fs-extra'; +import { join } from 'path'; + +const getAllFiles = (dirPath: string, arrayOfFiles = []) => { + const files = readdirSync(dirPath); + + files.forEach((file) => { + const filepath = join(dirPath, file); + if (statSync(filepath).isDirectory()) { + arrayOfFiles = getAllFiles(filepath, arrayOfFiles); + } else { + arrayOfFiles.push(filepath); + } + }); + + return arrayOfFiles; +}; + +describe('GeneratePackageJson Executor', () => { + const projectName = 'parent'; + const projectPath = `apps/${projectName}`; + const outputPath = `dist/apps/${projectName}`; + + const mockWorkspaceRoot = process.cwd(); + + const mockBuildOptions: GeneratePackageJsonExecutorOptions = { + main: `${projectPath}/index.js`, + outputPath, + tsConfig: `${projectPath}/tsconfig.app.json`, + }; + + const mockContext: ExecutorContext = { + cwd: '', + isVerbose: false, + root: mockWorkspaceRoot, + projectName, + target: { + options: mockBuildOptions, + }, + targetName: 'build', + projectGraph: { + nodes: { + parent: { + type: 'app', + name: 'parent', + data: { + files: [], + root: projectPath, + targets: { + build: { + inputs: [], + options: mockBuildOptions, + }, + }, + }, + }, + child1: { + type: 'lib', + name: 'child1', + data: {}, + }, + child2: { + type: 'lib', + name: 'child2', + data: {}, + }, + } as any, + externalNodes: { + 'npm:react': { + type: 'npm', + name: 'npm:react', + data: { packageName: 'react', version: '18.0.0' }, + }, + 'npm:axios': { + type: 'npm', + name: 'npm:axios', + data: { packageName: 'axios', version: '1.0.0' }, + }, + 'npm:dayjs': { + type: 'npm', + name: 'npm:dayjs', + data: { packageName: 'dayjs', version: '1.11.0' }, + }, + }, + dependencies: { + parent: [ + { source: 'parent', target: 'child1', type: 'static' }, + { source: 'parent', target: 'npm:react', type: 'static' }, + ], + child1: [ + { source: 'child1', target: 'child2', type: 'static' }, + { source: 'child1', target: 'npm:axios', type: 'static' }, + ], + child2: [{ source: 'child2', target: 'npm:dayjs', type: 'static' }], + }, + }, + }; + + beforeEach(async () => { + vol.reset(); + const fileSys = { + [`${projectPath}/package.json`]: JSON.stringify({ + name: '@company/parent', + author: 'John Doe', + something: 'else', + }), + 'package.json': JSON.stringify({ + name: 'package.name.does.not.matter', + }), + 'package-lock.json': JSON.stringify({ + name: 'package-lock.name.does.not.matter', + version: 'package-lock.version.does.not.matter', + lockfileVersion: 3, + packages: { + '': {}, + 'node_modules/axios': { + version: '1.0.0', + }, + 'node_modules/dayjs': { + version: '1.11.0', + }, + 'node_modules/react': { + version: '18.0.0', + }, + }, + }), + }; + vol.fromJSON(fileSys, mockWorkspaceRoot); + }); + + it('generates a package.json and package-lock.json with child dependencies', async () => { + const output = await executor(mockBuildOptions, mockContext); + + const expectedPackageLockJson = { + name: '@company/parent', + lockfileVersion: 3, + version: '0.0.1', + packages: { + '': { + name: '@company/parent', + dependencies: { + axios: '1.0.0', + dayjs: '1.11.0', + react: '18.0.0', + }, + }, + 'node_modules/axios': { + version: '1.0.0', + }, + 'node_modules/dayjs': { + version: '1.11.0', + }, + 'node_modules/react': { + version: '18.0.0', + }, + }, + }; + + const expectedPackageJson = { + name: '@company/parent', + author: 'John Doe', + something: 'else', + dependencies: { + axios: '1.0.0', + dayjs: '1.11.0', + react: '18.0.0', + }, + module: './index.js', + type: 'module', + main: './index.js', + }; + + expect( + JSON.parse(readFileSync(`${outputPath}/package-lock.json`, 'utf-8')) + ).toEqual(expectedPackageLockJson); + + expect( + JSON.parse(readFileSync(`${outputPath}/package.json`, 'utf-8')) + ).toEqual(expectedPackageJson); + + expect(output.success).toBe(true); + + expect(getAllFiles('')).toEqual([ + `${projectPath}/package.json`, + `${outputPath}/package-lock.json`, + `${outputPath}/package.json`, + 'package-lock.json', + 'package.json', + 'tmp/apps/parent/tsconfig.generated.json', + ]); + }); +}); diff --git a/packages/js/src/executors/generate-package-json/generate-package-json.impl.ts b/packages/js/src/executors/generate-package-json/generate-package-json.impl.ts new file mode 100644 index 0000000000000..47df240f6b7f4 --- /dev/null +++ b/packages/js/src/executors/generate-package-json/generate-package-json.impl.ts @@ -0,0 +1,70 @@ +import { ExecutorContext } from '@nx/devkit'; +import { getExtraDependencies } from '../../utils/get-extra-dependencies'; +import { + copyPackageJson, + CopyPackageJsonOptions, +} from '../../utils/package-json'; + +import { + GeneratePackageJsonExecutorOptions, + NormalizedGeneratePackageJsonExecutorOptions, +} from './schema'; + +export interface GeneratePackageJsonResult { + success?: boolean; +} + +export default async function generatePackageJson( + options: GeneratePackageJsonExecutorOptions, + context: ExecutorContext +): Promise { + const { + main, + buildableProjectDepsInPackageJsonType, + excludeLibsInPackageJson, + format, + generateLockfile, + outputPath, + } = normalizeOptions(options); + + const externalDependencies = getExtraDependencies( + context.projectName, + context.projectGraph + ); + + const cpjOptions: CopyPackageJsonOptions = { + outputPath, + buildableProjectDepsInPackageJsonType, + excludeLibsInPackageJson, + generateLockfile, + format: [format], + main, + watch: false, + skipTypings: true, + updateBuildableProjectDepsInPackageJson: externalDependencies.length > 0, + }; + + await copyPackageJson(cpjOptions, context); + + return { + success: true, + }; +} + +const normalizeOptions = ( + options: GeneratePackageJsonExecutorOptions +): NormalizedGeneratePackageJsonExecutorOptions => { + const { + buildableProjectDepsInPackageJsonType = 'dependencies', + excludeLibsInPackageJson = true, + format = 'esm', + generateLockfile = true, + } = options; + return { + ...options, + buildableProjectDepsInPackageJsonType, + excludeLibsInPackageJson, + format, + generateLockfile, + }; +}; diff --git a/packages/js/src/executors/generate-package-json/schema.d.ts b/packages/js/src/executors/generate-package-json/schema.d.ts new file mode 100644 index 0000000000000..9431fb4ffc144 --- /dev/null +++ b/packages/js/src/executors/generate-package-json/schema.d.ts @@ -0,0 +1,27 @@ +export interface GeneratePackageJsonExecutorOptions { + main: string; + outputPath: string; + /** + * this isn't used directly in the executor but is read from "context.target.options.tsConfig" when calling copyPackageJson + */ + tsConfig: string; + /** + * @default 'dependencies' + */ + buildableProjectDepsInPackageJsonType?: 'dependencies' | 'peerDependencies'; + /** + * @default true + */ + excludeLibsInPackageJson?: boolean; + /** + * @default 'esm' + */ + format?: 'esm' | 'cjs'; + /** + * @default true + */ + generateLockfile?: boolean; +} + +export type NormalizedGeneratePackageJsonExecutorOptions = + Required; diff --git a/packages/js/src/executors/generate-package-json/schema.json b/packages/js/src/executors/generate-package-json/schema.json new file mode 100644 index 0000000000000..654f4eb4e82e9 --- /dev/null +++ b/packages/js/src/executors/generate-package-json/schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/schema", + "outputCapture": "direct-nodejs", + "version": 2, + "cli": "nx", + "title": "GeneratePackageJson builder", + "description": "Generates a package.json file.", + "type": "object", + "properties": { + "main": { + "type": "string", + "description": "The path to the entry file, relative to project.", + "alias": "entryFile", + "x-completion-type": "file", + "x-completion-glob": "**/*@(.js|.ts)" + }, + "outputPath": { + "type": "string", + "description": "The output path of the generated files.", + "x-completion-type": "directory", + "x-priority": "important" + }, + "tsConfig": { + "type": "string", + "description": "The path to tsconfig file.", + "x-completion-type": "file", + "x-completion-glob": "tsconfig.*.json" + }, + "buildableProjectDepsInPackageJsonType": { + "type": "string", + "description": "The type of dependency to use for buildable project dependencies in the generated `package.json`.", + "enum": ["dependencies", "devDependencies"], + "default": "dependencies" + }, + "excludeLibsInPackageJson": { + "type": "boolean", + "description": "Exclude libraries in the `package.json` file. This is useful if you are using a `package.json` file in the project's directory.", + "default": true + }, + "generateLockfile": { + "type": "boolean", + "description": "Generate a lock file for the generated `package.json`.", + "default": true + }, + "format": { + "type": "string", + "description": "List of module formats to output. Defaults to matching format from tsconfig (e.g. CJS for CommonJS, and ESM otherwise).", + "alias": "f", + "enum": ["esm", "cjs"], + "default": "esm" + } + }, + "definitions": {}, + "required": ["main", "outputPath", "tsConfig"], + "examplesFile": "../../../docs/generate-package-json.examples.md" +} diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index b5233e45bd1a9..0116f3b7844ff 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -7,6 +7,7 @@ export * from './utils/compiler-helper-dependency'; export * from './utils/typescript/ts-config'; export * from './utils/typescript/create-ts-config'; export * from './utils/typescript/ast-utils'; +export * from './utils/get-extra-dependencies'; export * from './utils/package-json'; export * from './utils/assets'; export * from './utils/package-json/update-package-json'; diff --git a/packages/esbuild/src/executors/esbuild/lib/get-extra-dependencies.spec.ts b/packages/js/src/utils/get-extra-dependencies.spec.ts similarity index 100% rename from packages/esbuild/src/executors/esbuild/lib/get-extra-dependencies.spec.ts rename to packages/js/src/utils/get-extra-dependencies.spec.ts diff --git a/packages/esbuild/src/executors/esbuild/lib/get-extra-dependencies.ts b/packages/js/src/utils/get-extra-dependencies.ts similarity index 91% rename from packages/esbuild/src/executors/esbuild/lib/get-extra-dependencies.ts rename to packages/js/src/utils/get-extra-dependencies.ts index 5a9a1b1d7081c..9cce4e5cf3915 100644 --- a/packages/esbuild/src/executors/esbuild/lib/get-extra-dependencies.ts +++ b/packages/js/src/utils/get-extra-dependencies.ts @@ -1,5 +1,5 @@ import { ProjectGraph } from '@nx/devkit'; -import { DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils'; +import { DependentBuildableProjectNode } from './buildable-libs-utils'; export function getExtraDependencies( projectName: string, diff --git a/packages/nx/src/utils/testing/mock-fs.ts b/packages/nx/src/utils/testing/mock-fs.ts index 6e6e96ea7c030..443c8b6b62bf8 100644 --- a/packages/nx/src/utils/testing/mock-fs.ts +++ b/packages/nx/src/utils/testing/mock-fs.ts @@ -3,6 +3,18 @@ jest.mock('fs', (): Partial => { const mockFs = require('memfs').fs; return { ...mockFs, + realpath: { + ...mockFs.realpath, + native: () => { + // @SEE: https://github.com/streamich/memfs/issues/735 + // realpath.native is not implemeted in memfs + // @SEE: https://github.com/streamich/memfs/issues/803 + // fs-extra is "defensively patched" to emit a warning if the function does not exist + // So if we are mocking fs with memfs "fs.realpath.native" will not work properly in tests, so throwing an error + // will prevent harder to debug issues with writing tests that use memfs + throw Error('[mock-fs.ts] Not implemented'); + }, + }, existsSync(path: string) { if (path.endsWith('.node')) { return true;