Compare commits

...

1 Commits

Author SHA1 Message Date
Jack Hsu d4988b8683 fix(node): node application generator sets up docker correctly 2025-07-29 21:06:14 -04:00
16 changed files with 213 additions and 5 deletions
@@ -124,6 +124,11 @@
"type": "boolean",
"description": "Add a docker build target"
},
"skipDockerPlugin": {
"type": "boolean",
"description": "Skip the @nx/docker plugin and use the legacy docker build target instead.",
"default": false
},
"useProjectJson": {
"type": "boolean",
"description": "Use a `project.json` configuration file instead of inlining the Nx configuration in the `package.json` file."
@@ -29,6 +29,11 @@
"outputPath": {
"description": "The output path for the node application",
"type": "string"
},
"skipDockerPlugin": {
"type": "boolean",
"description": "Skip the @nx/docker plugin and use the legacy docker build target instead.",
"default": false
}
},
"presets": []
+5 -1
View File
@@ -35,7 +35,11 @@
"./package.json": "./package.json",
"./generators.json": "./generators.json",
"./executors.json": "./executors.json",
"./migrations.json": "./migrations.json"
"./migrations.json": "./migrations.json",
"./generators": {
"types": "./src/generators/index.d.ts",
"default": "./src/generators/index.js"
}
},
"nx-migrations": {
"migrations": "./migrations.json"
+1
View File
@@ -0,0 +1 @@
export { initGenerator } from './init/init';
+1
View File
@@ -33,6 +33,7 @@
"dependencies": {
"tslib": "^2.3.0",
"@nx/devkit": "workspace:*",
"@nx/docker": "workspace:*",
"@nx/jest": "workspace:*",
"@nx/js": "workspace:*",
"@nx/eslint": "workspace:*",
@@ -32,6 +32,8 @@ import {
normalizeOptions,
NormalizedSchema,
} from './lib';
// @ts-ignore-next-line
import { initGenerator as dockerInitGenerator } from '@nx/docker/generators';
function updateTsConfigOptions(tree: Tree, options: NormalizedSchema) {
if (options.isUsingTsSolutionConfig) {
@@ -97,6 +99,7 @@ export async function applicationGeneratorInternal(tree: Tree, schema: Schema) {
...options,
project: options.name,
skipFormat: true,
skipDockerPlugin: options.skipDockerPlugin,
});
tasks.push(dockerTask);
}
@@ -210,10 +213,20 @@ export async function applicationGeneratorInternal(tree: Tree, schema: Schema) {
}
if (options.docker) {
// Initialize @nx/docker plugin if not skipping
if (!options.skipDockerPlugin) {
const dockerInitTask = await dockerInitGenerator(tree, {
skipFormat: true,
skipPackageJson: options.skipPackageJson,
});
tasks.push(dockerInitTask);
}
const dockerTask = await setupDockerGenerator(tree, {
...options,
project: options.name,
skipFormat: true,
skipDockerPlugin: options.skipDockerPlugin,
});
tasks.push(dockerTask);
+1
View File
@@ -22,6 +22,7 @@ export interface Schema {
port?: number;
rootProject?: boolean;
docker?: boolean;
skipDockerPlugin?: boolean;
isNest?: boolean;
addPlugin?: boolean;
useTsSolution?: boolean;
@@ -124,6 +124,11 @@
"type": "boolean",
"description": "Add a docker build target"
},
"skipDockerPlugin": {
"type": "boolean",
"description": "Skip the @nx/docker plugin and use the legacy docker build target instead.",
"default": false
},
"useProjectJson": {
"type": "boolean",
"description": "Use a `project.json` configuration file instead of inlining the Nx configuration in the `package.json` file."
@@ -1,8 +1,11 @@
# This file is generated by Nx.
#
#<% if (skipDockerPlugin) { %>
# Build the docker image with `npx nx docker-build <%= project %>`.
# Tip: Modify "docker-build" options in project.json to change docker build args.
#
#<% } else { %>
# Build the docker image with `npx nx docker:build <%= project %>`.
# Tip: Modify "docker:build" options in project.json to change docker build args.
#<% } %>
# Run the container with `docker run -p 3000:3000 -t <%= sanitizedProjectName %>`.
FROM docker.io/node:lts-alpine
@@ -15,7 +18,7 @@ RUN addgroup --system <%= sanitizedProjectName %> && \
adduser --system -G <%= sanitizedProjectName %> <%= sanitizedProjectName %>
COPY <%= buildLocation %> <%= sanitizedProjectName %>/
COPY <%= projectPath %>/package.json <%= sanitizedProjectName %>/
<% if (skipDockerPlugin) { %>COPY <%= projectPath %>/package.json <%= sanitizedProjectName %>/<% } else { %>COPY package.json <%= sanitizedProjectName %>/<% } %>
RUN chown -R <%= sanitizedProjectName %>:<%= sanitizedProjectName %> .
# You can remove this install step if you build with `--bundle` option.
+1
View File
@@ -4,4 +4,5 @@ export interface SetUpDockerOptions {
buildTarget?: string;
skipFormat?: boolean;
outputPath: string;
skipDockerPlugin?: boolean;
}
@@ -28,6 +28,11 @@
"outputPath": {
"description": "The output path for the node application",
"type": "string"
},
"skipDockerPlugin": {
"type": "boolean",
"description": "Skip the @nx/docker plugin and use the legacy docker build target instead.",
"default": false
}
}
}
@@ -3,6 +3,31 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { applicationGenerator } from '../application/application';
import { setupDockerGenerator } from './setup-docker';
// Mock the @nx/docker package
jest.mock('@nx/devkit', () => {
const actualDevkit = jest.requireActual('@nx/devkit');
return {
...actualDevkit,
ensurePackage: jest.fn((pkg, version) => {
if (pkg === '@nx/docker') {
return {
initGenerator: jest.fn(async (tree, options) => {
// Mock the @nx/docker init generator
const nxJson = actualDevkit.readNxJson(tree);
if (!nxJson.plugins) {
nxJson.plugins = [];
}
nxJson.plugins.push('@nx/docker');
actualDevkit.updateNxJson(tree, nxJson);
return () => {};
}),
};
}
return actualDevkit.ensurePackage(pkg, version);
}),
};
});
describe('setupDockerGenerator', () => {
let tree: Tree;
beforeEach(async () => {
@@ -20,6 +45,7 @@ describe('setupDockerGenerator', () => {
framework: 'express',
e2eTestRunner: 'none',
docker: true,
skipDockerPlugin: true, // Use legacy mode for this test
addPlugin: true,
});
@@ -48,6 +74,7 @@ describe('setupDockerGenerator', () => {
directory: '.',
framework: 'fastify',
docker: true,
skipDockerPlugin: true, // Use legacy mode for this test
addPlugin: true,
});
@@ -67,6 +94,106 @@ describe('setupDockerGenerator', () => {
});
});
describe('skipDockerPlugin', () => {
it('should create docker-build target when skipDockerPlugin is true', async () => {
const projectName = 'api-with-legacy-docker';
await applicationGenerator(tree, {
directory: projectName,
framework: 'express',
e2eTestRunner: 'none',
docker: true,
skipDockerPlugin: true,
addPlugin: true,
});
const project = readProjectConfiguration(tree, projectName);
const dockerFile = tree.read(`${projectName}/Dockerfile`, 'utf8');
expect(tree.exists(`${projectName}/Dockerfile`)).toBeTruthy();
expect(dockerFile).toContain(`COPY dist/${projectName} ${projectName}/`);
expect(dockerFile).toContain(
`COPY ${projectName}/package.json ${projectName}/`
);
expect(dockerFile).toContain(
'Build the docker image with `npx nx docker-build'
);
expect(project.targets).toEqual(
expect.objectContaining({
'docker-build': {
dependsOn: ['build'],
command: `docker build -f ${projectName}/Dockerfile . -t ${projectName}`,
},
})
);
});
it('should not create docker-build target when skipDockerPlugin is false', async () => {
const projectName = 'api-with-plugin-docker';
await applicationGenerator(tree, {
directory: projectName,
framework: 'express',
e2eTestRunner: 'none',
docker: true,
skipDockerPlugin: false,
addPlugin: true,
});
const project = readProjectConfiguration(tree, projectName);
const dockerFile = tree.read(`${projectName}/Dockerfile`, 'utf8');
expect(tree.exists(`${projectName}/Dockerfile`)).toBeTruthy();
expect(dockerFile).toContain(`COPY dist ${projectName}/`);
expect(dockerFile).toContain(`COPY package.json ${projectName}/`);
expect(dockerFile).toContain(
'Build the docker image with `npx nx docker:build'
);
expect(project.targets?.['docker-build']).toBeUndefined();
});
it('should use project-relative paths when skipDockerPlugin is false', async () => {
const projectName = 'nested-api';
await applicationGenerator(tree, {
directory: `apps/${projectName}`,
framework: 'express',
e2eTestRunner: 'none',
docker: true,
skipDockerPlugin: false,
addPlugin: true,
});
const dockerFile = tree.read(`apps/${projectName}/Dockerfile`, 'utf8');
expect(dockerFile).toContain(`COPY dist nested-api/`);
expect(dockerFile).toContain(`COPY package.json nested-api/`);
expect(dockerFile).not.toContain(`apps/${projectName}`);
});
it('should use workspace-relative paths when skipDockerPlugin is true', async () => {
const projectName = 'nested-api-legacy';
await applicationGenerator(tree, {
directory: `apps/${projectName}`,
framework: 'express',
e2eTestRunner: 'none',
docker: true,
skipDockerPlugin: true,
addPlugin: true,
});
const dockerFile = tree.read(`apps/${projectName}/Dockerfile`, 'utf8');
expect(dockerFile).toContain(
`COPY dist/apps/${projectName} nested-api-legacy/`
);
expect(dockerFile).toContain(
`COPY apps/${projectName}/package.json nested-api-legacy/`
);
});
});
describe('project name sanitization', () => {
it('should sanitize project names with special characters for Docker commands', async () => {
const projectName = '@myorg/my-app';
@@ -82,6 +209,7 @@ describe('setupDockerGenerator', () => {
await setupDockerGenerator(tree, {
project: projectName,
outputPath: 'dist/myorg/my-app',
skipDockerPlugin: true,
});
const project = readProjectConfiguration(tree, projectName);
@@ -135,6 +263,7 @@ describe('setupDockerGenerator', () => {
await setupDockerGenerator(tree, {
project: projectName,
outputPath: 'dist/basic-app',
skipDockerPlugin: true,
});
const project = readProjectConfiguration(tree, projectName);
@@ -183,6 +312,7 @@ describe('setupDockerGenerator', () => {
framework: 'express',
e2eTestRunner: 'none',
docker: true,
skipDockerPlugin: true, // Use legacy mode for this test
addPlugin: true,
});
@@ -237,6 +367,7 @@ describe('setupDockerGenerator', () => {
await setupDockerGenerator(tree, {
project: projectName,
outputPath: 'dist/scope/my-app',
skipDockerPlugin: true,
});
const dockerfileContent = tree.read('Dockerfile', 'utf8');
@@ -22,6 +22,7 @@ function normalizeOptions(
project: setupOptions.project ?? readNxJson(tree).defaultProject,
targetName: setupOptions.targetName ?? 'docker-build',
buildTarget: setupOptions.buildTarget ?? 'build',
skipDockerPlugin: setupOptions.skipDockerPlugin ?? false,
};
}
@@ -51,17 +52,40 @@ function addDocker(tree: Tree, options: SetUpDockerOptions) {
}
const sanitizedProjectName = sanitizeProjectName(options.project);
const finalOutputPath = options.outputPath ?? outputPath;
// Calculate build location based on skipDockerPlugin flag
let buildLocation: string;
if (options.skipDockerPlugin) {
// Legacy mode: use workspace-relative paths
buildLocation = finalOutputPath;
} else {
// New mode: use project-relative paths
// Remove the project root prefix from the output path
const projectRootWithSlash = projectConfig.root + '/';
buildLocation = finalOutputPath.startsWith(projectRootWithSlash)
? finalOutputPath.substring(projectRootWithSlash.length)
: finalOutputPath.startsWith(projectConfig.root)
? finalOutputPath.substring(projectConfig.root.length)
: 'dist';
}
generateFiles(tree, join(__dirname, './files'), projectConfig.root, {
tmpl: '',
buildLocation: options.outputPath ?? outputPath,
buildLocation,
project: options.project,
projectPath: projectConfig.root,
sanitizedProjectName,
skipDockerPlugin: options.skipDockerPlugin,
});
}
export function updateProjectConfig(tree: Tree, options: SetUpDockerOptions) {
// Only create custom docker-build target if skipDockerPlugin is true
if (!options.skipDockerPlugin) {
return;
}
let projectConfig = readProjectConfiguration(tree, options.project);
// Use sanitized project name for Docker image tag
+3
View File
@@ -6,6 +6,9 @@
"include": [],
"files": [],
"references": [
{
"path": "../docker"
},
{
"path": "../eslint"
},
+3
View File
@@ -15,6 +15,9 @@
],
"include": ["**/*.ts", "**/*.json"],
"references": [
{
"path": "../docker/tsconfig.lib.json"
},
{
"path": "../eslint/tsconfig.lib.json"
},
+3
View File
@@ -2820,6 +2820,9 @@ importers:
'@nx/devkit':
specifier: workspace:*
version: link:../devkit
'@nx/docker':
specifier: workspace:*
version: link:../docker
'@nx/eslint':
specifier: workspace:*
version: link:../eslint