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/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/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/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/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/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/core/errors.ts b/core/errors.ts index 207b4c8..0b4782f 100644 --- a/core/errors.ts +++ b/core/errors.ts @@ -7,7 +7,9 @@ export type ArchGenErrorCode = | "NO_PLUGIN" | "NO_ADDON_SUPPORT" | "ADDON_FAILED" - | "OUTPUT_DIR_NOT_FOUND"; + | "ADDON_REQUIRES_MISSING" + | "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..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", @@ -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/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..703d2b2 100644 --- a/plugins/node/index.ts +++ b/plugins/node/index.ts @@ -4,8 +4,133 @@ 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)"; + +/** + * Single source of truth for addon → app.ts wiring (imports + plugin registration). + * Patched incrementally against the `// @addon-imports` / `// @addon-plugins` markers + * in the base template instead of each addon shipping its own full app.ts — two addons + * both overwriting the same file used to silently drop whichever ran first. + */ +const ADDON_APP_PATCH: 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);`, + }, +}; + +/** + * 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; @@ -110,75 +235,97 @@ export class NodePlugin 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 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"; + 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 extraDeps = collectAddonDeps(selectedAddons, !!options.sentry); + 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); } + } - if (Object.keys(extraDeps).length === 0) return; + /** + * 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 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 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); } - async generate(projectName: string, options: GenerateOptions): Promise { - await super.generate(projectName, options); + /** + * 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; - // Merge addon-specific deps into package.json after all overlays are applied - if (options.dryRun) 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 outputPath = options.outputDir ?? path.join(process.cwd(), projectName); - const pkgPath = path.join(outputPath, "package.json"); + const patched = content.replace("# @addon-env", `${patch}\n# @addon-env`); + await this.fs.writeFile(envPath, patched); + } - 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"; - } + protected async beforeApplyAddon(_projectPath: string, addon: string, options: AddAddonOptions): Promise { + assertAddonRequires(options.sentry, addon === "observability", SENTRY_REQUIRES_OBSERVABILITY_MSG); + } + 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); 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/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/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/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/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 2383136..7d012ca 100644 --- a/plugins/node/template/base/src/app.ts +++ b/plugins/node/template/base/src/app.ts @@ -1,21 +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); @@ -23,37 +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(routes, { prefix: "/api/v1" }) + await app.register(responsePlugin); + await app.register(prismaPlugin); + // @addon-plugins + await app.register(routes, { prefix: '/api/v1' }); - app.setErrorHandler(errorHandler) + app.setErrorHandler(errorHandler); return app; } 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/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/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/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/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/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/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 }); + }); +}); 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-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); + }); +}); 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/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); + }); +}); 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(); + }); +}); 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(); + }); +}); 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"),