From 265868822134b56483d9d04ccda0d1e2bb8f8b5d Mon Sep 17 00:00:00 2001 From: kidkender Date: Thu, 2 Jul 2026 13:36:15 +0700 Subject: [PATCH 1/6] refactor: dedupe addon dependency injection, add plugin lifecycle hooks - Node/Python: collapse the addon->dependency map duplicated between generate() and applyAddon() into one source of truth. This also fixes a real bug: `archgen add observability --sentry` on Python silently skipped the sentry-sdk dependency (only generate() handled it). - Validate --sentry requires --observability for both create and add, on Node and Python. Previously the flag was silently ignored. - BasePlugin: add beforeGenerate/afterGenerate/beforeApplyAddon/ afterApplyAddon hooks. Node/Python/Go plugins now implement small hooks instead of overriding generate()/applyAddon() wholesale and re-deriving the dry-run guard themselves. - Add regression tests for GoPlugin (previously untested) and for the new BasePlugin hook contract. --- core/addon-requires.ts | 8 ++ core/base-plugin.ts | 29 ++++- core/errors.ts | 1 + plugins/go/index.ts | 8 +- plugins/node/index.ts | 125 ++++++++++--------- plugins/python/index.ts | 109 +++++++++-------- tests/unit/base-plugin-hooks.test.ts | 137 +++++++++++++++++++++ tests/unit/go-plugin.test.ts | 117 ++++++++++++++++++ tests/unit/node-plugin-deps.test.ts | 166 ++++++++++++++++++++++++++ tests/unit/python-plugin-deps.test.ts | 163 +++++++++++++++++++++++++ 10 files changed, 747 insertions(+), 116 deletions(-) create mode 100644 core/addon-requires.ts create mode 100644 tests/unit/base-plugin-hooks.test.ts create mode 100644 tests/unit/go-plugin.test.ts create mode 100644 tests/unit/node-plugin-deps.test.ts create mode 100644 tests/unit/python-plugin-deps.test.ts diff --git a/core/addon-requires.ts b/core/addon-requires.ts new file mode 100644 index 0000000..8bb55f8 --- /dev/null +++ b/core/addon-requires.ts @@ -0,0 +1,8 @@ +import { ArchGenError } from "./errors"; + +/** Throws when a flag is set but the addon/condition it depends on is not satisfied. */ +export function assertAddonRequires(flag: boolean | undefined, satisfied: boolean, message: string): void { + if (flag && !satisfied) { + throw new ArchGenError("ADDON_REQUIRES_MISSING", message); + } +} diff --git a/core/base-plugin.ts b/core/base-plugin.ts index c370ed4..2291dd8 100644 --- a/core/base-plugin.ts +++ b/core/base-plugin.ts @@ -56,7 +56,25 @@ export abstract class BasePlugin implements Plugin { }; } + /** Runs before any template files are processed. Throw here to abort generation (e.g. invalid option combos). */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async beforeGenerate(projectName: string, options: GenerateOptions): Promise {} + + /** Runs once after generate() finishes writing files (or previewing them, on dry-run). */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async afterGenerate(outputPath: string, options: GenerateOptions): Promise {} + + /** Runs before an addon's template files are processed. Throw here to abort (e.g. invalid option combos). */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async beforeApplyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise {} + + /** Runs once after applyAddon() finishes writing files (or previewing them, on dry-run). */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async afterApplyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise {} + async generate(projectName: string, options: GenerateOptions): Promise { + await this.beforeGenerate(projectName, options); + const outputPath = options.outputDir ?? path.join(process.cwd(), projectName); const templateBasePath = path.join(__dirname, this.relativeTemplateDir, "base"); const addonsPath = path.join(__dirname, this.relativeTemplateDir, "addons"); @@ -82,14 +100,17 @@ export abstract class BasePlugin implements Plugin { logger.info(`Would create ${files.length} files in ./${projectName}:`); files.forEach((f) => console.log(` ${f}`)); console.log(""); - return; + } else { + logger.success(`Project "${projectName}" generated successfully`); + this.showNextSteps(projectName, options); } - logger.success(`Project "${projectName}" generated successfully`); - this.showNextSteps(projectName, options); + await this.afterGenerate(outputPath, options); } async applyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { + await this.beforeApplyAddon(projectPath, addon, options); + const dryRun = options.dryRun ?? false; const addonsPath = path.join(__dirname, this.relativeTemplateDir, "addons"); @@ -121,5 +142,7 @@ export abstract class BasePlugin implements Plugin { } else { logger.success(`Addon "${addon}" applied successfully.`); } + + await this.afterApplyAddon(projectPath, addon, options); } } diff --git a/core/errors.ts b/core/errors.ts index 207b4c8..6888878 100644 --- a/core/errors.ts +++ b/core/errors.ts @@ -7,6 +7,7 @@ export type ArchGenErrorCode = | "NO_PLUGIN" | "NO_ADDON_SUPPORT" | "ADDON_FAILED" + | "ADDON_REQUIRES_MISSING" | "OUTPUT_DIR_NOT_FOUND"; export class ArchGenError extends Error { diff --git a/plugins/go/index.ts b/plugins/go/index.ts index 067bb6e..0971ff9 100644 --- a/plugins/go/index.ts +++ b/plugins/go/index.ts @@ -1,7 +1,7 @@ import path from "path"; import { BasePlugin, AddonEntry } from "../../core/base-plugin"; import { TemplateVariables } from "../../core/template-engine"; -import { AddAddonOptions, GenerateOptions, StackInfo } from "../../types"; +import { GenerateOptions, StackInfo } from "../../types"; import { goConfig } from "./config"; export class GoPlugin extends BasePlugin { @@ -60,7 +60,7 @@ export class GoPlugin extends BasePlugin { ]; } - async applyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { + protected async beforeApplyAddon(projectPath: string): Promise { try { const goModContent = await this.fs.readFile(path.join(projectPath, "go.mod")); const match = goModContent.match(/^module\s+(\S+)/m); @@ -68,7 +68,9 @@ export class GoPlugin extends BasePlugin { } catch { this._cachedModulePath = undefined; } - await super.applyAddon(projectPath, addon, options); + } + + protected async afterApplyAddon(): Promise { this._cachedModulePath = undefined; } diff --git a/plugins/node/index.ts b/plugins/node/index.ts index 7f28d6c..d290e19 100644 --- a/plugins/node/index.ts +++ b/plugins/node/index.ts @@ -4,8 +4,49 @@ import { TemplateVariables } from "../../core/template-engine"; import { AddAddonOptions, GenerateOptions, StackInfo } from "../../types"; import { nodeConfig } from "./config"; import { logger } from "../../core/logger"; +import { assertAddonRequires } from "../../core/addon-requires"; import fs from "fs-extra"; +/** Single source of truth for addon → npm dependency mapping, shared by generate() and applyAddon(). */ +const ADDON_DEPENDENCIES: Record Record> = { + websocket: () => ({ "socket.io": "^4.8.1" }), + oauth: () => ({ + "@fastify/oauth2": "^8.1.0", + "@fastify/cookie": "^11.0.2", + }), + "api-docs": () => ({ "@scalar/fastify-api-reference": "^1.25.0" }), + email: () => ({ nodemailer: "^6.9.0" }), + s3: () => ({ + "@aws-sdk/client-s3": "^3.600.0", + "@aws-sdk/s3-request-presigner": "^3.600.0", + }), + queue: () => ({ bullmq: "^5.0.0" }), + observability: (sentry) => ({ + "@opentelemetry/sdk-node": "^0.51.0", + "@opentelemetry/auto-instrumentations-node": "^0.46.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.51.0", + "prom-client": "^15.1.0", + ...(sentry ? { "@sentry/node": "^8.0.0" } : {}), + }), +}; + +function collectAddonDeps(addons: string[], sentry: boolean): Record { + return addons.reduce>((deps, addon) => { + const resolve = ADDON_DEPENDENCIES[addon]; + return resolve ? { ...deps, ...resolve(sentry) } : deps; + }, {}); +} + +async function mergePackageDeps(pkgPath: string, extraDeps: Record): Promise { + if (Object.keys(extraDeps).length === 0) return; + const pkg = (await fs.readJson(pkgPath)) as { dependencies: Record }; + pkg.dependencies = { ...pkg.dependencies, ...extraDeps }; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); +} + +const SENTRY_REQUIRES_OBSERVABILITY_MSG = + "--sentry requires the observability addon (Sentry ships as part of it)"; + export class NodePlugin extends BasePlugin { readonly name = nodeConfig.name; readonly description = nodeConfig.description; @@ -110,75 +151,41 @@ export class NodePlugin extends BasePlugin { ]; } - async applyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { - await super.applyAddon(projectPath, addon, options); - if (options.dryRun) return; + protected async beforeGenerate(_projectName: string, options: GenerateOptions): Promise { + assertAddonRequires(options.sentry, !!options.observability, SENTRY_REQUIRES_OBSERVABILITY_MSG); + } - const pkgPath = path.join(projectPath, "package.json"); - const extraDeps: Record = {}; - if (addon === "websocket") extraDeps["socket.io"] = "^4.8.1"; - if (addon === "oauth") { - extraDeps["@fastify/oauth2"] = "^8.1.0"; - extraDeps["@fastify/cookie"] = "^11.0.2"; - } - if (addon === "api-docs") extraDeps["@scalar/fastify-api-reference"] = "^1.25.0"; - if (addon === "email") extraDeps["nodemailer"] = "^6.9.0"; - if (addon === "s3") { - extraDeps["@aws-sdk/client-s3"] = "^3.600.0"; - extraDeps["@aws-sdk/s3-request-presigner"] = "^3.600.0"; - } - if (addon === "queue") extraDeps["bullmq"] = "^5.0.0"; - if (addon === "observability") { - extraDeps["@opentelemetry/sdk-node"] = "^0.51.0"; - extraDeps["@opentelemetry/auto-instrumentations-node"] = "^0.46.0"; - extraDeps["@opentelemetry/exporter-trace-otlp-http"] = "^0.51.0"; - extraDeps["prom-client"] = "^15.1.0"; - if (options.sentry) extraDeps["@sentry/node"] = "^8.0.0"; - } + protected async afterGenerate(outputPath: string, options: GenerateOptions): Promise { + if (options.dryRun) return; - if (Object.keys(extraDeps).length === 0) return; + const pkgPath = path.join(outputPath, "package.json"); + const selectedAddons = [ + options.websocket && "websocket", + options.oauth && "oauth", + options.apiDocs && "api-docs", + options.email && "email", + options.s3 && "s3", + options.queue && "queue", + options.observability && "observability", + ].filter((addon): addon is string => !!addon); - const pkg = await fs.readJson(pkgPath) as { dependencies: Record }; - pkg.dependencies = { ...pkg.dependencies, ...extraDeps }; - await fs.writeJson(pkgPath, pkg, { spaces: 2 }); - logger.info(`Updated package.json with deps: ${Object.keys(extraDeps).join(", ")}`); + const extraDeps = collectAddonDeps(selectedAddons, !!options.sentry); + await mergePackageDeps(pkgPath, extraDeps); } - async generate(projectName: string, options: GenerateOptions): Promise { - await super.generate(projectName, options); + protected async beforeApplyAddon(_projectPath: string, addon: string, options: AddAddonOptions): Promise { + assertAddonRequires(options.sentry, addon === "observability", SENTRY_REQUIRES_OBSERVABILITY_MSG); + } - // Merge addon-specific deps into package.json after all overlays are applied + protected async afterApplyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { if (options.dryRun) return; - const outputPath = options.outputDir ?? path.join(process.cwd(), projectName); - const pkgPath = path.join(outputPath, "package.json"); - - const extraDeps: Record = {}; - if (options.websocket) extraDeps["socket.io"] = "^4.8.1"; - if (options.oauth) { - extraDeps["@fastify/oauth2"] = "^8.1.0"; - extraDeps["@fastify/cookie"] = "^11.0.2"; - } - if (options.apiDocs) extraDeps["@scalar/fastify-api-reference"] = "^1.25.0"; - if (options.email) extraDeps["nodemailer"] = "^6.9.0"; - if (options.s3) { - extraDeps["@aws-sdk/client-s3"] = "^3.600.0"; - extraDeps["@aws-sdk/s3-request-presigner"] = "^3.600.0"; - } - if (options.queue) extraDeps["bullmq"] = "^5.0.0"; - if (options.observability) { - extraDeps["@opentelemetry/sdk-node"] = "^0.51.0"; - extraDeps["@opentelemetry/auto-instrumentations-node"] = "^0.46.0"; - extraDeps["@opentelemetry/exporter-trace-otlp-http"] = "^0.51.0"; - extraDeps["prom-client"] = "^15.1.0"; - if (options.sentry) extraDeps["@sentry/node"] = "^8.0.0"; - } - + const pkgPath = path.join(projectPath, "package.json"); + const extraDeps = collectAddonDeps([addon], !!options.sentry); if (Object.keys(extraDeps).length === 0) return; - const pkg = await fs.readJson(pkgPath) as { dependencies: Record }; - pkg.dependencies = { ...pkg.dependencies, ...extraDeps }; - await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + await mergePackageDeps(pkgPath, extraDeps); + logger.info(`Updated package.json with deps: ${Object.keys(extraDeps).join(", ")}`); } protected async readProjectName(projectPath: string): Promise { diff --git a/plugins/python/index.ts b/plugins/python/index.ts index aadda6d..5807f8f 100644 --- a/plugins/python/index.ts +++ b/plugins/python/index.ts @@ -5,6 +5,29 @@ import { TemplateVariables } from "../../core/template-engine"; import { AddAddonOptions, GenerateOptions, StackInfo } from "../../types"; import { pythonConfig } from "./config"; import { logger } from "../../core/logger"; +import { assertAddonRequires } from "../../core/addon-requires"; + +const SENTRY_REQUIRES_OBSERVABILITY_MSG = + "--sentry requires the observability addon (Sentry ships as part of it)"; + +/** Single source of truth for addon → pyproject.toml dependency mapping, shared by generate() and applyAddon(). */ +const ADDON_DEPENDENCIES: Record { deps: string[]; devDeps: string[] }> = { + s3: () => ({ deps: ["boto3>=1.35.0"], devDeps: [] }), + queue: () => ({ deps: ["arq>=0.26.0"], devDeps: [] }), + testing: () => ({ deps: [], devDeps: ["aiosqlite>=0.20.0"] }), + "pre-commit": () => ({ deps: [], devDeps: ["pre-commit>=3.7.0"] }), + observability: (sentry) => ({ + deps: [ + "opentelemetry-sdk>=1.24.0", + "opentelemetry-instrumentation-fastapi>=0.45b0", + "opentelemetry-exporter-otlp-proto-http>=1.24.0", + "prometheus-fastapi-instrumentator>=7.0.0", + "structlog>=24.1.0", + ...(sentry ? ["sentry-sdk[fastapi]>=2.0.0"] : []), + ], + devDeps: [], + }), +}; export class PythonPlugin extends BasePlugin { readonly name = pythonConfig.name; @@ -110,68 +133,52 @@ export class PythonPlugin extends BasePlugin { ]; } - async applyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { - await super.applyAddon(projectPath, addon, options); + protected async beforeGenerate(_projectName: string, options: GenerateOptions): Promise { + assertAddonRequires(options.sentry, !!options.observability, SENTRY_REQUIRES_OBSERVABILITY_MSG); + } + + protected async afterGenerate(outputPath: string, options: GenerateOptions): Promise { if (options.dryRun) return; - const pyprojectPath = path.join(projectPath, "pyproject.toml"); - if (addon === "s3") { - await this._injectPyprojectDep(pyprojectPath, "boto3>=1.35.0"); - logger.info("Updated pyproject.toml with boto3"); - } - if (addon === "queue") { - await this._injectPyprojectDep(pyprojectPath, "arq>=0.26.0"); - logger.info("Updated pyproject.toml with arq"); - } - if (addon === "testing") { - await this._injectPyprojectDevDeps(pyprojectPath, ["aiosqlite>=0.20.0"]); - logger.info("Updated pyproject.toml with aiosqlite"); - } - if (addon === "pre-commit") { - await this._injectPyprojectDevDeps(pyprojectPath, ["pre-commit>=3.7.0"]); - logger.info("Updated pyproject.toml with pre-commit"); - } - if (addon === "observability") { - await this._injectPyprojectDeps(pyprojectPath, [ - "opentelemetry-sdk>=1.24.0", - "opentelemetry-instrumentation-fastapi>=0.45b0", - "opentelemetry-exporter-otlp-proto-http>=1.24.0", - "prometheus-fastapi-instrumentator>=7.0.0", - "structlog>=24.1.0", - ]); - logger.info("Updated pyproject.toml with observability deps"); - } + const pyprojectPath = path.join(outputPath, "pyproject.toml"); + const selectedAddons = [ + options.s3 && "s3", + options.queue && "queue", + options.testing && "testing", + options.preCommit && "pre-commit", + options.observability && "observability", + ].filter((addon): addon is string => !!addon); + + await this._applyAddonDependencies(pyprojectPath, selectedAddons, !!options.sentry); + } + + protected async beforeApplyAddon(_projectPath: string, addon: string, options: AddAddonOptions): Promise { + assertAddonRequires(options.sentry, addon === "observability", SENTRY_REQUIRES_OBSERVABILITY_MSG); } - async generate(projectName: string, options: GenerateOptions): Promise { - await super.generate(projectName, options); + protected async afterApplyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { if (options.dryRun) return; - const outputPath = options.outputDir ?? path.join(process.cwd(), projectName); - const pyprojectPath = path.join(outputPath, "pyproject.toml"); + const pyprojectPath = path.join(projectPath, "pyproject.toml"); + await this._applyAddonDependencies(pyprojectPath, [addon], !!options.sentry); + } - if (options.s3) await this._injectPyprojectDep(pyprojectPath, "boto3>=1.35.0"); - if (options.queue) await this._injectPyprojectDep(pyprojectPath, "arq>=0.26.0"); - if (options.testing) await this._injectPyprojectDevDeps(pyprojectPath, ["aiosqlite>=0.20.0"]); - if (options.preCommit) await this._injectPyprojectDevDeps(pyprojectPath, ["pre-commit>=3.7.0"]); - if (options.observability) { - await this._injectPyprojectDeps(pyprojectPath, [ - "opentelemetry-sdk>=1.24.0", - "opentelemetry-instrumentation-fastapi>=0.45b0", - "opentelemetry-exporter-otlp-proto-http>=1.24.0", - "prometheus-fastapi-instrumentator>=7.0.0", - "structlog>=24.1.0", - ]); - if (options.sentry) { - await this._injectPyprojectDeps(pyprojectPath, ["sentry-sdk[fastapi]>=2.0.0"]); + private async _applyAddonDependencies(pyprojectPath: string, addons: string[], sentry: boolean): Promise { + for (const addon of addons) { + const resolve = ADDON_DEPENDENCIES[addon]; + if (!resolve) continue; + const { deps, devDeps } = resolve(sentry); + if (deps.length > 0) { + await this._injectPyprojectDeps(pyprojectPath, deps); + logger.info(`Updated pyproject.toml with ${addon} deps`); + } + if (devDeps.length > 0) { + await this._injectPyprojectDevDeps(pyprojectPath, devDeps); + logger.info(`Updated pyproject.toml with ${addon} dev deps`); } } } - private async _injectPyprojectDep(pyprojectPath: string, dep: string): Promise { - return this._injectPyprojectDeps(pyprojectPath, [dep]); - } - private async _injectPyprojectDeps(pyprojectPath: string, deps: string[]): Promise { try { let content = await fs.readFile(pyprojectPath, "utf8"); diff --git a/tests/unit/base-plugin-hooks.test.ts b/tests/unit/base-plugin-hooks.test.ts new file mode 100644 index 0000000..421a27d --- /dev/null +++ b/tests/unit/base-plugin-hooks.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import path from "path"; +import { AddAddonOptions, GenerateOptions } from "../../types"; + +const { mockFs, mockProcessTemplate } = vi.hoisted(() => { + const mockProcessTemplate = vi.fn().mockResolvedValue([]); + const mockFs = { + exists: vi.fn().mockReturnValue(true), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + }; + return { mockFs, mockProcessTemplate }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/template-engine", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TemplateEngine: vi.fn().mockImplementation(function (this: any) { + return { processTemplate: mockProcessTemplate }; + }), +})); + +import { BasePlugin, AddonEntry } from "../../core/base-plugin"; +import { TemplateVariables } from "../../core/template-engine"; + +class TestPlugin extends BasePlugin { + readonly name = "test-plugin"; + readonly description = "Plugin used to test BasePlugin lifecycle hooks"; + readonly addons: string[] = ["thing"]; + calls: string[] = []; + + protected get relativeTemplateDir(): string { + return "test/template"; + } + + protected getVariables(projectName: string): TemplateVariables { + return { PROJECT_NAME: projectName }; + } + + protected getAddonEntries(): AddonEntry[] { + return []; + } + + protected async readProjectName(projectPath: string): Promise { + return path.basename(projectPath); + } + + showNextSteps(): void { + // no-op for test + } + + protected async beforeGenerate(): Promise { + this.calls.push("beforeGenerate"); + } + + protected async afterGenerate(): Promise { + this.calls.push("afterGenerate"); + } + + protected async beforeApplyAddon(): Promise { + this.calls.push("beforeApplyAddon"); + } + + protected async afterApplyAddon(): Promise { + this.calls.push("afterApplyAddon"); + } +} + +describe("BasePlugin lifecycle hooks — generate()", () => { + let plugin: TestPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new TestPlugin(); + }); + + it("calls beforeGenerate before template processing and afterGenerate after", async () => { + const options: GenerateOptions = { language: "test", outputDir: "/tmp/my-app" }; + await plugin.generate("my-app", options); + expect(plugin.calls).toEqual(["beforeGenerate", "afterGenerate"]); + }); + + it("still calls afterGenerate exactly once on dry-run", async () => { + const options: GenerateOptions = { language: "test", outputDir: "/tmp/my-app", dryRun: true }; + await plugin.generate("my-app", options); + expect(plugin.calls).toEqual(["beforeGenerate", "afterGenerate"]); + }); + + it("does not call afterGenerate if beforeGenerate throws", async () => { + class ThrowingPlugin extends TestPlugin { + protected async beforeGenerate(): Promise { + this.calls.push("beforeGenerate"); + throw new Error("validation failed"); + } + } + const throwing = new ThrowingPlugin(); + const options: GenerateOptions = { language: "test", outputDir: "/tmp/my-app" }; + await expect(throwing.generate("my-app", options)).rejects.toThrow("validation failed"); + expect(throwing.calls).toEqual(["beforeGenerate"]); + expect(mockProcessTemplate).not.toHaveBeenCalled(); + }); +}); + +describe("BasePlugin lifecycle hooks — applyAddon()", () => { + let plugin: TestPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(""); + plugin = new TestPlugin(); + }); + + it("calls beforeApplyAddon before template processing and afterApplyAddon after", async () => { + const options: AddAddonOptions = { dryRun: false }; + await plugin.applyAddon("/tmp/my-app", "thing", options); + expect(plugin.calls).toEqual(["beforeApplyAddon", "afterApplyAddon"]); + }); + + it("calls beforeApplyAddon but not afterApplyAddon when addon dir is missing", async () => { + mockFs.exists.mockReturnValue(false); + const options: AddAddonOptions = { dryRun: false }; + await expect(plugin.applyAddon("/tmp/my-app", "unknown", options)).rejects.toThrow(/not found/); + expect(plugin.calls).toEqual(["beforeApplyAddon"]); + }); +}); diff --git a/tests/unit/go-plugin.test.ts b/tests/unit/go-plugin.test.ts new file mode 100644 index 0000000..52a6ab5 --- /dev/null +++ b/tests/unit/go-plugin.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import path from "path"; + +const { mockFs, mockProcessTemplate } = vi.hoisted(() => { + const mockProcessTemplate = vi.fn().mockResolvedValue([]); + const mockFs = { + exists: vi.fn().mockReturnValue(true), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + }; + return { mockFs, mockProcessTemplate }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/template-engine", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TemplateEngine: vi.fn().mockImplementation(function (this: any) { + return { processTemplate: mockProcessTemplate }; + }), +})); + +import { GoPlugin } from "../../plugins/go"; + +describe("GoPlugin", () => { + let plugin: GoPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + plugin = new GoPlugin(); + }); + + it("has correct metadata", () => { + expect(plugin.name).toBe("Go"); + expect(plugin.addons).toContain("docker"); + expect(plugin.addons).toContain("jwt"); + }); + + it("generate() uses provided modulePath in variables", async () => { + await plugin.generate("my-app", { + language: "go", + modulePath: "github.com/acme/my-app", + outputDir: "/tmp/my-app", + }); + expect(mockProcessTemplate).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ MODULE_PATH: "github.com/acme/my-app" }), + false, + ); + }); + + it("applyAddon() reads go.mod and passes its module path to the template engine", async () => { + mockFs.readFile.mockResolvedValue("module github.com/foo/bar\n\ngo 1.22\n"); + await plugin.applyAddon("/tmp/my-app", "jwt", {}); + expect(mockProcessTemplate).toHaveBeenCalledWith( + expect.any(String), + "/tmp/my-app", + expect.objectContaining({ MODULE_PATH: "github.com/foo/bar" }), + false, + ); + }); + + it("applyAddon() falls back to a default module path when go.mod cannot be read", async () => { + mockFs.readFile.mockRejectedValue(new Error("ENOENT")); + await plugin.applyAddon("/tmp/my-app", "jwt", {}); + expect(mockProcessTemplate).toHaveBeenCalledWith( + expect.any(String), + "/tmp/my-app", + expect.objectContaining({ MODULE_PATH: expect.stringContaining("my-app") }), + false, + ); + }); + + it("applyAddon() re-derives the module path fresh on each call", async () => { + mockFs.readFile.mockResolvedValue("module github.com/foo/bar\n\ngo 1.22\n"); + await plugin.applyAddon("/tmp/my-app", "jwt", {}); + expect(mockProcessTemplate).toHaveBeenLastCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ MODULE_PATH: "github.com/foo/bar" }), + false, + ); + + mockFs.readFile.mockRejectedValue(new Error("ENOENT")); + await plugin.applyAddon("/tmp/other-app", "jwt", {}); + expect(mockProcessTemplate).toHaveBeenLastCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ MODULE_PATH: expect.stringContaining("other-app") }), + false, + ); + }); + + it("applyAddon() throws when addon dir does not exist", async () => { + mockFs.exists.mockReturnValue(false); + mockFs.readFile.mockResolvedValue("module github.com/foo/bar\n"); + await expect(plugin.applyAddon("/tmp/my-app", "unknown-addon", {})).rejects.toThrow( + /not found/, + ); + }); + + it("readProjectName() derives project name from go.mod module path", async () => { + mockFs.readFile.mockResolvedValue("module github.com/foo/bar\n"); + await plugin.applyAddon("/tmp/bar", "jwt", {}); + expect(mockFs.readFile).toHaveBeenCalledWith(path.join("/tmp/bar", "go.mod")); + }); +}); diff --git a/tests/unit/node-plugin-deps.test.ts b/tests/unit/node-plugin-deps.test.ts new file mode 100644 index 0000000..31ac780 --- /dev/null +++ b/tests/unit/node-plugin-deps.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockFs, mockProcessTemplate, mockFsExtra } = vi.hoisted(() => { + const mockProcessTemplate = vi.fn().mockResolvedValue([]); + const mockFs = { + exists: vi.fn().mockReturnValue(true), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(JSON.stringify({ name: "my-project" })), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + }; + const mockFsExtra = { + readJson: vi.fn().mockResolvedValue({ dependencies: {} }), + writeJson: vi.fn().mockResolvedValue(undefined), + }; + return { mockFs, mockProcessTemplate, mockFsExtra }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/template-engine", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TemplateEngine: vi.fn().mockImplementation(function (this: any) { + return { processTemplate: mockProcessTemplate }; + }), +})); + +vi.mock("fs-extra", () => ({ default: mockFsExtra, ...mockFsExtra })); + +import { NodePlugin } from "../../plugins/node"; + +// ─── Parity between create (generate) and add (applyAddon) ────────────────── +// These lock in that both code paths resolve the exact same dependency set +// per addon, so the two lists can be collapsed into one source of truth. + +describe("NodePlugin — dependency parity between generate() and applyAddon()", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(JSON.stringify({ name: "my-project" })); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + const cases: Array<{ addon: string; flag: string; expectedDeps: string[] }> = [ + { addon: "websocket", flag: "websocket", expectedDeps: ["socket.io"] }, + { addon: "oauth", flag: "oauth", expectedDeps: ["@fastify/oauth2", "@fastify/cookie"] }, + { addon: "api-docs", flag: "apiDocs", expectedDeps: ["@scalar/fastify-api-reference"] }, + { addon: "email", flag: "email", expectedDeps: ["nodemailer"] }, + { addon: "s3", flag: "s3", expectedDeps: ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"] }, + { addon: "queue", flag: "queue", expectedDeps: ["bullmq"] }, + ]; + + for (const { addon, flag, expectedDeps } of cases) { + it(`resolves identical deps for "${addon}" via create and add`, async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + await plugin.generate("my-app", { language: "node", [flag]: true, outputDir: "/tmp/my-app" }); + const createDeps = mockFsExtra.writeJson.mock.calls.at(-1)?.[1] as + | { dependencies: Record } + | undefined; + + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(JSON.stringify({ name: "my-project" })); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + + await plugin.applyAddon("/tmp/my-project", addon, { dryRun: false }); + const addDeps = mockFsExtra.writeJson.mock.calls.at(-1)?.[1] as + | { dependencies: Record } + | undefined; + + for (const dep of expectedDeps) { + expect(createDeps?.dependencies?.[dep]).toBeDefined(); + expect(addDeps?.dependencies?.[dep]).toBeDefined(); + expect(createDeps?.dependencies?.[dep]).toBe(addDeps?.dependencies?.[dep]); + } + }); + } + + it("observability + sentry resolves identical deps via create and add", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + await plugin.generate("my-app", { + language: "node", + observability: true, + sentry: true, + outputDir: "/tmp/my-app", + }); + const createDeps = mockFsExtra.writeJson.mock.calls.at(-1)?.[1] as + | { dependencies: Record } + | undefined; + + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(JSON.stringify({ name: "my-project" })); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + + await plugin.applyAddon("/tmp/my-project", "observability", { dryRun: false, sentry: true }); + const addDeps = mockFsExtra.writeJson.mock.calls.at(-1)?.[1] as + | { dependencies: Record } + | undefined; + + expect(createDeps?.dependencies?.["@sentry/node"]).toBeDefined(); + expect(addDeps?.dependencies?.["@sentry/node"]).toBeDefined(); + }); +}); + +// ─── Addon dependency validation: sentry requires observability ───────────── + +describe("NodePlugin — sentry requires observability", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(JSON.stringify({ name: "my-project" })); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + it("generate() throws when sentry=true and observability is not selected", async () => { + await expect( + plugin.generate("my-app", { language: "node", sentry: true, outputDir: "/tmp/my-app" }), + ).rejects.toThrow(/observability/i); + }); + + it("generate() does not write any files when sentry requirement is violated", async () => { + await expect( + plugin.generate("my-app", { language: "node", sentry: true, outputDir: "/tmp/my-app" }), + ).rejects.toThrow(); + expect(mockProcessTemplate).not.toHaveBeenCalled(); + }); + + it("generate() succeeds when sentry=true and observability=true", async () => { + await expect( + plugin.generate("my-app", { + language: "node", + sentry: true, + observability: true, + outputDir: "/tmp/my-app", + }), + ).resolves.toBeUndefined(); + }); + + it("applyAddon() throws when sentry=true and addon is not observability", async () => { + await expect( + plugin.applyAddon("/tmp/my-project", "email", { dryRun: false, sentry: true }), + ).rejects.toThrow(/observability/i); + }); + + it("applyAddon() succeeds when sentry=true and addon is observability", async () => { + await expect( + plugin.applyAddon("/tmp/my-project", "observability", { dryRun: false, sentry: true }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/python-plugin-deps.test.ts b/tests/unit/python-plugin-deps.test.ts new file mode 100644 index 0000000..3c6f501 --- /dev/null +++ b/tests/unit/python-plugin-deps.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const PYPROJECT_FIXTURE = + 'name = "my-api"\n"pyjwt>=2.11.0",\n]\n[project.optional-dependencies]\ndev = [\n "mypy>=1.8.0",\n]'; + +const { mockFs, mockProcessTemplate, mockFsExtra } = vi.hoisted(() => { + const mockProcessTemplate = vi.fn().mockResolvedValue([]); + const mockFs = { + exists: vi.fn().mockReturnValue(true), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue('name = "my-api"\n'), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + }; + const mockFsExtra = { + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + return { mockFs, mockProcessTemplate, mockFsExtra }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/template-engine", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TemplateEngine: vi.fn().mockImplementation(function (this: any) { + return { processTemplate: mockProcessTemplate }; + }), +})); + +vi.mock("fs-extra", () => ({ default: mockFsExtra, ...mockFsExtra })); + +import { PythonPlugin } from "../../plugins/python"; + +// ─── Parity between create (generate) and add (applyAddon) ────────────────── + +describe("PythonPlugin — dependency parity between generate() and applyAddon()", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue('name = "my-api"\n'); + mockFsExtra.readFile.mockResolvedValue(PYPROJECT_FIXTURE); + mockFsExtra.writeFile.mockResolvedValue(undefined); + plugin = new PythonPlugin(); + }); + + const cases: Array<{ addon: string; flag: string; expectedDep: string }> = [ + { addon: "s3", flag: "s3", expectedDep: "boto3>=1.35.0" }, + { addon: "queue", flag: "queue", expectedDep: "arq>=0.26.0" }, + { addon: "testing", flag: "testing", expectedDep: "aiosqlite>=0.20.0" }, + { addon: "pre-commit", flag: "preCommit", expectedDep: "pre-commit>=3.7.0" }, + ]; + + for (const { addon, flag, expectedDep } of cases) { + it(`injects "${expectedDep}" for "${addon}" via both create and add`, async () => { + await plugin.generate("my-api", { language: "python", [flag]: true, outputDir: "/tmp/my-api" }); + const createWroteDep = mockFsExtra.writeFile.mock.calls.some((c) => (c[1] as string).includes(expectedDep)); + expect(createWroteDep).toBe(true); + + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue('name = "my-api"\n'); + mockFsExtra.readFile.mockResolvedValue(PYPROJECT_FIXTURE); + mockFsExtra.writeFile.mockResolvedValue(undefined); + + await plugin.applyAddon("/tmp/my-api", addon, { dryRun: false }); + const addWroteDep = mockFsExtra.writeFile.mock.calls.some((c) => (c[1] as string).includes(expectedDep)); + expect(addWroteDep).toBe(true); + }); + } + + it("observability + sentry injects sentry-sdk via both create and add", async () => { + await plugin.generate("my-api", { + language: "python", + observability: true, + sentry: true, + outputDir: "/tmp/my-api", + }); + const createWroteSentry = mockFsExtra.writeFile.mock.calls.some((c) => + (c[1] as string).includes("sentry-sdk[fastapi]>=2.0.0"), + ); + expect(createWroteSentry).toBe(true); + + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue('name = "my-api"\n'); + mockFsExtra.readFile.mockResolvedValue(PYPROJECT_FIXTURE); + mockFsExtra.writeFile.mockResolvedValue(undefined); + + await plugin.applyAddon("/tmp/my-api", "observability", { dryRun: false, sentry: true }); + const addWroteSentry = mockFsExtra.writeFile.mock.calls.some((c) => + (c[1] as string).includes("sentry-sdk[fastapi]>=2.0.0"), + ); + expect(addWroteSentry).toBe(true); + }); + + it("does NOT inject deps when dry-run", async () => { + await plugin.generate("my-api", { language: "python", s3: true, outputDir: "/tmp/my-api", dryRun: true }); + expect(mockFsExtra.writeFile).not.toHaveBeenCalled(); + }); +}); + +// ─── Addon dependency validation: sentry requires observability ───────────── + +describe("PythonPlugin — sentry requires observability", () => { + let plugin: PythonPlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue('name = "my-api"\n'); + mockFsExtra.readFile.mockResolvedValue(PYPROJECT_FIXTURE); + mockFsExtra.writeFile.mockResolvedValue(undefined); + plugin = new PythonPlugin(); + }); + + it("generate() throws when sentry=true and observability is not selected", async () => { + await expect( + plugin.generate("my-api", { language: "python", sentry: true, outputDir: "/tmp/my-api" }), + ).rejects.toThrow(/observability/i); + }); + + it("generate() does not write any files when sentry requirement is violated", async () => { + await expect( + plugin.generate("my-api", { language: "python", sentry: true, outputDir: "/tmp/my-api" }), + ).rejects.toThrow(); + expect(mockProcessTemplate).not.toHaveBeenCalled(); + }); + + it("generate() succeeds when sentry=true and observability=true", async () => { + await expect( + plugin.generate("my-api", { + language: "python", + sentry: true, + observability: true, + outputDir: "/tmp/my-api", + }), + ).resolves.toBeUndefined(); + }); + + it("applyAddon() throws when sentry=true and addon is not observability", async () => { + await expect( + plugin.applyAddon("/tmp/my-api", "s3", { dryRun: false, sentry: true }), + ).rejects.toThrow(/observability/i); + }); + + it("applyAddon() succeeds when sentry=true and addon is observability", async () => { + await expect( + plugin.applyAddon("/tmp/my-api", "observability", { dryRun: false, sentry: true }), + ).resolves.toBeUndefined(); + }); +}); From 280d2746c3a118e78c46942f360bcf016350197d Mon Sep 17 00:00:00 2001 From: kidkender Date: Fri, 3 Jul 2026 14:23:05 +0700 Subject: [PATCH 2/6] feat: validate GenerateOptions with Zod schema Config was a plain object with no runtime validation, so bad input (wrong database for a language, wrong flag types) failed late inside a plugin instead of fast at the boundary. Adds core/schema.ts with a language-aware database check and wires it into ArchGen.create() before any disk work happens; removes the now-redundant hardcoded VALID_*_DATABASES checks from the CLI. --- cli/command/index.ts | 20 +----------- core/archgen.ts | 4 ++- core/errors.ts | 3 +- core/schema.ts | 63 ++++++++++++++++++++++++++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 8 +++++ tests/unit/archgen.test.ts | 8 +++++ tests/unit/schema.test.ts | 59 +++++++++++++++++++++++++++++++++++ 8 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 core/schema.ts create mode 100644 tests/unit/schema.test.ts diff --git a/cli/command/index.ts b/cli/command/index.ts index c4d8569..fdde304 100644 --- a/cli/command/index.ts +++ b/cli/command/index.ts @@ -5,10 +5,6 @@ import { logger } from "../../core/logger"; import { promptMissingOptions } from "../prompts"; import { findPresetFile, loadPreset, mergePreset } from "../../core/config-preset"; -const VALID_NODE_DATABASES = ["mysql", "postgresql"]; -const VALID_PYTHON_DATABASES = ["postgresql", "sqlite"]; -const VALID_GO_DATABASES = ["postgresql"]; - export const createCommand = new Command("create") .argument("", "Name of the project") .option("-l, --language ", "Language (node|python)") @@ -53,23 +49,9 @@ export const createCommand = new Command("create") logger.debug(`Loaded preset from ${presetFile}`); } - // Database validation is deferred until after prompts resolve the language + // Database validation happens inside ArchGen.create() (Zod schema, language-aware) const finalOptions = await promptMissingOptions(projectName, options); - if (finalOptions.database) { - const lang = finalOptions.language; - const valid = - lang === "python" ? VALID_PYTHON_DATABASES : - lang === "go" ? VALID_GO_DATABASES : - VALID_NODE_DATABASES; - if (!valid.includes(finalOptions.database)) { - logger.error( - `Invalid database "${finalOptions.database}" for ${lang}. Must be one of: ${valid.join(", ")}`, - ); - process.exit(1); - } - } - const archgen = new ArchGen(); try { await archgen.create(projectName, finalOptions); diff --git a/core/archgen.ts b/core/archgen.ts index ee9f4a6..b151a14 100644 --- a/core/archgen.ts +++ b/core/archgen.ts @@ -8,6 +8,7 @@ import { logger } from "./logger"; import { createSpinner } from "./spinner"; import { registry } from "./registry"; import { getNameError } from "./validation"; +import { validateGenerateOptions } from "./schema"; import { ArchGenError } from "./errors"; export class ArchGen { @@ -17,7 +18,8 @@ export class ArchGen { this.fs = new FileSystem(); } - async create(projectName: string, options: GenerateOptions): Promise { + async create(projectName: string, rawOptions: GenerateOptions): Promise { + const options = validateGenerateOptions(rawOptions); const basePath = options.output ? path.resolve(options.output) : process.cwd(); if (options.output && !this.fs.exists(basePath)) { diff --git a/core/errors.ts b/core/errors.ts index 6888878..0b4782f 100644 --- a/core/errors.ts +++ b/core/errors.ts @@ -8,7 +8,8 @@ export type ArchGenErrorCode = | "NO_ADDON_SUPPORT" | "ADDON_FAILED" | "ADDON_REQUIRES_MISSING" - | "OUTPUT_DIR_NOT_FOUND"; + | "OUTPUT_DIR_NOT_FOUND" + | "VALIDATION_ERROR"; export class ArchGenError extends Error { code: ArchGenErrorCode; diff --git a/core/schema.ts b/core/schema.ts new file mode 100644 index 0000000..00f374e --- /dev/null +++ b/core/schema.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { GenerateOptions } from "../types"; +import { ArchGenError } from "./errors"; + +const DATABASE_BY_LANGUAGE: Record = { + node: ["mysql", "postgresql"], + python: ["postgresql", "sqlite"], + go: ["postgresql"], +}; + +export const generateOptionsSchema = z + .object({ + language: z.string().min(1, "language is required"), + docker: z.boolean().optional(), + testing: z.boolean().optional(), + ci: z.boolean().optional(), + husky: z.boolean().optional(), + websocket: z.boolean().optional(), + oauth: z.boolean().optional(), + apiDocs: z.boolean().optional(), + author: z.string().optional(), + description: z.string().optional(), + force: z.boolean().optional(), + dryRun: z.boolean().optional(), + database: z.string().optional(), + skipGit: z.boolean().optional(), + output: z.string().optional(), + claudeCode: z.boolean().optional(), + cursor: z.boolean().optional(), + email: z.boolean().optional(), + s3: z.boolean().optional(), + queue: z.boolean().optional(), + preCommit: z.boolean().optional(), + observability: z.boolean().optional(), + sentry: z.boolean().optional(), + modulePath: z.string().optional(), + jwt: z.boolean().optional(), + outputDir: z.string().optional(), + }) + .superRefine((options, ctx) => { + if (!options.database) return; + const validDatabases = DATABASE_BY_LANGUAGE[options.language]; + if (validDatabases && !validDatabases.includes(options.database)) { + ctx.addIssue({ + code: "custom", + path: ["database"], + message: `Invalid database "${options.database}" for ${options.language}. Must be one of: ${validDatabases.join(", ")}`, + }); + } + }); + +function formatZodError(error: z.ZodError): string { + return error.issues.map((issue) => `${issue.path.join(".") || "options"}: ${issue.message}`).join("; "); +} + +/** Validates GenerateOptions at the CLI/library boundary so bad input fails fast, before any file is touched. */ +export function validateGenerateOptions(options: GenerateOptions): GenerateOptions { + const result = generateOptionsSchema.safeParse(options); + if (!result.success) { + throw new ArchGenError("VALIDATION_ERROR", formatZodError(result.error)); + } + return result.data as GenerateOptions; +} diff --git a/package.json b/package.json index 1c3f041..2a6992b 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,8 @@ "commander": "^14.0.3", "fs-extra": "^11.3.3", "ora": "^9.4.0", - "prompts": "^2.4.2" + "prompts": "^2.4.2", + "zod": "^4.4.3" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3811f4..ab1d033 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: prompts: specifier: ^2.4.2 version: 2.4.2 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@aws-sdk/client-s3': specifier: ^3.1046.0 @@ -1287,6 +1290,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@aws-crypto/crc32@5.2.0': @@ -2504,3 +2510,5 @@ snapshots: xml-naming@0.1.0: {} yoctocolors@2.1.2: {} + + zod@4.4.3: {} diff --git a/tests/unit/archgen.test.ts b/tests/unit/archgen.test.ts index 03718bd..7a6d111 100644 --- a/tests/unit/archgen.test.ts +++ b/tests/unit/archgen.test.ts @@ -113,6 +113,14 @@ describe("ArchGen.create", () => { ); }); + it("rejects invalid database for the given language before touching disk", async () => { + await expect( + archgen.create("my-app", { language: "node", database: "sqlite" }), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + expect(mockFs.exists).not.toHaveBeenCalled(); + expect(mockGenerate).not.toHaveBeenCalled(); + }); + it("rejects --output dir that does not exist", async () => { mockFs.exists.mockReturnValue(false); await expect( diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts new file mode 100644 index 0000000..045f619 --- /dev/null +++ b/tests/unit/schema.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { validateGenerateOptions } from "../../core/schema"; +import { ArchGenError } from "../../core/errors"; +import { GenerateOptions } from "../../types"; + +describe("validateGenerateOptions", () => { + it("returns options unchanged when valid", () => { + const options: GenerateOptions = { language: "node", database: "mysql", docker: true }; + expect(validateGenerateOptions(options)).toMatchObject(options); + }); + + it("throws VALIDATION_ERROR when language is missing", () => { + const options = { language: "" } as GenerateOptions; + expect(() => validateGenerateOptions(options)).toThrow(ArchGenError); + expect(() => validateGenerateOptions(options)).toThrow(/language/i); + }); + + it("throws VALIDATION_ERROR with code set on the error", () => { + try { + validateGenerateOptions({ language: "" } as GenerateOptions); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ArchGenError); + expect((error as ArchGenError).code).toBe("VALIDATION_ERROR"); + } + }); + + it("throws when a boolean flag receives a non-boolean value", () => { + const options = { language: "node", docker: "yes" } as unknown as GenerateOptions; + expect(() => validateGenerateOptions(options)).toThrow(ArchGenError); + }); + + it.each([ + ["node", "mysql"], + ["node", "postgresql"], + ["python", "postgresql"], + ["python", "sqlite"], + ["go", "postgresql"], + ])("accepts %s + %s as a valid database pairing", (language, database) => { + expect(() => validateGenerateOptions({ language, database })).not.toThrow(); + }); + + it.each([ + ["node", "sqlite"], + ["python", "mysql"], + ["go", "mysql"], + ])("rejects %s + %s as an invalid database pairing", (language, database) => { + expect(() => validateGenerateOptions({ language, database })).toThrow(ArchGenError); + expect(() => validateGenerateOptions({ language, database })).toThrow(/database/i); + }); + + it("skips database validation for unknown languages (registry check happens later)", () => { + expect(() => validateGenerateOptions({ language: "ruby", database: "mongodb" })).not.toThrow(); + }); + + it("allows omitting database entirely", () => { + expect(() => validateGenerateOptions({ language: "node" })).not.toThrow(); + }); +}); From 4ccfc1b1a2575c57e1cb23afd32566bc00f2ba2a Mon Sep 17 00:00:00 2001 From: kidkender Date: Fri, 3 Jul 2026 14:28:15 +0700 Subject: [PATCH 3/6] fix: warn instead of silently ignoring a malformed .archgenrc.json loadPreset() swallowed any read/parse error and returned {} with no signal. A user with a typo'd preset file would see none of their expected defaults applied and no indication why. Now logs a warning with the file path and underlying error before falling back to {}. --- core/config-preset.ts | 4 ++- tests/unit/config-preset.test.ts | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/unit/config-preset.test.ts diff --git a/core/config-preset.ts b/core/config-preset.ts index 3681524..f288c5a 100644 --- a/core/config-preset.ts +++ b/core/config-preset.ts @@ -1,5 +1,6 @@ import fs from "fs-extra"; import path from "path"; +import { logger } from "./logger"; export interface ArchGenPreset { language?: string; @@ -37,7 +38,8 @@ export function findPresetFile(startDir: string = process.cwd()): string | null export function loadPreset(filePath: string): ArchGenPreset { try { return fs.readJsonSync(filePath) as ArchGenPreset; - } catch { + } catch (error) { + logger.warn(`Ignoring ${filePath}: ${(error as Error).message}`); return {}; } } diff --git a/tests/unit/config-preset.test.ts b/tests/unit/config-preset.test.ts new file mode 100644 index 0000000..571dd8b --- /dev/null +++ b/tests/unit/config-preset.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import os from "os"; +import path from "path"; +import fs from "fs-extra"; +import { loadPreset, mergePreset } from "../../core/config-preset"; +import { logger } from "../../core/logger"; + +describe("loadPreset", () => { + const tmpDir = path.join(os.tmpdir(), "archgen-config-preset-test"); + const presetPath = path.join(tmpDir, ".archgenrc.json"); + + beforeEach(async () => { + await fs.ensureDir(tmpDir); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + vi.restoreAllMocks(); + }); + + it("returns the parsed preset for valid JSON", async () => { + await fs.writeJson(presetPath, { language: "node", docker: true }); + expect(loadPreset(presetPath)).toEqual({ language: "node", docker: true }); + }); + + it("warns and returns {} for malformed JSON instead of failing silently", async () => { + await fs.writeFile(presetPath, "{ not valid json ,,,"); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + expect(loadPreset(presetPath)).toEqual({}); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0][0]).toContain(presetPath); + }); + + it("warns and returns {} when the file does not exist", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + expect(loadPreset(path.join(tmpDir, "missing.json"))).toEqual({}); + expect(warnSpy).toHaveBeenCalledOnce(); + }); +}); + +describe("mergePreset", () => { + it("lets CLI flags take priority over preset values", () => { + const preset = { language: "node", docker: true }; + const cliOptions = { language: "python" }; + expect(mergePreset(preset, cliOptions)).toMatchObject({ language: "python", docker: true }); + }); +}); From f3d09841f5d41484bb6f1e93db2915c395e04319 Mon Sep 17 00:00:00 2001 From: kidkender Date: Fri, 3 Jul 2026 14:44:55 +0700 Subject: [PATCH 4/6] fix(node): stop oauth/api-docs addons from silently overwriting app.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both addons shipped a full app.ts overwriting the base file, so combining --oauth and --api-docs (or add-ing one after the other) meant whichever ran last won and the other's Fastify plugin registration was silently dropped — the orphaned module files still existed on disk but were never wired up. e2e tests only checked file existence, not that app.ts actually referenced them, so this shipped unnoticed. Replaces the full-file overwrite with incremental patching against `// @addon-imports` / `// @addon-plugins` markers in the base template, mirroring the regex-based dependency injection already used for pyproject.toml. Idempotent (re-running an addon add doesn't duplicate the patch) and marker-preserving (repeated `archgen add` calls keep working). --- plugins/node/index.ts | 74 +++++++++++ .../node/template/addons/api-docs/src/app.ts | 60 --------- plugins/node/template/addons/oauth/src/app.ts | 86 ------------- plugins/node/template/base/src/app.ts | 2 + tests/integration/e2e-addons.test.ts | 8 ++ tests/unit/node-plugin-app-patch.test.ts | 121 ++++++++++++++++++ 6 files changed, 205 insertions(+), 146 deletions(-) delete mode 100644 plugins/node/template/addons/api-docs/src/app.ts delete mode 100644 plugins/node/template/addons/oauth/src/app.ts create mode 100644 tests/unit/node-plugin-app-patch.test.ts diff --git a/plugins/node/index.ts b/plugins/node/index.ts index d290e19..6b7d322 100644 --- a/plugins/node/index.ts +++ b/plugins/node/index.ts @@ -47,6 +47,53 @@ async function mergePackageDeps(pkgPath: string, extraDeps: Record = { + oauth: { + imports: [ + `import cookie from "@fastify/cookie";`, + `import oauth2 from "@fastify/oauth2";`, + `import oauthRoutes from "./modules/oauth/oauth.routes";`, + `import { oauthEnv } from "./config/oauth";`, + ].join("\n"), + plugins: [ + ` // @fastify/cookie is required by @fastify/oauth2 v8 for state cookie management`, + ` await app.register(cookie);`, + ``, + ` await app.register(oauth2, {`, + ` name: "googleOAuth2",`, + ` credentials: {`, + ` client: { id: oauthEnv.GOOGLE_CLIENT_ID, secret: oauthEnv.GOOGLE_CLIENT_SECRET },`, + ` auth: (oauth2 as any).GOOGLE_CONFIGURATION,`, + ` },`, + " callbackUri: `${oauthEnv.APP_URL}/api/v1/oauth/google/callback`,", + ` scope: ["profile", "email"],`, + ` });`, + ``, + ` await app.register(oauth2, {`, + ` name: "githubOAuth2",`, + ` credentials: {`, + ` client: { id: oauthEnv.GITHUB_CLIENT_ID, secret: oauthEnv.GITHUB_CLIENT_SECRET },`, + ` auth: (oauth2 as any).GITHUB_CONFIGURATION,`, + ` },`, + " callbackUri: `${oauthEnv.APP_URL}/api/v1/oauth/github/callback`,", + ` scope: ["user:email"],`, + ` });`, + ``, + ` await app.register(oauthRoutes, { prefix: "/api/v1/oauth" });`, + ].join("\n"), + }, + "api-docs": { + imports: `import docsPlugin from "./plugins/docs.plugin";`, + plugins: ` await app.register(docsPlugin);`, + }, +}; + export class NodePlugin extends BasePlugin { readonly name = nodeConfig.name; readonly description = nodeConfig.description; @@ -171,6 +218,31 @@ export class NodePlugin extends BasePlugin { const extraDeps = collectAddonDeps(selectedAddons, !!options.sentry); await mergePackageDeps(pkgPath, extraDeps); + + const appPath = path.join(outputPath, "src", "app.ts"); + for (const addon of selectedAddons) { + await this.patchAppFile(appPath, addon); + } + } + + /** + * Incrementally splices an addon's imports/plugin-registration into app.ts against the + * `// @addon-imports` / `// @addon-plugins` markers, instead of overwriting the whole file. + * Markers are left in place so repeated `archgen add` calls keep working, and each patch is + * skipped if already applied (idempotent re-runs). + */ + private async patchAppFile(appPath: string, addon: string): Promise { + const patch = ADDON_APP_PATCH[addon]; + if (!patch || !this.fs.exists(appPath)) return; + + const content = await this.fs.readFile(appPath); + if (content.includes(patch.imports)) return; + + const patched = content + .replace("// @addon-imports", `${patch.imports}\n// @addon-imports`) + .replace(" // @addon-plugins", `${patch.plugins}\n // @addon-plugins`); + + await this.fs.writeFile(appPath, patched); } protected async beforeApplyAddon(_projectPath: string, addon: string, options: AddAddonOptions): Promise { @@ -180,6 +252,8 @@ export class NodePlugin extends BasePlugin { protected async afterApplyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { if (options.dryRun) return; + await this.patchAppFile(path.join(projectPath, "src", "app.ts"), addon); + const pkgPath = path.join(projectPath, "package.json"); const extraDeps = collectAddonDeps([addon], !!options.sentry); if (Object.keys(extraDeps).length === 0) return; diff --git a/plugins/node/template/addons/api-docs/src/app.ts b/plugins/node/template/addons/api-docs/src/app.ts deleted file mode 100644 index 6fbe21d..0000000 --- a/plugins/node/template/addons/api-docs/src/app.ts +++ /dev/null @@ -1,60 +0,0 @@ - -import { env } from "./config/env"; -import { logger } from "./core/logger"; -import Fastify from "fastify"; -import { serializerCompiler, validatorCompiler, jsonSchemaTransform } from "fastify-type-provider-zod"; -import rateLimit from "@fastify/rate-limit"; -import cors from "@fastify/cors"; -import helmet from "@fastify/helmet"; -import swagger from "@fastify/swagger"; -import swaggerUi from "@fastify/swagger-ui"; -import routes from "./routes"; -import { errorHandler } from "./middleware/error.middleware"; -import prismaPlugin from "./plugins/prisma.plugin"; -import responsePlugin from "./plugins/response.plugin"; -import docsPlugin from "./plugins/docs.plugin"; - -export async function buildApp() { - const app = Fastify({ - loggerInstance: logger - }); - - app.setValidatorCompiler(validatorCompiler); - app.setSerializerCompiler(serializerCompiler); - - await app.register(cors, { - origin: env.CORS_ORIGIN, - methods: ["GET", "POST", "PUT", "DELETE"], - allowedHeaders: ["Content-Type", "Authorization"], - credentials: true - }); - - await app.register(helmet); - - await app.register(rateLimit, { - max: env.RATE_LIMIT_MAX, - timeWindow: env.RATE_LIMIT_WINDOW - }); - - await app.register(swagger, { - openapi: { - info: { - title: "{{PROJECT_NAME}}", - version: "1.0.0" - } - }, - transform: jsonSchemaTransform - }); - - await app.register(swaggerUi, { - routePrefix: "/docs" - }); - - await app.register(responsePlugin); - await app.register(prismaPlugin); - await app.register(docsPlugin); - await app.register(routes, { prefix: "/api/v1" }); - - app.setErrorHandler(errorHandler); - return app; -} diff --git a/plugins/node/template/addons/oauth/src/app.ts b/plugins/node/template/addons/oauth/src/app.ts deleted file mode 100644 index ec32b49..0000000 --- a/plugins/node/template/addons/oauth/src/app.ts +++ /dev/null @@ -1,86 +0,0 @@ - -import { env } from "./config/env"; -import { logger } from "./core/logger"; -import Fastify from "fastify"; -import { serializerCompiler, validatorCompiler, jsonSchemaTransform } from "fastify-type-provider-zod"; -import rateLimit from "@fastify/rate-limit"; -import cors from "@fastify/cors"; -import helmet from "@fastify/helmet"; -import swagger from "@fastify/swagger"; -import swaggerUi from "@fastify/swagger-ui"; -import cookie from "@fastify/cookie"; -import oauth2 from "@fastify/oauth2"; -import routes from "./routes"; -import { errorHandler } from "./middleware/error.middleware"; -import prismaPlugin from "./plugins/prisma.plugin"; -import responsePlugin from "./plugins/response.plugin"; -import oauthRoutes from "./modules/oauth/oauth.routes"; -import { oauthEnv } from "./config/oauth"; - -export async function buildApp() { - const app = Fastify({ - loggerInstance: logger - }); - - app.setValidatorCompiler(validatorCompiler); - app.setSerializerCompiler(serializerCompiler); - - await app.register(cors, { - origin: env.CORS_ORIGIN, - methods: ["GET", "POST", "PUT", "DELETE"], - allowedHeaders: ["Content-Type", "Authorization"], - credentials: true - }); - - await app.register(helmet); - - await app.register(rateLimit, { - max: env.RATE_LIMIT_MAX, - timeWindow: env.RATE_LIMIT_WINDOW - }); - - await app.register(swagger, { - openapi: { - info: { - title: "{{PROJECT_NAME}}", - version: "1.0.0" - } - }, - transform: jsonSchemaTransform - }); - - await app.register(swaggerUi, { - routePrefix: "/docs" - }); - - // @fastify/cookie is required by @fastify/oauth2 v8 for state cookie management - await app.register(cookie); - - await app.register(oauth2, { - name: "googleOAuth2", - credentials: { - client: { id: oauthEnv.GOOGLE_CLIENT_ID, secret: oauthEnv.GOOGLE_CLIENT_SECRET }, - auth: (oauth2 as any).GOOGLE_CONFIGURATION, - }, - callbackUri: `${oauthEnv.APP_URL}/api/v1/oauth/google/callback`, - scope: ["profile", "email"], - }); - - await app.register(oauth2, { - name: "githubOAuth2", - credentials: { - client: { id: oauthEnv.GITHUB_CLIENT_ID, secret: oauthEnv.GITHUB_CLIENT_SECRET }, - auth: (oauth2 as any).GITHUB_CONFIGURATION, - }, - callbackUri: `${oauthEnv.APP_URL}/api/v1/oauth/github/callback`, - scope: ["user:email"], - }); - - await app.register(responsePlugin); - await app.register(prismaPlugin); - await app.register(oauthRoutes, { prefix: "/api/v1/oauth" }); - await app.register(routes, { prefix: "/api/v1" }); - - app.setErrorHandler(errorHandler); - return app; -} diff --git a/plugins/node/template/base/src/app.ts b/plugins/node/template/base/src/app.ts index 2383136..8e15fe2 100644 --- a/plugins/node/template/base/src/app.ts +++ b/plugins/node/template/base/src/app.ts @@ -12,6 +12,7 @@ import routes from "./routes"; import { errorHandler } from "./middleware/error.middleware"; import prismaPlugin from "./plugins/prisma.plugin"; import responsePlugin from "./plugins/response.plugin"; +// @addon-imports export async function buildApp() { const app = Fastify({ @@ -52,6 +53,7 @@ export async function buildApp() { await app.register(responsePlugin) await app.register(prismaPlugin) + // @addon-plugins await app.register(routes, { prefix: "/api/v1" }) app.setErrorHandler(errorHandler) diff --git a/tests/integration/e2e-addons.test.ts b/tests/integration/e2e-addons.test.ts index 6a2bf21..97f5f57 100644 --- a/tests/integration/e2e-addons.test.ts +++ b/tests/integration/e2e-addons.test.ts @@ -84,6 +84,14 @@ describe("Step 1 — generate project with all new addons", () => { expect(fs.existsSync(path.join(PROJECT_DIR, "src", "plugins", "docs.plugin.ts"))).toBe(true); }); + it("app.ts wires up both oauth and api-docs (regression: addons used to overwrite each other)", () => { + const appTs = fs.readFileSync(path.join(PROJECT_DIR, "src", "app.ts"), "utf-8"); + expect(appTs).toContain("oauthRoutes"); + expect(appTs).toContain("docsPlugin"); + expect(appTs).toContain('await app.register(oauthRoutes, { prefix: "/api/v1/oauth" });'); + expect(appTs).toContain("await app.register(docsPlugin);"); + }); + it("package.json contains new addon dependencies", () => { const pkg = fs.readJsonSync(path.join(PROJECT_DIR, "package.json")) as { dependencies: Record; diff --git a/tests/unit/node-plugin-app-patch.test.ts b/tests/unit/node-plugin-app-patch.test.ts new file mode 100644 index 0000000..0588555 --- /dev/null +++ b/tests/unit/node-plugin-app-patch.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const BASE_APP_TS = ` +import { env } from "./config/env"; +import prismaPlugin from "./plugins/prisma.plugin"; +import responsePlugin from "./plugins/response.plugin"; +// @addon-imports + +export async function buildApp() { + await app.register(responsePlugin) + await app.register(prismaPlugin) + // @addon-plugins + await app.register(routes, { prefix: "/api/v1" }) + return app; +} +`; + +const { mockFs, mockProcessTemplate, mockFsExtra } = vi.hoisted(() => { + const mockProcessTemplate = vi.fn().mockResolvedValue([]); + const files = new Map(); + const mockFs = { + exists: vi.fn().mockReturnValue(true), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn(async (filePath: string, content: string) => { + files.set(filePath, content); + }), + readFile: vi.fn(async (filePath: string) => { + if (filePath.endsWith("package.json")) return JSON.stringify({ name: "my-project" }); + return files.get(filePath) ?? ""; + }), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + __files: files, + }; + const mockFsExtra = { + readJson: vi.fn().mockResolvedValue({ dependencies: {} }), + writeJson: vi.fn().mockResolvedValue(undefined), + }; + return { mockFs, mockProcessTemplate, mockFsExtra }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/template-engine", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TemplateEngine: vi.fn().mockImplementation(function (this: any) { + return { processTemplate: mockProcessTemplate }; + }), +})); + +vi.mock("fs-extra", () => ({ default: mockFsExtra, ...mockFsExtra })); + +import { NodePlugin } from "../../plugins/node"; + +describe("NodePlugin — app.ts patching (oauth + api-docs no longer clobber each other)", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockFs.__files.clear(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + function appTsPath(outputDir: string): string { + return `${outputDir}/src/app.ts`; + } + + it("wires both oauth and api-docs into app.ts when both are selected at create time", async () => { + const outputDir = "/tmp/my-app"; + mockFs.__files.set(appTsPath(outputDir), BASE_APP_TS); + + await plugin.generate("my-app", { language: "node", oauth: true, apiDocs: true, outputDir }); + + const finalAppTs = mockFs.__files.get(appTsPath(outputDir)) ?? ""; + expect(finalAppTs).toContain("oauthRoutes"); + expect(finalAppTs).toContain("docsPlugin"); + expect(finalAppTs).toContain('await app.register(oauthRoutes, { prefix: "/api/v1/oauth" });'); + expect(finalAppTs).toContain("await app.register(docsPlugin);"); + }); + + it("preserves oauth wiring when api-docs is added afterwards via applyAddon", async () => { + const projectPath = "/tmp/my-project"; + mockFs.__files.set(appTsPath(projectPath), BASE_APP_TS); + + await plugin.applyAddon(projectPath, "oauth", { dryRun: false }); + await plugin.applyAddon(projectPath, "api-docs", { dryRun: false }); + + const finalAppTs = mockFs.__files.get(appTsPath(projectPath)) ?? ""; + expect(finalAppTs).toContain("oauthRoutes"); + expect(finalAppTs).toContain("docsPlugin"); + }); + + it("does not duplicate the patch when the same addon is applied twice", async () => { + const projectPath = "/tmp/my-project"; + mockFs.__files.set(appTsPath(projectPath), BASE_APP_TS); + + await plugin.applyAddon(projectPath, "oauth", { dryRun: false }); + await plugin.applyAddon(projectPath, "oauth", { dryRun: false }); + + const finalAppTs = mockFs.__files.get(appTsPath(projectPath)) ?? ""; + const occurrences = finalAppTs.split("import oauthRoutes").length - 1; + expect(occurrences).toBe(1); + }); + + it("leaves app.ts untouched for addons with no app.ts wiring (e.g. websocket)", async () => { + const outputDir = "/tmp/my-app"; + mockFs.__files.set(appTsPath(outputDir), BASE_APP_TS); + + await plugin.generate("my-app", { language: "node", websocket: true, outputDir }); + + const finalAppTs = mockFs.__files.get(appTsPath(outputDir)) ?? ""; + expect(finalAppTs).toBe(BASE_APP_TS); + }); +}); From 152b974bddd85d2625053a78d721e9b332c650f8 Mon Sep 17 00:00:00 2001 From: kidkender Date: Fri, 3 Jul 2026 14:58:06 +0700 Subject: [PATCH 5/6] fix(node): stop database/oauth/email/s3 addons from clobbering .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same overwrite-conflict pattern as the app.ts fix: database/postgresql, oauth, email and s3 each shipped a full .env.example, so combining any two silently dropped whichever ran first (confirmed: --oauth --email --s3 together left only s3's vars, and --database postgresql lost its postgresql:// URL back to the mysql default). Replaces the full-file overwrites with incremental patching against a `# @addon-env` marker, mirroring the app.ts fix. Patch text runs through variable substitution before splicing, since splicing happens after the base file's own {{VAR}} substitution already ran (email's MAIL_FROM_NAME={{PROJECT_NAME}} was silently left unsubstituted otherwise). Also fixes tsup.config.ts: the template-copy step never cleaned dist/plugins before re-copying, so deleted/renamed template files (like the ones removed here) would have silently lingered in dist/ across builds — including published releases via prepublishOnly. --- plugins/node/index.ts | 66 +++++++++++++++++++ .../addons/database/postgresql/.env.example | 1 + .../node/template/addons/email/.env.example | 28 -------- .../node/template/addons/oauth/.env.example | 22 ------- plugins/node/template/addons/s3/.env.example | 27 -------- plugins/node/template/base/.env.example | 1 + plugins/node/template/base/src/app.ts | 66 ++++++++++--------- tests/integration/create.test.ts | 14 ++++ tsup.config.ts | 7 +- 9 files changed, 122 insertions(+), 110 deletions(-) delete mode 100644 plugins/node/template/addons/email/.env.example delete mode 100644 plugins/node/template/addons/oauth/.env.example delete mode 100644 plugins/node/template/addons/s3/.env.example diff --git a/plugins/node/index.ts b/plugins/node/index.ts index 6b7d322..703d2b2 100644 --- a/plugins/node/index.ts +++ b/plugins/node/index.ts @@ -94,6 +94,43 @@ const ADDON_APP_PATCH: Record = { }, }; +/** + * Single source of truth for addon → .env.example additions. Same overwrite-conflict class as + * ADDON_APP_PATCH above: database/postgresql, oauth, email and s3 each used to ship a full + * .env.example, so combining any two silently dropped whichever ran first. Patched incrementally + * against the `# @addon-env` marker instead. + */ +const ADDON_ENV_PATCH: Record = { + oauth: [ + `APP_URL=http://localhost:3000`, + ``, + `# Google OAuth — https://console.cloud.google.com/`, + `GOOGLE_CLIENT_ID=your-google-client-id`, + `GOOGLE_CLIENT_SECRET=your-google-client-secret`, + ``, + `# GitHub OAuth — https://github.com/settings/developers`, + `GITHUB_CLIENT_ID=your-github-client-id`, + `GITHUB_CLIENT_SECRET=your-github-client-secret`, + ].join("\n"), + email: [ + `# Email (SMTP)`, + `MAIL_HOST=smtp.gmail.com`, + `MAIL_PORT=587`, + `MAIL_USERNAME=your-email@gmail.com`, + `MAIL_PASSWORD=your-app-password`, + `MAIL_FROM_ADDRESS=your-email@gmail.com`, + `MAIL_FROM_NAME={{PROJECT_NAME}}`, + ].join("\n"), + s3: [ + `# Storage (AWS S3 / Cloudflare R2 / MinIO)`, + `S3_BUCKET=your-bucket-name`, + `S3_REGION=us-east-1`, + `# S3_ENDPOINT=https://your-r2-endpoint.r2.cloudflarestorage.com # Leave empty for AWS S3`, + `AWS_ACCESS_KEY_ID=your-access-key-id`, + `AWS_SECRET_ACCESS_KEY=your-secret-access-key`, + ].join("\n"), +}; + export class NodePlugin extends BasePlugin { readonly name = nodeConfig.name; readonly description = nodeConfig.description; @@ -220,8 +257,11 @@ export class NodePlugin extends BasePlugin { await mergePackageDeps(pkgPath, extraDeps); const appPath = path.join(outputPath, "src", "app.ts"); + const envPath = path.join(outputPath, ".env.example"); + const variables = this.getVariables(path.basename(outputPath), options); for (const addon of selectedAddons) { await this.patchAppFile(appPath, addon); + await this.patchEnvExample(envPath, addon, variables); } } @@ -245,6 +285,26 @@ export class NodePlugin extends BasePlugin { await this.fs.writeFile(appPath, patched); } + /** + * Same incremental-splice approach as patchAppFile, applied to .env.example's `# @addon-env` + * marker. Runs the patch through TemplateEngine first since splicing happens after the file's + * own {{VAR}} substitution already ran (e.g. email's MAIL_FROM_NAME={{PROJECT_NAME}}). + */ + private async patchEnvExample(envPath: string, addon: string, variables: TemplateVariables): Promise { + const rawPatch = ADDON_ENV_PATCH[addon]; + if (!rawPatch || !this.fs.exists(envPath)) return; + + const patch = Object.entries(variables).reduce( + (text, [key, value]) => text.split(`{{${key}}}`).join(value ?? ""), + rawPatch, + ); + const content = await this.fs.readFile(envPath); + if (content.includes(patch)) return; + + const patched = content.replace("# @addon-env", `${patch}\n# @addon-env`); + await this.fs.writeFile(envPath, patched); + } + protected async beforeApplyAddon(_projectPath: string, addon: string, options: AddAddonOptions): Promise { assertAddonRequires(options.sentry, addon === "observability", SENTRY_REQUIRES_OBSERVABILITY_MSG); } @@ -252,7 +312,13 @@ export class NodePlugin extends BasePlugin { protected async afterApplyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise { if (options.dryRun) return; + const projectName = path.basename(projectPath); await this.patchAppFile(path.join(projectPath, "src", "app.ts"), addon); + await this.patchEnvExample( + path.join(projectPath, ".env.example"), + addon, + this.buildApplyAddonVariables(projectName), + ); const pkgPath = path.join(projectPath, "package.json"); const extraDeps = collectAddonDeps([addon], !!options.sentry); diff --git a/plugins/node/template/addons/database/postgresql/.env.example b/plugins/node/template/addons/database/postgresql/.env.example index 5f3980f..9d7e15a 100644 --- a/plugins/node/template/addons/database/postgresql/.env.example +++ b/plugins/node/template/addons/database/postgresql/.env.example @@ -18,3 +18,4 @@ CORS_ORIGIN=* # Rate Limit RATE_LIMIT_MAX=100 RATE_LIMIT_TIMEWINDOW=60000 +# @addon-env diff --git a/plugins/node/template/addons/email/.env.example b/plugins/node/template/addons/email/.env.example deleted file mode 100644 index b0da9f0..0000000 --- a/plugins/node/template/addons/email/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# App -NODE_ENV=development -PORT=3000 - -# Database -DATABASE_URL=mysql://root:root@localhost:3306/{{PROJECT_NAME}} - -# Redis (optional) -REDIS_URL=redis://localhost:6379 - -# JWT -JWT_SECRET=your-super-secret-jwt-key-change-this-in-production -JWT_EXPIRES_IN=7d - -# CORS -CORS_ORIGIN=* - -# Rate Limit -RATE_LIMIT_MAX=100 -RATE_LIMIT_TIMEWINDOW=60000 - -# Email (SMTP) -MAIL_HOST=smtp.gmail.com -MAIL_PORT=587 -MAIL_USERNAME=your-email@gmail.com -MAIL_PASSWORD=your-app-password -MAIL_FROM_ADDRESS=your-email@gmail.com -MAIL_FROM_NAME={{PROJECT_NAME}} diff --git a/plugins/node/template/addons/oauth/.env.example b/plugins/node/template/addons/oauth/.env.example deleted file mode 100644 index 07714f5..0000000 --- a/plugins/node/template/addons/oauth/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -NODE_ENV=development -PORT=3000 - -DATABASE_URL="mysql://root:root@localhost:3306/{{PROJECT_NAME}}" -REDIS_URL="redis://localhost:6379" - -JWT_SECRET=your-super-secret-jwt-key-change-this-in-production -JWT_EXPIRES_IN=7d - -CORS_ORIGIN=* -RATE_LIMIT_MAX=100 -RATE_LIMIT_TIMEWINDOW=60000 - -APP_URL=http://localhost:3000 - -# Google OAuth — https://console.cloud.google.com/ -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret - -# GitHub OAuth — https://github.com/settings/developers -GITHUB_CLIENT_ID=your-github-client-id -GITHUB_CLIENT_SECRET=your-github-client-secret diff --git a/plugins/node/template/addons/s3/.env.example b/plugins/node/template/addons/s3/.env.example deleted file mode 100644 index 3a5e553..0000000 --- a/plugins/node/template/addons/s3/.env.example +++ /dev/null @@ -1,27 +0,0 @@ -# App -NODE_ENV=development -PORT=3000 - -# Database -DATABASE_URL=mysql://root:root@localhost:3306/{{PROJECT_NAME}} - -# Redis (optional) -REDIS_URL=redis://localhost:6379 - -# JWT -JWT_SECRET=your-super-secret-jwt-key-change-this-in-production -JWT_EXPIRES_IN=7d - -# CORS -CORS_ORIGIN=* - -# Rate Limit -RATE_LIMIT_MAX=100 -RATE_LIMIT_TIMEWINDOW=60000 - -# Storage (AWS S3 / Cloudflare R2 / MinIO) -S3_BUCKET=your-bucket-name -S3_REGION=us-east-1 -# S3_ENDPOINT=https://your-r2-endpoint.r2.cloudflarestorage.com # Leave empty for AWS S3 -AWS_ACCESS_KEY_ID=your-access-key-id -AWS_SECRET_ACCESS_KEY=your-secret-access-key diff --git a/plugins/node/template/base/.env.example b/plugins/node/template/base/.env.example index a71de9a..7d7bd7d 100644 --- a/plugins/node/template/base/.env.example +++ b/plugins/node/template/base/.env.example @@ -18,3 +18,4 @@ CORS_ORIGIN=* # Rate Limit RATE_LIMIT_MAX=100 RATE_LIMIT_TIMEWINDOW=60000 +# @addon-env diff --git a/plugins/node/template/base/src/app.ts b/plugins/node/template/base/src/app.ts index 8e15fe2..7d012ca 100644 --- a/plugins/node/template/base/src/app.ts +++ b/plugins/node/template/base/src/app.ts @@ -1,22 +1,25 @@ - -import { env } from "./config/env"; -import { logger } from "./core/logger"; -import Fastify from "fastify"; -import { serializerCompiler, validatorCompiler, jsonSchemaTransform } from "fastify-type-provider-zod"; -import rateLimit from "@fastify/rate-limit"; -import cors from "@fastify/cors"; -import helmet from "@fastify/helmet"; -import swagger from "@fastify/swagger"; -import swaggerUi from "@fastify/swagger-ui"; -import routes from "./routes"; -import { errorHandler } from "./middleware/error.middleware"; -import prismaPlugin from "./plugins/prisma.plugin"; -import responsePlugin from "./plugins/response.plugin"; +import { env } from './config/env'; +import { logger } from './core/logger'; +import Fastify from 'fastify'; +import { + serializerCompiler, + validatorCompiler, + jsonSchemaTransform, +} from 'fastify-type-provider-zod'; +import rateLimit from '@fastify/rate-limit'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import swagger from '@fastify/swagger'; +import swaggerUi from '@fastify/swagger-ui'; +import routes from './routes'; +import { errorHandler } from './middleware/error.middleware'; +import prismaPlugin from './plugins/prisma.plugin'; +import responsePlugin from './plugins/response.plugin'; // @addon-imports export async function buildApp() { const app = Fastify({ - loggerInstance: logger + loggerInstance: logger, }); app.setValidatorCompiler(validatorCompiler); @@ -24,38 +27,37 @@ export async function buildApp() { await app.register(cors, { origin: env.CORS_ORIGIN, - methods: ["GET", "POST", "PUT", "DELETE"], - allowedHeaders: ["Content-Type", "Authorization"], - credentials: true - }) - + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: true, + }); - await app.register(helmet) + await app.register(helmet); await app.register(rateLimit, { max: env.RATE_LIMIT_MAX, - timeWindow: env.RATE_LIMIT_WINDOW + timeWindow: env.RATE_LIMIT_WINDOW, }); await app.register(swagger, { openapi: { info: { - title: "{{PROJECT_NAME}}", - version: "1.0.0" - } + title: '{{PROJECT_NAME}}', + version: '1.0.0', + }, }, - transform: jsonSchemaTransform + transform: jsonSchemaTransform, }); await app.register(swaggerUi, { - routePrefix: "/docs" - }) + routePrefix: '/docs', + }); - await app.register(responsePlugin) - await app.register(prismaPlugin) + await app.register(responsePlugin); + await app.register(prismaPlugin); // @addon-plugins - await app.register(routes, { prefix: "/api/v1" }) + await app.register(routes, { prefix: '/api/v1' }); - app.setErrorHandler(errorHandler) + app.setErrorHandler(errorHandler); return app; } diff --git a/tests/integration/create.test.ts b/tests/integration/create.test.ts index 7882401..cd20f4a 100644 --- a/tests/integration/create.test.ts +++ b/tests/integration/create.test.ts @@ -170,6 +170,20 @@ describe("CLI: archgen create Node -- api-docs addon", () => { }); }); +describe("CLI: archgen create Node -- oauth + email + s3 + postgresql together", () => { + it(".env.example contains all four addons' vars without dropping any (regression)", async () => { + await fs.ensureDir(tmpDir); + run("create my-app --language node --database postgresql --oauth --email --s3 --skip-git"); + const projectDir = path.join(tmpDir, "my-app"); + const env = fs.readFileSync(path.join(projectDir, ".env.example"), "utf-8"); + expect(env).toContain("DATABASE_URL=postgresql://"); + expect(env).toContain("GOOGLE_CLIENT_ID"); + expect(env).toContain("MAIL_HOST"); + expect(env).toContain("S3_BUCKET"); + expect(env).toContain("MAIL_FROM_NAME=my-app"); + }); +}); + describe("CLI: archgen add", () => { it("adds ci addon to existing node project", async () => { await fs.ensureDir(tmpDir); diff --git a/tsup.config.ts b/tsup.config.ts index 90672e7..337add9 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,4 +1,4 @@ -import { copy } from "fs-extra"; +import { copy, remove } from "fs-extra"; import { basename, dirname, join } from "path"; import { defineConfig } from "tsup"; import { fileURLToPath } from "url"; @@ -32,6 +32,11 @@ export default defineConfig({ onSuccess: async () => { const __dirname = dirname(fileURLToPath(import.meta.url)); + // tsup's `clean` only tracks its own JS bundle outputs, not this manually-copied + // template tree — without wiping it first, files deleted/renamed in source since + // the last full rebuild silently linger in dist/ (and ship in published releases). + await remove(join(__dirname, "dist/plugins")); + await copy( join(__dirname, "plugins/node/template"), join(__dirname, "dist/plugins/node/template"), From f0ff13f25e0cb9fb9cb0974622698bbe855277d2 Mon Sep 17 00:00:00 2001 From: kidkender Date: Fri, 3 Jul 2026 15:03:42 +0700 Subject: [PATCH 6/6] test: add missing .env.example patch coverage from previous commit tests/unit/node-plugin-env-patch.test.ts was written and verified passing alongside the .env.example fix but was dropped from that commit by a git add scoping mistake (directory add missed this new file). --- CHANGELOG.md | 13 +++ package.json | 2 +- tests/unit/node-plugin-env-patch.test.ts | 126 +++++++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/unit/node-plugin-env-patch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b34080..cc79532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.4.0] - 2026-07-03 + +### Added +- **Config schema validation** — `GenerateOptions` is now validated with Zod (`core/schema.ts`) before any file is touched, including a language-aware database check (e.g. `--database sqlite` now fails fast for `--language node` instead of erroring deep inside a plugin) +- **Plugin lifecycle hooks** — `BasePlugin` gained `beforeGenerate`/`afterGenerate`/`beforeApplyAddon`/`afterApplyAddon` hooks; Node/Python plugins no longer override the monolithic `generate()`/`applyAddon()` methods directly + +### Fixed +- **Node: `--oauth` + `--api-docs` silently dropped one addon's wiring** — both addons shipped a full `src/app.ts` overwrite, so combining them (or `archgen add`-ing one after the other) meant whichever ran last won and the other's Fastify plugin registration vanished, with its module files left orphaned on disk. Replaced with incremental patching against `// @addon-imports` / `// @addon-plugins` markers. +- **Node: `--database postgresql` + `--oauth`/`--email`/`--s3` silently dropped `.env.example` vars** — same overwrite-conflict pattern, now patched incrementally against a `# @addon-env` marker. Also fixes `MAIL_FROM_NAME={{PROJECT_NAME}}` shipping unsubstituted when spliced in after the addon's variable-substitution pass. +- **`archgen add`/`create` with a malformed `.archgenrc.json`** no longer silently ignores the preset — now warns with the file path and parse error before falling back to defaults +- **Python: `add observability --sentry` didn't inject `sentry-sdk`** — dependency map was duplicated and had drifted between `generate()` and `applyAddon()`; consolidated into one source of truth for both Node and Python +- **Build: stale template files could leak into `dist/`** — `tsup.config.ts`'s template-copy step never cleaned `dist/plugins` before re-copying, so deleted/renamed template source files silently lingered across builds (including published releases via `prepublishOnly`) + ## [1.3.1] - 2026-06-16 ### Security diff --git a/package.json b/package.json index 2a6992b..e00efb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kidkender/archgen", - "version": "1.3.1", + "version": "1.4.0", "description": "Generate production-ready Node.js, Python, and Go project structures in seconds", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/tests/unit/node-plugin-env-patch.test.ts b/tests/unit/node-plugin-env-patch.test.ts new file mode 100644 index 0000000..7766923 --- /dev/null +++ b/tests/unit/node-plugin-env-patch.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const BASE_ENV_EXAMPLE = `# App +NODE_ENV=development +PORT=3000 + +# Database +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/{{PROJECT_NAME}} + +# Rate Limit +RATE_LIMIT_MAX=100 +RATE_LIMIT_TIMEWINDOW=60000 +# @addon-env +`; + +const { mockFs, mockProcessTemplate, mockFsExtra } = vi.hoisted(() => { + const mockProcessTemplate = vi.fn().mockResolvedValue([]); + const files = new Map(); + const mockFs = { + exists: vi.fn().mockReturnValue(true), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn(async (filePath: string, content: string) => { + files.set(filePath, content); + }), + readFile: vi.fn(async (filePath: string) => { + if (filePath.endsWith("package.json")) return JSON.stringify({ name: "my-project" }); + return files.get(filePath) ?? ""; + }), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + __files: files, + }; + const mockFsExtra = { + readJson: vi.fn().mockResolvedValue({ dependencies: {} }), + writeJson: vi.fn().mockResolvedValue(undefined), + }; + return { mockFs, mockProcessTemplate, mockFsExtra }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/template-engine", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TemplateEngine: vi.fn().mockImplementation(function (this: any) { + return { processTemplate: mockProcessTemplate }; + }), +})); + +vi.mock("fs-extra", () => ({ default: mockFsExtra, ...mockFsExtra })); + +import { NodePlugin } from "../../plugins/node"; + +describe("NodePlugin — .env.example patching (oauth/email/s3 no longer clobber each other)", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockFs.__files.clear(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + function envPath(dir: string): string { + return `${dir}/.env.example`; + } + + it("appends oauth, email and s3 blocks together at create time without dropping any", async () => { + const outputDir = "/tmp/my-app"; + mockFs.__files.set(envPath(outputDir), BASE_ENV_EXAMPLE); + + await plugin.generate("my-app", { + language: "node", + database: "postgresql", + oauth: true, + email: true, + s3: true, + outputDir, + }); + + const finalEnv = mockFs.__files.get(envPath(outputDir)) ?? ""; + expect(finalEnv).toContain("GOOGLE_CLIENT_ID"); + expect(finalEnv).toContain("MAIL_HOST"); + expect(finalEnv).toContain("S3_BUCKET"); + expect(finalEnv.endsWith("# @addon-env\n") || finalEnv.trimEnd().endsWith("# @addon-env")).toBe(true); + }); + + it("substitutes {{PROJECT_NAME}} in the email block after splicing", async () => { + const outputDir = "/tmp/my-app"; + mockFs.__files.set(envPath(outputDir), BASE_ENV_EXAMPLE); + + await plugin.generate("my-app", { language: "node", email: true, outputDir }); + + const finalEnv = mockFs.__files.get(envPath(outputDir)) ?? ""; + expect(finalEnv).toContain("MAIL_FROM_NAME=my-app"); + }); + + it("preserves oauth vars when email is added afterwards via applyAddon", async () => { + const projectPath = "/tmp/my-project"; + mockFs.__files.set(envPath(projectPath), BASE_ENV_EXAMPLE); + + await plugin.applyAddon(projectPath, "oauth", { dryRun: false }); + await plugin.applyAddon(projectPath, "email", { dryRun: false }); + + const finalEnv = mockFs.__files.get(envPath(projectPath)) ?? ""; + expect(finalEnv).toContain("GOOGLE_CLIENT_ID"); + expect(finalEnv).toContain("MAIL_HOST"); + }); + + it("does not duplicate the patch when the same addon is applied twice", async () => { + const projectPath = "/tmp/my-project"; + mockFs.__files.set(envPath(projectPath), BASE_ENV_EXAMPLE); + + await plugin.applyAddon(projectPath, "oauth", { dryRun: false }); + await plugin.applyAddon(projectPath, "oauth", { dryRun: false }); + + const finalEnv = mockFs.__files.get(envPath(projectPath)) ?? ""; + const occurrences = finalEnv.split("GOOGLE_CLIENT_ID=your-google-client-id").length - 1; + expect(occurrences).toBe(1); + }); +});