diff --git a/e2e/docker.test.ts b/e2e/docker.test.ts new file mode 100644 index 0000000..067a40a --- /dev/null +++ b/e2e/docker.test.ts @@ -0,0 +1,45 @@ +import { SchematicTestRunner, type UnitTestTree } from "@angular-devkit/schematics/testing"; +import { resolve } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +const collectionPath = resolve(__dirname, "../dist/collection.json"); + +describe("docker schematic", () => { + const runner = new SchematicTestRunner("schematics", collectionPath); + + describe("with npm package manager", () => { + let tree: UnitTestTree; + + describe.each(["npm", "yarn", "pnpm", "bun"] as const)( + "with ${packageManager} package manager", + (packageManager) => { + beforeAll(async () => { + tree = await runner.runSchematic("docker", { + name: `${packageManager}-test-app`, + packageManager, + }); + }); + + it("should create Dockerfile", () => { + expect(tree.files).toContain(`/${packageManager}-test-app/Dockerfile`); + expect(tree.files).toContain(`/${packageManager}-test-app/.dockerignore`); + }); + + it("should create docker file with install command", () => { + const content = tree.readContent(`/${packageManager}-test-app/Dockerfile`); + expect(content).toContain(`RUN ${packageManager} install`); + }); + + it("should create docker file with build command", () => { + const content = tree.readContent(`/${packageManager}-test-app/Dockerfile`); + expect(content).toContain(`RUN ${packageManager} run build`); + }); + + it("should create docker file with start command", () => { + const content = tree.readContent(`/${packageManager}-test-app/Dockerfile`); + expect(content).toContain(`CMD ["${packageManager}", "run", "start"]`); + }); + }, + ); + }); +}); diff --git a/package.json b/package.json index 59537b6..0eba2c4 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "build": "tsc --noEmit && tsup", "postbuild": "pnpm run copy:collection && pnpm run copy:lib", "copy:collection": "cpx src/collection.json dist && cpx 'src/libs/**/schema.json' dist/libs", - "copy:lib": "cpx 'src/libs/**/files/**/*' dist/libs", + "copy:lib": "cpx 'src/libs/**/files/**/{.,}*' dist/libs", "lint": "prettier --check . && eslint --format=pretty src", "format": "prettier --write . && eslint --fix --format=pretty src", "prepack": "pnpm run build && pnpm run lint", diff --git a/src/collection.json b/src/collection.json index c015867..697a4f5 100644 --- a/src/collection.json +++ b/src/collection.json @@ -20,6 +20,11 @@ "factory": "./libs/part-main/part-main.factory#main", "description": "Create a Main file for client or server.", "schema": "./libs/part-main/schema.json" + }, + "docker": { + "factory": "./libs/docker/docker.factory#main", + "description": "Create a Dockerfile for the application.", + "schema": "./libs/docker/schema.json" } } } diff --git a/src/libs/docker/docker.factory.ts b/src/libs/docker/docker.factory.ts new file mode 100644 index 0000000..e0f43ac --- /dev/null +++ b/src/libs/docker/docker.factory.ts @@ -0,0 +1,43 @@ +import { type Path, strings } from "@angular-devkit/core"; +import { + type Rule, + type Source, + apply, + mergeWith, + move, + template, + url, +} from "@angular-devkit/schematics"; + +import { toKebabCase } from "@utils/formatting"; +import { resolvePackageName } from "@utils/name"; + +import { DEFAULT_APP_NAME, DEFAULT_PACKAGE_MANAGER } from "~/defaults"; + +import { type DockerOptions } from "./docker.options"; +import { type DockerSchema } from "./docker.schema"; + +const transform = (schema: DockerSchema): DockerOptions => { + const name = resolvePackageName(toKebabCase(schema.name?.toString() ?? DEFAULT_APP_NAME)); + + return { + name, + packageManager: schema.packageManager ?? DEFAULT_PACKAGE_MANAGER, + directory: schema.directory, + }; +}; + +const generate = (options: DockerOptions, path: string): Source => { + return apply(url("./files" as Path), [ + template({ + ...strings, + ...options, + }), + move(path), + ]); +}; + +export const main = (schema: DockerSchema): Rule => { + const options = transform(schema); + return mergeWith(generate(options, schema.directory ?? options.name)); +}; diff --git a/src/libs/docker/docker.options.d.ts b/src/libs/docker/docker.options.d.ts new file mode 100644 index 0000000..cf16c22 --- /dev/null +++ b/src/libs/docker/docker.options.d.ts @@ -0,0 +1,16 @@ +export interface DockerOptions { + /** + * NanoForge application name + */ + name: string; + + /** + * NanoForge application package manager + */ + packageManager: "npm" | "yarn" | "pnpm" | "bun"; + + /** + * Target directory for generated Docker files + */ + directory?: string; +} diff --git a/src/libs/docker/docker.schema.d.ts b/src/libs/docker/docker.schema.d.ts new file mode 100644 index 0000000..7e60641 --- /dev/null +++ b/src/libs/docker/docker.schema.d.ts @@ -0,0 +1,16 @@ +export interface DockerSchema { + /** + * NanoForge application name + */ + name: string; + + /** + * NanoForge application package manager + */ + packageManager: "npm" | "yarn" | "pnpm" | "bun"; + + /** + * Target directory for generated Docker files + */ + directory?: string; +} diff --git a/src/libs/docker/files/.dockerignore b/src/libs/docker/files/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/src/libs/docker/files/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/src/libs/docker/files/Dockerfile b/src/libs/docker/files/Dockerfile new file mode 100644 index 0000000..3085c72 --- /dev/null +++ b/src/libs/docker/files/Dockerfile @@ -0,0 +1,39 @@ +<% if (packageManager === 'bun') { %> +FROM oven/bun:1.3 +<% } else { %> +FROM node:25-alpine +<% } %> + +<% if (packageManager === 'pnpm') { %> +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN npm install -g pnpm +<% } %> + +<% if (packageManager === 'yarn') { %> +RUN npm install -g yarn +<% } %> + +WORKDIR /app +COPY package.json ./ +<% if (packageManager === 'npm') { %> +COPY package-lock.json ./ +<% } else if (packageManager === 'yarn') { %> +COPY yarn.lock ./ +<% } else if (packageManager === 'pnpm') { %> +COPY pnpm-lock.yaml ./ +<% } else if (packageManager === 'bun') { %> +COPY bun.lock ./ +<% } %> +<% if (packageManager === 'npm') { %> +RUN npm install --no-lockfile +<% } if (packageManager === 'pnpm') { %> +RUN pnpm install --frozen-lockfile --allow-build=bun +<% } else { %> +RUN <%= packageManager %> install --frozen-lockfile +<% } %> +COPY . . + +RUN <%= packageManager %> run build + +CMD ["<%= packageManager %>", "run", "start"] diff --git a/src/libs/docker/schema.json b/src/libs/docker/schema.json new file mode 100644 index 0000000..4ff6ac5 --- /dev/null +++ b/src/libs/docker/schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsNanoForgeDocker", + "title": "NanoForge Base Docker Options Schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the application", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the new project ?" + }, + "packageManager": { + "type": "string", + "enum": ["npm", "yarn", "pnpm", "bun"], + "description": "NanoForge application package manager", + "default": "npm" + }, + "directory": { + "type": "string", + "description": "NanoForge application destination directory" + } + }, + "required": ["name"] +} diff --git a/tsup.config.ts b/tsup.config.ts index 7ab058b..16ddd2f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -62,4 +62,5 @@ export default [ createLibTsupConfig("configuration"), createLibTsupConfig("part-base"), createLibTsupConfig("part-main"), + createLibTsupConfig("docker"), ];