diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd28e0..adecdd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Add Express TypeScript Postgres/Prisma support with `--db postgres --orm prisma`. +- Replace the legacy bare `--db` MongoDB shortcut with explicit `--db mongodb`. +- Generate Prisma 7 config, schema, seed script, lazy Prisma client, `/users` routes/controllers, Docker Compose, OpenAPI `/users` paths, and database-aware generated tests. +- Update interactive mode, docs, package metadata, and package smoke coverage for the Postgres/Prisma path. + ## 2.4.0 - 2026-06-24 - Add Hono framework support with TypeScript-first generated apps, Docker files, generated tests, and JSON routes for `/`, `/about`, `/contact`, and `/health`. diff --git a/README.md b/README.md index 872a084..9c5b352 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ServerGen is an npm CLI for scaffolding Node.js, Express, and Hono API projects with practical defaults: MVC-style folders, health checks, Docker files, ready-to-run npm scripts, optional Express views, and optional -Mongoose/MongoDB config. +Mongoose/MongoDB or Postgres/Prisma config. ## 30-Second Quick Start @@ -53,8 +53,9 @@ npx servergen@latest my-api | `--framework node` | Plain Node.js HTTP server with `/`, `/about`, `/contact`, and `/health`, plus MVC folders, Docker files, `.gitignore`, generated `README.md`, and `package.json`. | | `--framework hono` | TypeScript Hono API app with `src/index.ts`, `tsconfig.json`, `test/app.test.ts`, Docker files, `.gitignore`, generated `README.md`, and `package.json`. | | `--view ejs`, `pug`, or `hbs` | Adds the selected Express view template and renders it from `/`. | -| `--db` | Adds Mongoose, `config/mongoose.js`, and `MONGODB_URI` in `.env.example` for Express apps. | -| `--openapi` | Adds `docs/openapi.yaml`, a static OpenAPI 3.0 spec for generated Express and Hono routes. | +| `--db mongodb` | Adds Mongoose, `config/mongoose.js`, and `MONGODB_URI` in `.env.example` for Express apps. | +| `--typescript --db postgres --orm prisma` | Adds Prisma 7, a Postgres Docker Compose service, `prisma/schema.prisma`, `prisma.config.ts`, `src/lib/prisma.ts`, `/users` routes/controllers, generated route tests, and `DATABASE_URL` in `.env.example` for Express TypeScript apps. | +| `--openapi` | Adds `docs/openapi.yaml`, a static OpenAPI 3.0 spec for generated Express and Hono routes. Postgres/Prisma apps include `/users` paths. | Generated apps include `npm start` and `npm run dev`. Express apps also include `npm test`. TypeScript Express and Hono apps also include `npm run build`. @@ -74,7 +75,8 @@ servergen [options] [name] -n, --name name of the app to create -f, --framework framework: express | node | hono (default: "express") -v, --view view engine (express only): ejs | pug | hbs - --db add Mongoose and a MongoDB config (express only) + --db database: mongodb | postgres + --orm ORM for supported databases: prisma --openapi generate an OpenAPI spec file (express and hono) --typescript generate a TypeScript app where supported -p, --port port for the generated app (1-65535) (default: "3000") @@ -91,7 +93,8 @@ npx servergen@latest my-api npx servergen@latest my-api --framework node npx servergen@latest my-api --framework hono npx servergen@latest my-api --view ejs -npx servergen@latest my-api --db +npx servergen@latest my-api --db mongodb +npx servergen@latest my-api --typescript --db postgres --orm prisma npx servergen@latest my-api --openapi npx servergen@latest my-api --typescript npx servergen@latest my-api --port 8080 diff --git a/bin/servergen.js b/bin/servergen.js index 1ca21cb..17e3918 100644 --- a/bin/servergen.js +++ b/bin/servergen.js @@ -10,6 +10,7 @@ import path from 'path'; import { createRequire } from 'module'; import { program } from 'commander'; import { getConfig } from '../lib/config.js'; +import { normalizeDatabaseOption, normalizeOrmOption } from '../lib/database.js'; import { validateOptions } from '../lib/validator.js'; import { createGenerator } from '../index.js'; import * as fileName from '../lib/fileName.js'; @@ -34,7 +35,8 @@ program .option('-n, --name ', 'name of the app to create') .option('-f, --framework ', 'framework: express | node | hono', 'express') .option('-v, --view ', 'view engine (express only): ejs | pug | hbs') - .option('--db', 'add Mongoose and a MongoDB config (express only)') + .option('--db ', 'database: mongodb | postgres') + .option('--orm ', 'ORM for supported databases: prisma') .option('--openapi', 'generate an OpenAPI spec file (express and hono)') .option('--typescript', 'generate a TypeScript app where supported') .option('-p, --port ', 'port for the generated app (1-65535)', '3000') @@ -48,7 +50,8 @@ Examples: $ servergen my-api -f node create a Node app $ servergen my-api -f hono create a Hono app $ servergen my-api -v ejs Express app with the EJS view engine - $ servergen my-api --db Express app with Mongoose/MongoDB + $ servergen my-api --db mongodb Express app with Mongoose/MongoDB + $ servergen my-api --typescript --db postgres --orm prisma $ servergen my-api --openapi API app with docs/openapi.yaml $ servergen my-api --typescript TypeScript where supported $ servergen my-api -p 8080 use a custom port @@ -129,11 +132,15 @@ const main = async () => { const port = parseInt(resolvedOptions.port, 10) || 3000; const skipInstall = resolvedOptions.skipInstall || false; + const db = normalizeDatabaseOption(resolvedOptions.db); + const orm = normalizeOrmOption(resolvedOptions.orm); logger.debug('Parsed configuration', { appName, + db, port, framework: resolvedOptions.framework, + orm, skipInstall, typescript: resolvedOptions.typescript, }); @@ -142,7 +149,8 @@ const main = async () => { appName, framework: resolvedOptions.framework, view: resolvedOptions.view, - db: resolvedOptions.db, + db, + orm, openapi: resolvedOptions.openapi, port, skipInstall, diff --git a/docs/examples/README.md b/docs/examples/README.md index d3bfdbc..835aa04 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -18,6 +18,7 @@ The command-by-command examples use `npx --yes servergen@latest` to follow the c - [Hono app](./hono.md) - [Express views with EJS, Pug, or HBS](./views.md) - [Express app with MongoDB/Mongoose config](./mongodb.md) +- [TypeScript Express app with Postgres and Prisma](./postgres-prisma.md) - [Custom port and Docker notes](./custom-port-docker.md) ## Shared Notes @@ -28,6 +29,7 @@ The command-by-command examples use `npx --yes servergen@latest` to follow the c - Generated apps require Node.js 20 or newer. - Generation runs `npm install` unless you pass `--skip-install`. - When install is not skipped, npm also creates `node_modules/` and `package-lock.json` inside the generated app. -- Express-only options: `--view ejs|pug|hbs` and `--db`. +- Express-only options: `--view ejs|pug|hbs` and `--db mongodb`. +- Postgres/Prisma currently requires `--framework express --typescript --db postgres --orm prisma`. - Hono accepts `--typescript` and `--openapi`; the Hono preset does not support `--view` or `--db`. - Generated apps include Docker support files. Express apps also include `.env.example`; Node apps do not. diff --git a/docs/examples/hono.md b/docs/examples/hono.md index b18426c..a67ced2 100644 --- a/docs/examples/hono.md +++ b/docs/examples/hono.md @@ -27,7 +27,7 @@ Hono does not support the Express-only view and database options: ```bash npx --yes servergen@latest hello-hono --framework hono --view ejs -npx --yes servergen@latest hello-hono --framework hono --db +npx --yes servergen@latest hello-hono --framework hono --db mongodb ``` Each command fails before creating the app directory. diff --git a/docs/examples/mongodb.md b/docs/examples/mongodb.md index 1df3d57..13451df 100644 --- a/docs/examples/mongodb.md +++ b/docs/examples/mongodb.md @@ -5,10 +5,10 @@ Use this when you want an Express app with Mongoose installed and a MongoDB conn ## Command ```bash -npx --yes servergen@latest mongo-api --db +npx --yes servergen@latest mongo-api --db mongodb ``` -`--db` is Express-only. ServerGen rejects `--framework node --db`. +`--db mongodb` is Express-only. ServerGen rejects `--framework node --db mongodb`. ## What Gets Generated diff --git a/docs/examples/postgres-prisma.md b/docs/examples/postgres-prisma.md new file mode 100644 index 0000000..13aec7d --- /dev/null +++ b/docs/examples/postgres-prisma.md @@ -0,0 +1,80 @@ +# TypeScript Express App With Postgres And Prisma + +Use this when you want an Express TypeScript API with Prisma 7, a local +Postgres service, a generated `User` model, `/users` routes, tests, Docker +files, and OpenAPI docs. + +## Generate + +```sh +npx --yes servergen@latest users-api --typescript --db postgres --orm prisma --openapi +``` + +ServerGen creates `users-api/` in the current directory. + +## Generated Files + +```text +users-api/ +├── docker-compose.yml +├── prisma.config.ts +├── prisma/ +│ ├── schema.prisma +│ └── seed.ts +├── src/ +│ ├── controllers/usersController.ts +│ ├── lib/prisma.ts +│ ├── routes/index.ts +│ ├── routes/users.ts +│ └── index.ts +├── test/ +│ ├── app.test.ts +│ └── users.test.ts +├── .env.example +├── Dockerfile +├── package.json +└── tsconfig.json +``` + +## Run Locally + +```sh +cd users-api +npm install +cp .env.example .env +docker compose up -d +npm run db:migrate +npm test +npm run dev +``` + +In another terminal: + +```sh +curl http://localhost:3000/health +curl http://localhost:3000/users +curl -X POST http://localhost:3000/users \ + -H "Content-Type: application/json" \ + -d '{"email":"ada@example.com","name":"Ada Lovelace"}' +``` + +## Prisma Commands + +```sh +npm run db:generate +npm run db:migrate +npm run db:seed +npm run db:studio +``` + +## Notes + +Postgres/Prisma currently requires Express TypeScript: + +```sh +npx --yes servergen@latest users-api --framework express --typescript --db postgres --orm prisma +``` + +The generated user-route tests run when `DATABASE_URL` is configured. Without a +database URL, those tests are skipped while the generated app health and root +route tests still run. diff --git a/lib/app_generator.js b/lib/app_generator.js index 423220d..cb5a2f9 100644 --- a/lib/app_generator.js +++ b/lib/app_generator.js @@ -6,6 +6,7 @@ import path from 'path'; import { spawn } from 'child_process'; import fs from 'fs-extra'; +import { isMongoDatabase, isPostgresPrisma } from './database.js'; /** * AppGenerator class that handles the complete app generation workflow. @@ -17,7 +18,8 @@ class AppGenerator { * @param {string} options.appName - The application name. * @param {string} options.framework - The framework type ('node', 'express', or 'hono'). * @param {string|null} options.view - The view engine name. - * @param {boolean} options.db - Whether to include database configuration. + * @param {false|'mongodb'|'postgres'|string} options.db - Database preset to include. + * @param {string} options.orm - ORM to use for supported database presets. * @param {boolean} options.openapi - Whether to generate an OpenAPI spec. * @param {number} options.port - The port number for the app. * @param {boolean} options.skipInstall - Whether to skip npm install. @@ -34,6 +36,7 @@ class AppGenerator { this.framework = options.framework || 'express'; this.view = options.view; this.db = options.db; + this.orm = options.orm; this.openapi = options.openapi || false; this.port = options.port || 3000; this.skipInstall = options.skipInstall || false; @@ -57,7 +60,9 @@ class AppGenerator { async generate() { this.logger?.debug('Starting app generation', { appName: this.appName, + db: this.db, framework: this.framework, + orm: this.orm, openapi: this.openapi, port: this.port, skipInstall: this.skipInstall, @@ -110,13 +115,20 @@ class AppGenerator { { typescript: this.typescript } ); } else { + const expressOptions = { typescript: this.typescript }; + if (isPostgresPrisma(this.db, this.orm)) { + expressOptions.db = this.db; + } + if (this.orm) { + expressOptions.orm = this.orm; + } this.fileCreator.createExpressApp( this.templatesDir, this.folderDir, this.appName, this.view, - this.db, - { typescript: this.typescript } + isMongoDatabase(this.db), + expressOptions ); } } @@ -163,13 +175,20 @@ class AppGenerator { * Sets up database configuration if enabled. */ setupDatabase() { - if (this.framework === 'express' && this.db) { + if (this.framework === 'express' && isMongoDatabase(this.db)) { this.fileCreator.handleConfig( this.folderDir, this.config.paths.templates.express, { typescript: this.typescript } ); } + if (this.framework === 'express' && isPostgresPrisma(this.db, this.orm)) { + this.fileCreator.handlePostgresPrisma( + this.folderDir, + this.config.paths.templates.express, + this.appName + ); + } } /** @@ -178,11 +197,14 @@ class AppGenerator { addSupportFiles() { const typescript = this.typescript || this.framework === 'hono'; const supportOptions = { - db: Boolean(this.db), + db: this.db || false, openapi: Boolean(this.openapi), port: this.port, typescript, }; + if (this.orm) { + supportOptions.orm = this.orm; + } this.fileCreator.addGitIgnore(this.folderDir, this.templatesDir); this.fileCreator.addDockerSupport(this.folderDir, this.templatesDir, supportOptions); @@ -195,6 +217,8 @@ class AppGenerator { this.fileCreator.addOpenApiSpec(this.folderDir, { appName: this.appName, framework: this.framework, + db: this.db, + orm: this.orm, port: this.port, view: this.view, }); diff --git a/lib/constants.js b/lib/constants.js index 1b067e1..c4e0d40 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -41,8 +41,12 @@ export const DEPENDENCY_VERSIONS = { express: '^5.2.1', hono: '^4.12.27', '@hono/node-server': '^2.0.6', + '@prisma/adapter-pg': '^7.8.0', + '@prisma/client': '^7.8.0', mongoose: '^9.7.0', dotenv: '^17.4.2', + pg: '^8.22.0', + prisma: '^7.8.0', supertest: '^7.2.2', typescript: '^5.9.3', tsx: '^4.21.0', diff --git a/lib/database.js b/lib/database.js new file mode 100644 index 0000000..1d98045 --- /dev/null +++ b/lib/database.js @@ -0,0 +1,56 @@ +/** + * Database option helpers for CLI parsing, validation, and generation. + * @module lib/database + */ + +const FALSE_VALUES = new Set([false, undefined, null, '', 'none', 'no', 'n', 'false']); +const MONGO_VALUES = new Set(['mongo', 'mongodb', 'mongoose']); +const POSTGRES_VALUES = new Set(['postgres', 'postgresql']); +const PRISMA_VALUES = new Set(['prisma']); + +const normalizeValue = (value) => ( + typeof value === 'string' ? value.trim().toLowerCase() : value +); + +/** + * Normalizes supported database CLI values. + * @param {boolean|string|undefined|null} value - Raw database option value. + * @returns {false|'mongodb'|'postgres'|string} Normalized option value. + */ +export const normalizeDatabaseOption = (value) => { + const normalized = normalizeValue(value); + if (FALSE_VALUES.has(normalized)) { + return false; + } + if (MONGO_VALUES.has(normalized)) { + return 'mongodb'; + } + if (POSTGRES_VALUES.has(normalized)) { + return 'postgres'; + } + return normalized; +}; + +/** + * Normalizes supported ORM CLI values. + * @param {string|undefined|null} value - Raw ORM option value. + * @returns {undefined|'prisma'|string} Normalized option value. + */ +export const normalizeOrmOption = (value) => { + const normalized = normalizeValue(value); + if (FALSE_VALUES.has(normalized)) { + return undefined; + } + if (PRISMA_VALUES.has(normalized)) { + return 'prisma'; + } + return normalized; +}; + +export const isMongoDatabase = (value) => normalizeDatabaseOption(value) === 'mongodb'; + +export const isPostgresDatabase = (value) => normalizeDatabaseOption(value) === 'postgres'; + +export const isPostgresPrisma = (db, orm) => ( + isPostgresDatabase(db) && normalizeOrmOption(orm) === 'prisma' +); diff --git a/lib/file_generator.js b/lib/file_generator.js index a891a66..4190cce 100644 --- a/lib/file_generator.js +++ b/lib/file_generator.js @@ -7,6 +7,7 @@ import path from 'path'; import fs from 'fs-extra'; import * as fs_helper from './build_helper.js'; import { VIEW_ENGINES, VALID_VIEWS, DEPENDENCY_VERSIONS } from './constants.js'; +import { isMongoDatabase, isPostgresPrisma } from './database.js'; /** * Generates MVC folder structure (controllers, model, routes). @@ -60,6 +61,7 @@ const getGeneratedPaths = (folderDir, typescript = false) => { */ const generatePackage = (folderDir, appName, view, config, framework, options = {}) => { const typescript = Boolean(options.typescript); + const postgresPrisma = isPostgresPrisma(options.db, options.orm); const pkg = { name: appName, version: '1.0.0', @@ -76,6 +78,10 @@ const generatePackage = (folderDir, appName, view, config, framework, options = devDependencies: {}, }; + if (postgresPrisma) { + pkg.type = 'module'; + } + if (typescript) { pkg.scripts.build = 'tsc'; pkg.devDependencies.typescript = DEPENDENCY_VERSIONS.typescript; @@ -114,6 +120,17 @@ const generatePackage = (folderDir, appName, view, config, framework, options = pkg.dependencies.mongoose = DEPENDENCY_VERSIONS.mongoose; } + if (postgresPrisma) { + pkg.dependencies['@prisma/adapter-pg'] = DEPENDENCY_VERSIONS['@prisma/adapter-pg']; + pkg.dependencies['@prisma/client'] = DEPENDENCY_VERSIONS['@prisma/client']; + pkg.dependencies.pg = DEPENDENCY_VERSIONS.pg; + pkg.devDependencies.prisma = DEPENDENCY_VERSIONS.prisma; + pkg.scripts['db:generate'] = 'prisma generate'; + pkg.scripts['db:migrate'] = 'prisma migrate dev'; + pkg.scripts['db:seed'] = 'prisma db seed'; + pkg.scripts['db:studio'] = 'prisma studio'; + } + fs.writeFileSync( path.join(folderDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n' @@ -130,6 +147,7 @@ const generatePackage = (folderDir, appName, view, config, framework, options = */ const createExpressApp = (templatesDir, folderDir, appName, view, config, options = {}) => { const typescript = Boolean(options.typescript); + const postgresPrisma = isPostgresPrisma(options.db, options.orm); const appTemplatesDir = typescript ? path.join(templatesDir, 'typescript') : templatesDir; @@ -140,7 +158,9 @@ const createExpressApp = (templatesDir, folderDir, appName, view, config, option // Select the correct base index.js up front (config variant vs plain) so that // later port and view edits operate on the final file and are not overwritten. - const indexTemplate = config ? `index(config).${generatedPaths.extension}` : `index.${generatedPaths.extension}`; + const indexTemplate = postgresPrisma + ? `index(prisma).${generatedPaths.extension}` + : config ? `index(config).${generatedPaths.extension}` : `index.${generatedPaths.extension}`; const basePath = path.join(appTemplatesDir, indexTemplate); fs.ensureDirSync(generatedPaths.sourceRoot); fs_helper.buildFilewithContents( @@ -149,8 +169,12 @@ const createExpressApp = (templatesDir, folderDir, appName, view, config, option `index.${generatedPaths.extension}` ); generateMVC(folderDir, sourceTemplatesDir, { typescript }); - generatePackage(folderDir, appName, view, config, 'express', { typescript }); - generateTypeScriptConfig(folderDir, appTemplatesDir, { typescript }); + generatePackage(folderDir, appName, view, config, 'express', { + db: options.db, + orm: options.orm, + typescript, + }); + generateTypeScriptConfig(folderDir, appTemplatesDir, { postgresPrisma, typescript }); generateAppTest(folderDir, appTemplatesDir, { typescript }); console.log(typescript ? 'Generating TypeScript Express application..' : 'Generating Express application..'); }; @@ -207,7 +231,8 @@ const generateTypeScriptConfig = (folderDir, templatesDir, options = {}) => { return; } - const tsconfigPath = path.join(templatesDir, 'tsconfig.json'); + const tsconfigFile = options.postgresPrisma ? 'tsconfig.prisma.json' : 'tsconfig.json'; + const tsconfigPath = path.join(templatesDir, tsconfigFile); if (fs.existsSync(tsconfigPath)) { fs_helper.buildFilewithContents(tsconfigPath, folderDir, 'tsconfig.json'); } @@ -343,6 +368,67 @@ const handleConfig = (folderDir, templatesDir, options = {}) => { ); }; +/** + * Adds Postgres + Prisma support files and mounts the generated users routes. + * @param {string} folderDir - The application directory path. + * @param {string} templatesDir - Path to Express templates. + * @param {string} appName - The generated app name. + */ +const handlePostgresPrisma = (folderDir, templatesDir, appName) => { + const templateRoot = path.join(templatesDir, 'typescript', 'postgres-prisma'); + const copyFile = (from, to) => { + const sourcePath = path.join(templateRoot, from); + const targetPath = path.join(folderDir, to); + fs.ensureDirSync(path.dirname(targetPath)); + fs_helper.buildFilewithContents( + sourcePath, + path.dirname(targetPath), + path.basename(targetPath) + ); + }; + + console.log('Configuring Postgres with Prisma..'); + copyFile('docker-compose.yml', 'docker-compose.yml'); + copyFile('prisma.config.ts', 'prisma.config.ts'); + copyFile(path.join('prisma', 'schema.prisma'), path.join('prisma', 'schema.prisma')); + copyFile(path.join('prisma', 'seed.ts'), path.join('prisma', 'seed.ts')); + copyFile(path.join('src', 'lib', 'prisma.ts'), path.join('src', 'lib', 'prisma.ts')); + copyFile( + path.join('src', 'controllers', 'usersController.ts'), + path.join('src', 'controllers', 'usersController.ts') + ); + copyFile(path.join('src', 'routes', 'users.ts'), path.join('src', 'routes', 'users.ts')); + copyFile(path.join('test', 'users.test.ts'), path.join('test', 'users.test.ts')); + + const routesFile = path.join(folderDir, 'src', 'routes', 'index.ts'); + let routes = fs.readFileSync(routesFile, 'utf-8'); + routes = routes.replace( + "import express from 'express';", + "import express from 'express';\nimport usersRouter from './users.js';" + ); + routes = routes.replace( + '\nexport default router;', + '\nrouter.use(usersRouter);\n\nexport default router;' + ); + fs.writeFileSync(routesFile, routes, 'utf-8'); + + const appTestFile = path.join(folderDir, 'test', 'app.test.ts'); + if (fs.existsSync(appTestFile)) { + const appTest = fs + .readFileSync(appTestFile, 'utf-8') + .replace("from '../src/index';", "from '../src/index.js';"); + fs.writeFileSync(appTestFile, appTest, 'utf-8'); + } + + if (appName) { + const schemaPath = path.join(folderDir, 'prisma', 'schema.prisma'); + const schema = fs + .readFileSync(schemaPath, 'utf-8') + .replace('ServerGen Postgres API', `${appName} API`); + fs.writeFileSync(schemaPath, schema, 'utf-8'); + } +}; + /** * Adds .gitignore file to the application. * The template source is stored as 'gitignore' (no leading dot) because npm @@ -365,7 +451,9 @@ const addGitIgnore = (folderDir, templatesDir) => { * @param {number} options.port - Port configured for the generated app. */ const addDockerSupport = (folderDir, templatesDir, options = {}) => { - const dockerfilePath = options.typescript + const dockerfilePath = isPostgresPrisma(options.db, options.orm) + ? path.join(templatesDir, 'typescript', 'postgres-prisma', 'Dockerfile') + : options.typescript ? path.join(templatesDir, 'typescript', 'Dockerfile') : path.join(templatesDir, 'Dockerfile'); fs_helper.buildFilewithContents(dockerfilePath, folderDir, 'Dockerfile'); @@ -415,6 +503,33 @@ This app includes a static OpenAPI 3.0 spec at \`docs/openapi.yaml\`. Inspect it with an OpenAPI viewer, import it into API tooling, or serve it with your preferred static file middleware if you want browser-accessible docs. +`; + } + if (isPostgresPrisma(options.db, options.orm)) { + readme += ` +## Postgres + Prisma + +This app includes a Prisma 7 Postgres setup with a generated \`User\` model, +\`/users\` API routes, route tests, and a local Docker Compose database. + +Start Postgres and prepare the database: + +\`\`\`bash +cp .env.example .env +docker compose up -d +npm run db:migrate +\`\`\` + +Useful Prisma commands: + +\`\`\`bash +npm run db:generate +npm run db:seed +npm run db:studio +\`\`\` + +The generated user-route tests run when \`DATABASE_URL\` is configured. +Without a database URL, they are skipped while the basic app tests still run. `; } fs.writeFileSync(generatedReadmePath, readme, 'utf-8'); @@ -437,6 +552,7 @@ const addOpenApiSpec = (folderDir, options = {}) => { const framework = options.framework || 'express'; const frameworkName = framework === 'hono' ? 'Hono' : 'Express'; const hasView = Boolean(options.view); + const hasUsersResource = isPostgresPrisma(options.db, options.orm); const rootGetResponse = hasView ? ` description: Rendered welcome page content: @@ -522,6 +638,140 @@ const addOpenApiSpec = (folderDir, options = {}) => { contact: value: message: Contact this ServerGen app +` + : ''; + const usersPaths = hasUsersResource + ? ` /users: + get: + summary: List users + operationId: listUsers + responses: + '200': + description: Users response + content: + application/json: + schema: + type: object + required: + - users + properties: + users: + type: array + items: + $ref: '#/components/schemas/User' + post: + summary: Create a user + operationId: createUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserInput' + responses: + '201': + description: Created user + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/User' + '400': + description: Invalid request body + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Email already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /users/{id}: + get: + summary: Get a user by ID + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: User response + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/User' + '404': + $ref: '#/components/responses/NotFound' + delete: + summary: Delete a user by ID + operationId: deleteUser + parameters: + - name: id + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + '204': + description: User deleted + '404': + $ref: '#/components/responses/NotFound' +` + : ''; + const usersSchemas = hasUsersResource + ? ` User: + type: object + required: + - id + - email + - createdAt + - updatedAt + properties: + id: + type: integer + example: 1 + email: + type: string + format: email + example: user@example.com + name: + type: string + nullable: true + example: Ada Lovelace + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + CreateUserInput: + type: object + required: + - email + properties: + email: + type: string + format: email + example: user@example.com + name: + type: string + example: Ada Lovelace ` : ''; const notFoundContent = framework === 'hono' @@ -561,7 +811,7 @@ ${rootGetResponse} $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' -${postRootPath}${honoInfoPaths} /health: +${postRootPath}${honoInfoPaths}${usersPaths} /health: get: summary: Health check operationId: getHealth @@ -631,7 +881,7 @@ ${notFoundContent} status: type: string example: ok - ErrorResponse: +${usersSchemas} ErrorResponse: type: object required: - error @@ -650,14 +900,24 @@ ${notFoundContent} * @param {string} folderDir - The application directory path. * @param {string} templatesDir - Path to templates directory. * @param {Object} options - Environment file generation options. - * @param {boolean} options.db - Whether MongoDB configuration is enabled. + * @param {false|'mongodb'|'postgres'|string} options.db - Database preset for generated environment values. * @param {number} options.port - Port configured for the generated app. */ const addEnvExample = (folderDir, templatesDir, options = {}) => { const envPath = path.join(templatesDir, '.env.example'); if (fs.existsSync(envPath)) { let env = fs.readFileSync(envPath, 'utf-8'); - if (options.db === false) { + const postgresPrisma = isPostgresPrisma(options.db, options.orm); + if (postgresPrisma) { + env = env.replace( + /# Database Configuration\nMONGODB_URI=.*\n\n/, + '# Database Configuration\nDATABASE_URL="postgresql://servergen:servergen@localhost:5432/servergen?schema=public"\n\n' + ); + } + if ( + options.db === false + || (options.db && !isMongoDatabase(options.db) && !postgresPrisma) + ) { env = env.replace( /# Database Configuration\nMONGODB_URI=.*\n\n/, '' @@ -676,6 +936,7 @@ export { createNodeApp, handleViews, handleConfig, + handlePostgresPrisma, addGitIgnore, addDockerSupport, addReadme, diff --git a/lib/interactive.js b/lib/interactive.js index c9c40f2..8e70c48 100644 --- a/lib/interactive.js +++ b/lib/interactive.js @@ -137,26 +137,59 @@ export const promptForInteractiveOptions = async ({ } let db = false; + let orm; if (framework === 'express') { + const databaseChoices = typescript + ? { + none: false, + no: false, + n: false, + mongo: 'mongodb', + mongodb: 'mongodb', + mongoose: 'mongodb', + postgres: 'postgres', + postgresql: 'postgres', + } + : { + none: false, + no: false, + n: false, + mongo: 'mongodb', + mongodb: 'mongodb', + mongoose: 'mongodb', + }; + const databasePrompt = typescript + ? 'Database (none/mongodb/postgres) [none]: ' + : 'Database (none/mongodb) [none]: '; + const databaseError = typescript + ? 'Please choose none, mongodb, or postgres.' + : 'Please choose none or mongodb.'; + db = await askChoice({ question, output, - prompt: 'Database (none/mongodb) [none]: ', - choices: { - none: false, - no: false, - n: false, - mongo: true, - mongodb: true, - mongoose: true, - }, + prompt: databasePrompt, + choices: databaseChoices, defaultValue: false, - errorMessage: 'Please choose none or mongodb.', + errorMessage: databaseError, }); + + if (db === 'postgres') { + orm = await askChoice({ + question, + output, + prompt: 'ORM (prisma) [prisma]: ', + choices: { + prisma: 'prisma', + }, + defaultValue: 'prisma', + errorMessage: 'Please choose prisma.', + }); + } } let view; - if (framework === 'express') { + if (framework === 'express' && db !== 'postgres') { view = await askChoice({ question, output, @@ -190,7 +223,7 @@ export const promptForInteractiveOptions = async ({ errorMessage: 'Please answer yes or no.', }); - return { + const options = { name, framework, typescript, @@ -200,6 +233,10 @@ export const promptForInteractiveOptions = async ({ port, skipInstall: !installDependencies, }; + if (orm) { + options.orm = orm; + } + return options; }; /** diff --git a/lib/validator.js b/lib/validator.js index aab3fa6..b4ae53d 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -1,3 +1,10 @@ +import { + isMongoDatabase, + isPostgresDatabase, + normalizeDatabaseOption, + normalizeOrmOption, +} from './database.js'; + /** * Input validation utilities for CLI options. * @module lib/validator @@ -16,6 +23,8 @@ export const validateOptions = (options, validationRules) => { const framework = options.framework; const hasValidFramework = !framework || validationRules.frameworks.includes(framework); const isExpress = framework === 'express' || !framework; + const db = normalizeDatabaseOption(options.db); + const orm = normalizeOrmOption(options.orm); if (framework && !validationRules.frameworks.includes(framework)) { errors.push(`Invalid framework: ${framework}. Valid options: ${validationRules.frameworks.join(', ')}`); @@ -29,8 +38,36 @@ export const validateOptions = (options, validationRules) => { errors.push('View engines are only supported with the express framework. Use --framework express or remove --view.'); } - if (hasValidFramework && !isExpress && options.db) { - errors.push('The --db option (Mongoose) is only supported with the express framework. Use --framework express or remove --db.'); + if (db && db !== 'mongodb' && db !== 'postgres') { + errors.push(`Invalid database: ${options.db}. Valid options: mongodb, postgres.`); + } + + if (orm && orm !== 'prisma') { + errors.push(`Invalid ORM: ${options.orm}. Valid options: prisma.`); + } + + if (orm && db !== 'postgres') { + errors.push('The --orm option is only supported with --db postgres. Use --db postgres --orm prisma or remove --orm.'); + } + + if (hasValidFramework && !isExpress && isMongoDatabase(db)) { + errors.push('The --db mongodb option is only supported with the express framework. Use --framework express or remove --db.'); + } + + if (hasValidFramework && !isExpress && isPostgresDatabase(db)) { + errors.push('The --db postgres option is only supported with Express TypeScript apps. Use --framework express --typescript --db postgres --orm prisma.'); + } + + if (isPostgresDatabase(db) && orm !== 'prisma') { + errors.push('The --db postgres option requires --orm prisma.'); + } + + if (isPostgresDatabase(db) && !options.typescript) { + errors.push('The --db postgres option currently requires --typescript.'); + } + + if (isPostgresDatabase(db) && options.view) { + errors.push('The --db postgres option is API-only and does not support --view yet. Remove --view or use --db mongodb.'); } if (framework === 'node' && options.openapi) { diff --git a/package.json b/package.json index 25ddcf3..07d5eb5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "servergen", - "version": "2.4.0", - "description": "CLI that scaffolds production-ready Node.js, Express, and Hono apps with practical defaults, optional views, MongoDB, OpenAPI, and Docker support.", + "version": "2.5.0", + "description": "CLI that scaffolds production-ready Node.js, Express, and Hono apps with practical defaults, optional views, MongoDB, Postgres, Prisma, OpenAPI, and Docker support.", "type": "module", "main": "./index.js", "engines": { @@ -38,6 +38,9 @@ "mongodb", "mvc", "nodejs", + "postgres", + "postgresql", + "prisma", "scaffold", "scaffolding" ], diff --git a/packages/create-servergen/package.json b/packages/create-servergen/package.json index 6ba198b..7770935 100644 --- a/packages/create-servergen/package.json +++ b/packages/create-servergen/package.json @@ -1,6 +1,6 @@ { "name": "create-servergen", - "version": "2.4.0", + "version": "2.5.0", "description": "npm create wrapper for the servergen CLI.", "type": "module", "bin": { @@ -32,6 +32,6 @@ }, "homepage": "https://github.com/theinfosecguy/ServerGen#readme", "dependencies": { - "servergen": "^2.4.0" + "servergen": "^2.5.0" } } diff --git a/templates/express/typescript/index(prisma).ts b/templates/express/typescript/index(prisma).ts new file mode 100644 index 0000000..622ffd1 --- /dev/null +++ b/templates/express/typescript/index(prisma).ts @@ -0,0 +1,73 @@ +/** + * Express application entry point. + * @description Production-ready server with a health check, centralized error + * handling, Prisma-ready routes, and graceful shutdown. + */ + +import cors from 'cors'; +import dotenv from 'dotenv'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import router from './routes/index.js'; + +dotenv.config({ quiet: true }); + +const app = express(); +const port = Number(process.env.PORT || 3000); + +// Views + +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Health check endpoint. +app.get('/health', function (req: Request, res: Response) { + res.status(200).json({ status: 'ok' }); +}); + +app.use('/', router); + +// Centralized error-handling middleware (must be registered last). +app.use(function ( + err: Error & { status?: number }, + req: Request, + res: Response, + next: NextFunction +) { + console.error(err.stack); + res + .status(err.status || 500) + .json({ error: err.message || 'Internal Server Error' }); +}); + +export const startServer = function () { + // Bind to 0.0.0.0 so the server is reachable from outside a container. + const server = app.listen(port, '0.0.0.0', function () { + console.log('Express server started on port ' + port); + }); + + const shutdown = function (signal: string) { + console.log(signal + ' received, shutting down gracefully'); + server.close(function () { + console.log('Server closed'); + process.exit(0); + }); + }; + + process.on('SIGINT', function () { + shutdown('SIGINT'); + }); + process.on('SIGTERM', function () { + shutdown('SIGTERM'); + }); + + return server; +}; + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + startServer(); +} + +export default app; diff --git a/templates/express/typescript/postgres-prisma/Dockerfile b/templates/express/typescript/postgres-prisma/Dockerfile new file mode 100644 index 0000000..4717c78 --- /dev/null +++ b/templates/express/typescript/postgres-prisma/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine + +RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app + +WORKDIR /home/node/app + +COPY package*.json ./ + +USER node + +RUN npm install + +COPY --chown=node:node . . + +RUN npm run db:generate && npm run build && npm prune --omit=dev + +EXPOSE 3000 + +CMD [ "node", "dist/index.js" ] diff --git a/templates/express/typescript/postgres-prisma/docker-compose.yml b/templates/express/typescript/postgres-prisma/docker-compose.yml new file mode 100644 index 0000000..73ec29d --- /dev/null +++ b/templates/express/typescript/postgres-prisma/docker-compose.yml @@ -0,0 +1,19 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: servergen + POSTGRES_USER: servergen + POSTGRES_PASSWORD: servergen + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U servergen -d servergen"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + postgres-data: diff --git a/templates/express/typescript/postgres-prisma/prisma.config.ts b/templates/express/typescript/postgres-prisma/prisma.config.ts new file mode 100644 index 0000000..5ad73f1 --- /dev/null +++ b/templates/express/typescript/postgres-prisma/prisma.config.ts @@ -0,0 +1,13 @@ +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + seed: 'tsx prisma/seed.ts', + }, + datasource: { + url: process.env.DATABASE_URL ?? '', + }, +}); diff --git a/templates/express/typescript/postgres-prisma/prisma/schema.prisma b/templates/express/typescript/postgres-prisma/prisma/schema.prisma new file mode 100644 index 0000000..c4b3a2b --- /dev/null +++ b/templates/express/typescript/postgres-prisma/prisma/schema.prisma @@ -0,0 +1,17 @@ +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +/// User model generated by ServerGen Postgres API. +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/templates/express/typescript/postgres-prisma/prisma/seed.ts b/templates/express/typescript/postgres-prisma/prisma/seed.ts new file mode 100644 index 0000000..b63c5cc --- /dev/null +++ b/templates/express/typescript/postgres-prisma/prisma/seed.ts @@ -0,0 +1,18 @@ +import 'dotenv/config'; +import { disconnectPrisma, getPrisma } from '../src/lib/prisma.js'; + +const prisma = getPrisma(); + +try { + await prisma.user.upsert({ + where: { email: 'ada@example.com' }, + update: {}, + create: { + email: 'ada@example.com', + name: 'Ada Lovelace', + }, + }); + console.log('Seeded demo user: ada@example.com'); +} finally { + await disconnectPrisma(); +} diff --git a/templates/express/typescript/postgres-prisma/src/controllers/usersController.ts b/templates/express/typescript/postgres-prisma/src/controllers/usersController.ts new file mode 100644 index 0000000..f7f24b9 --- /dev/null +++ b/templates/express/typescript/postgres-prisma/src/controllers/usersController.ts @@ -0,0 +1,121 @@ +import type { NextFunction, Request, Response } from 'express'; +import { getPrisma } from '../lib/prisma.js'; + +const getErrorCode = (err: unknown) => ( + typeof err === 'object' && err !== null && 'code' in err + ? String((err as { code: unknown }).code) + : undefined +); + +const parseUserId = (value: string | string[] | undefined) => { + if (Array.isArray(value)) { + return undefined; + } + const id = Number(value); + return Number.isInteger(id) && id > 0 ? id : undefined; +}; + +const getUserPayload = (body: unknown) => { + if (typeof body !== 'object' || body === null) { + return { error: 'Request body must be a JSON object.' }; + } + + const payload = body as { email?: unknown; name?: unknown }; + const email = typeof payload.email === 'string' ? payload.email.trim() : ''; + const name = typeof payload.name === 'string' ? payload.name.trim() : undefined; + + if (!email || !email.includes('@')) { + return { error: 'A valid email is required.' }; + } + + return { + data: { + email, + name: name || undefined, + }, + }; +}; + +export const listUsers = async function ( + req: Request, + res: Response, + next: NextFunction +) { + try { + const users = await getPrisma().user.findMany({ + orderBy: { id: 'asc' }, + }); + res.status(200).json({ users }); + } catch (err) { + next(err); + } +}; + +export const getUser = async function ( + req: Request, + res: Response, + next: NextFunction +) { + const id = parseUserId(req.params.id); + if (!id) { + res.status(400).json({ error: 'A positive numeric user ID is required.' }); + return; + } + + try { + const user = await getPrisma().user.findUnique({ where: { id } }); + if (!user) { + res.status(404).json({ error: 'User not found.' }); + return; + } + res.status(200).json({ user }); + } catch (err) { + next(err); + } +}; + +export const createUser = async function ( + req: Request, + res: Response, + next: NextFunction +) { + const payload = getUserPayload(req.body); + if ('error' in payload) { + res.status(400).json({ error: payload.error }); + return; + } + + try { + const user = await getPrisma().user.create({ data: payload.data }); + res.status(201).json({ user }); + } catch (err) { + if (getErrorCode(err) === 'P2002') { + res.status(409).json({ error: 'A user with this email already exists.' }); + return; + } + next(err); + } +}; + +export const deleteUser = async function ( + req: Request, + res: Response, + next: NextFunction +) { + const id = parseUserId(req.params.id); + if (!id) { + res.status(400).json({ error: 'A positive numeric user ID is required.' }); + return; + } + + try { + await getPrisma().user.delete({ where: { id } }); + res.status(204).send(); + } catch (err) { + if (getErrorCode(err) === 'P2025') { + res.status(404).json({ error: 'User not found.' }); + return; + } + next(err); + } +}; diff --git a/templates/express/typescript/postgres-prisma/src/lib/prisma.ts b/templates/express/typescript/postgres-prisma/src/lib/prisma.ts new file mode 100644 index 0000000..eaf78e1 --- /dev/null +++ b/templates/express/typescript/postgres-prisma/src/lib/prisma.ts @@ -0,0 +1,25 @@ +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../generated/prisma/client.js'; + +let prisma: PrismaClient | undefined; + +export const getPrisma = function () { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error('DATABASE_URL is required for Prisma database access.'); + } + + if (!prisma) { + const adapter = new PrismaPg({ connectionString }); + prisma = new PrismaClient({ adapter }); + } + + return prisma; +}; + +export const disconnectPrisma = async function () { + if (prisma) { + await prisma.$disconnect(); + prisma = undefined; + } +}; diff --git a/templates/express/typescript/postgres-prisma/src/routes/users.ts b/templates/express/typescript/postgres-prisma/src/routes/users.ts new file mode 100644 index 0000000..72cc56b --- /dev/null +++ b/templates/express/typescript/postgres-prisma/src/routes/users.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import { + createUser, + deleteUser, + getUser, + listUsers, +} from '../controllers/usersController.js'; + +const router = express.Router(); + +router.get('/users', listUsers); +router.post('/users', createUser); +router.get('/users/:id', getUser); +router.delete('/users/:id', deleteUser); + +export default router; diff --git a/templates/express/typescript/postgres-prisma/test/users.test.ts b/templates/express/typescript/postgres-prisma/test/users.test.ts new file mode 100644 index 0000000..073b330 --- /dev/null +++ b/templates/express/typescript/postgres-prisma/test/users.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert'; +import { after, test } from 'node:test'; +import request from 'supertest'; +import app from '../src/index.js'; +import { disconnectPrisma, getPrisma } from '../src/lib/prisma.js'; + +const hasDatabase = Boolean(process.env.DATABASE_URL); +const dbTestOptions = { + skip: hasDatabase ? false : 'DATABASE_URL is required for Prisma route tests.', +}; + +after(async () => { + if (hasDatabase) { + await disconnectPrisma(); + } +}); + +test('POST /users creates a user and GET /users returns it', dbTestOptions, async () => { + const prisma = getPrisma(); + const email = `servergen-${Date.now()}@example.com`; + await prisma.user.deleteMany({ where: { email } }); + + const createRes = await request(app) + .post('/users') + .send({ email, name: 'ServerGen User' }); + + assert.strictEqual(createRes.statusCode, 201); + assert.strictEqual(createRes.body.user.email, email); + assert.strictEqual(createRes.body.user.name, 'ServerGen User'); + + const listRes = await request(app).get('/users'); + assert.strictEqual(listRes.statusCode, 200); + assert.ok(listRes.body.users.some((user: { email: string }) => user.email === email)); +}); + +test('GET /users/:id returns a user and DELETE /users/:id removes it', dbTestOptions, async () => { + const prisma = getPrisma(); + const email = `servergen-delete-${Date.now()}@example.com`; + await prisma.user.deleteMany({ where: { email } }); + const user = await prisma.user.create({ + data: { email, name: 'Delete Me' }, + }); + + const getRes = await request(app).get(`/users/${user.id}`); + assert.strictEqual(getRes.statusCode, 200); + assert.strictEqual(getRes.body.user.email, email); + + const deleteRes = await request(app).delete(`/users/${user.id}`); + assert.strictEqual(deleteRes.statusCode, 204); + + const missingRes = await request(app).get(`/users/${user.id}`); + assert.strictEqual(missingRes.statusCode, 404); +}); + +test('POST /users validates email', dbTestOptions, async () => { + const res = await request(app).post('/users').send({ email: 'not-an-email' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'A valid email is required.'); +}); diff --git a/templates/express/typescript/tsconfig.prisma.json b/templates/express/typescript/tsconfig.prisma.json new file mode 100644 index 0000000..efeeff9 --- /dev/null +++ b/templates/express/typescript/tsconfig.prisma.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/tests/integration/integration.test.js b/tests/integration/integration.test.js index ed2b94a..baaab76 100644 --- a/tests/integration/integration.test.js +++ b/tests/integration/integration.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; @@ -9,6 +9,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.join(__dirname, '..', '..'); let testOutput; +vi.setConfig({ testTimeout: 30000 }); + describe('CLI Integration', () => { beforeEach(() => { testOutput = fs.mkdtempSync(path.join(os.tmpdir(), 'servergen-integration-')); @@ -49,6 +51,7 @@ describe('CLI Integration', () => { expect(output).toContain('-n, --name'); expect(output).toContain('-f, --framework'); expect(output).toContain('express | node | hono'); + expect(output).toContain('--orm'); expect(output).toContain('--openapi'); expect(output).toContain('--typescript'); }); @@ -60,6 +63,7 @@ describe('CLI Integration', () => { expect(output).toContain('-f node'); expect(output).toContain('-f hono'); expect(output).toContain('--db'); + expect(output).toContain('--orm'); expect(output).toContain('--openapi'); expect(output).toContain('--typescript'); }); @@ -133,8 +137,8 @@ describe('CLI Integration', () => { ); }); - it('includes mongoose when --db flag used', () => { - runCLI('-n dbtest -f express --db --skip-install'); + it('includes mongoose when --db mongodb is used', () => { + runCLI('-n dbtest -f express --db mongodb --skip-install'); const pkgPath = path.join(testOutput, 'dbtest', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); @@ -193,7 +197,7 @@ describe('CLI Integration', () => { }); it('supports TypeScript Express with views, MongoDB and a custom port', () => { - runCLI('-n tsfull -f express --typescript --db -v ejs -p 8082 --skip-install'); + runCLI('-n tsfull -f express --typescript --db mongodb -v ejs -p 8082 --skip-install'); const appDir = path.join(testOutput, 'tsfull'); const index = fs.readFileSync(path.join(appDir, 'src', 'index.ts'), 'utf-8'); @@ -217,6 +221,54 @@ describe('CLI Integration', () => { expect(dockerfile).toContain('EXPOSE 8082'); expect(dockerfile).toContain('npm run build'); }); + + it('generates a TypeScript Express app with Postgres, Prisma, users routes, and OpenAPI', () => { + runCLI( + '-n pgtest -f express --typescript --db postgres --orm prisma --openapi -p 8083 --skip-install' + ); + + const appDir = path.join(testOutput, 'pgtest'); + const pkg = JSON.parse( + fs.readFileSync(path.join(appDir, 'package.json'), 'utf-8') + ); + const index = fs.readFileSync(path.join(appDir, 'src', 'index.ts'), 'utf-8'); + const routes = fs.readFileSync( + path.join(appDir, 'src', 'routes', 'index.ts'), + 'utf-8' + ); + const env = fs.readFileSync(path.join(appDir, '.env.example'), 'utf-8'); + const readme = fs.readFileSync(path.join(appDir, 'README.md'), 'utf-8'); + const dockerfile = fs.readFileSync(path.join(appDir, 'Dockerfile'), 'utf-8'); + const spec = fs.readFileSync(path.join(appDir, 'docs', 'openapi.yaml'), 'utf-8'); + + expect(pkg.type).toBe('module'); + expect(pkg.dependencies['@prisma/client']).toBeDefined(); + expect(pkg.dependencies['@prisma/adapter-pg']).toBeDefined(); + expect(pkg.dependencies.pg).toBeDefined(); + expect(pkg.devDependencies.prisma).toBeDefined(); + expect(pkg.scripts['db:migrate']).toBe('prisma migrate dev'); + expect(index).toContain('process.env.PORT || 8083'); + expect(index).toContain("import router from './routes/index.js'"); + expect(routes).toContain("import usersRouter from './users.js'"); + expect(routes).toContain('router.use(usersRouter);'); + expect(fs.existsSync(path.join(appDir, 'prisma.config.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'prisma', 'schema.prisma'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'prisma', 'seed.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'src', 'lib', 'prisma.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'src', 'routes', 'users.ts'))).toBe(true); + expect( + fs.existsSync(path.join(appDir, 'src', 'controllers', 'usersController.ts')) + ).toBe(true); + expect(fs.existsSync(path.join(appDir, 'test', 'users.test.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'docker-compose.yml'))).toBe(true); + expect(env).toContain('DATABASE_URL='); + expect(env).not.toContain('MONGODB_URI'); + expect(readme).toContain('Postgres + Prisma'); + expect(dockerfile).toContain('npm run db:generate'); + expect(dockerfile).toContain('EXPOSE 8083'); + expect(spec).toContain('/users:'); + expect(spec).toContain('operationId: createUser'); + }); }); describe('Node app generation', () => { @@ -408,8 +460,8 @@ describe('CLI Integration', () => { }); describe('database combined with port and views', () => { - it('keeps the custom port when --db is used', () => { - runCLI('-n dbport -f express --db -p 8080 --skip-install'); + it('keeps the custom port when --db mongodb is used', () => { + runCLI('-n dbport -f express --db mongodb -p 8080 --skip-install'); const content = fs.readFileSync( path.join(testOutput, 'dbport', 'index.js'), @@ -419,8 +471,8 @@ describe('CLI Integration', () => { expect(content).toContain("require('./config/mongoose')"); }); - it('keeps the view engine when --db is used', () => { - runCLI('-n dbview -f express --db -v ejs --skip-install'); + it('keeps the view engine when --db mongodb is used', () => { + runCLI('-n dbview -f express --db mongodb -v ejs --skip-install'); const content = fs.readFileSync( path.join(testOutput, 'dbview', 'index.js'), @@ -429,10 +481,10 @@ describe('CLI Integration', () => { expect(content).toContain('view engine'); expect(content).toContain('ejs'); expect(content).toContain("require('./config/mongoose')"); - }); + }, 15000); - it('keeps both port and view when --db, --port and --view are combined', () => { - runCLI('-n dball -f express --db -v ejs -p 8080 --skip-install'); + it('keeps both port and view when --db mongodb, --port and --view are combined', () => { + runCLI('-n dball -f express --db mongodb -v ejs -p 8080 --skip-install'); const content = fs.readFileSync( path.join(testOutput, 'dball', 'index.js'), @@ -441,7 +493,7 @@ describe('CLI Integration', () => { expect(content).toContain('8080'); expect(content).toContain('ejs'); expect(content).toContain("require('./config/mongoose')"); - }); + }, 15000); }); describe('unsupported flag combinations', () => { @@ -582,7 +634,7 @@ describe('CLI Integration', () => { describe('invalid options', () => { it('rejects --db with the node framework', () => { expectCLIError( - 'nodedb -f node --db --skip-install', + 'nodedb -f node --db mongodb --skip-install', 'only supported with the express framework' ); expect(fs.existsSync(path.join(testOutput, 'nodedb'))).toBe(false); @@ -590,12 +642,44 @@ describe('CLI Integration', () => { it('rejects --db with the hono framework', () => { expectCLIError( - 'honodb -f hono --db --skip-install', + 'honodb -f hono --db mongodb --skip-install', 'only supported with the express framework' ); expect(fs.existsSync(path.join(testOutput, 'honodb'))).toBe(false); }); + it('rejects bare --db without an explicit database value', () => { + expectCLIError( + 'baredb -f express --db --skip-install', + 'Invalid database: --skip-install' + ); + expect(fs.existsSync(path.join(testOutput, 'baredb'))).toBe(false); + }); + + it('rejects Postgres without TypeScript', () => { + expectCLIError( + 'pgjs -f express --db postgres --orm prisma --skip-install', + 'requires --typescript' + ); + expect(fs.existsSync(path.join(testOutput, 'pgjs'))).toBe(false); + }); + + it('rejects Postgres without Prisma', () => { + expectCLIError( + 'pgnoorm -f express --typescript --db postgres --skip-install', + 'requires --orm prisma' + ); + expect(fs.existsSync(path.join(testOutput, 'pgnoorm'))).toBe(false); + }); + + it('rejects Prisma without Postgres', () => { + expectCLIError( + 'ormonly -f express --orm prisma --skip-install', + 'only supported with --db postgres' + ); + expect(fs.existsSync(path.join(testOutput, 'ormonly'))).toBe(false); + }); + it('rejects an invalid framework', () => { expectCLIError('badfw -f flask --skip-install', 'Invalid framework'); }); @@ -682,9 +766,9 @@ describe('CLI Integration', () => { expect(fs.readFileSync(testFile, 'utf-8')).toContain('/health'); }); - it('uses MONGODB_URI in the mongoose config with --db', () => { + it('uses MONGODB_URI in the mongoose config with --db mongodb', () => { const mongoose = fs.readFileSync( - path.join(generateExpress('dbenvapp', '--db'), 'config', 'mongoose.js'), + path.join(generateExpress('dbenvapp', '--db mongodb'), 'config', 'mongoose.js'), 'utf-8' ); expect(mongoose).toContain('process.env.MONGODB_URI'); @@ -693,7 +777,7 @@ describe('CLI Integration', () => { it('exports a lazy MongoDB connector instead of connecting on import', () => { const mongoose = fs.readFileSync( - path.join(generateExpress('lazydbapp', '--db'), 'config', 'mongoose.js'), + path.join(generateExpress('lazydbapp', '--db mongodb'), 'config', 'mongoose.js'), 'utf-8' ); @@ -706,7 +790,7 @@ describe('CLI Integration', () => { it('calls connectDatabase only from the generated server startup path', () => { const index = fs.readFileSync( - path.join(generateExpress('dbstartapp', '--db'), 'index.js'), + path.join(generateExpress('dbstartapp', '--db mongodb'), 'index.js'), 'utf-8' ); @@ -718,13 +802,13 @@ describe('CLI Integration', () => { ); }); - it('only includes MONGODB_URI in .env.example when --db is used', () => { + it('only includes MONGODB_URI in .env.example when --db mongodb is used', () => { const plainEnv = fs.readFileSync( path.join(generateExpress('plainenvapp'), '.env.example'), 'utf-8' ); const dbEnv = fs.readFileSync( - path.join(generateExpress('dbexampleapp', '--db'), '.env.example'), + path.join(generateExpress('dbexampleapp', '--db mongodb'), '.env.example'), 'utf-8' ); diff --git a/tests/smoke/package.smoke.test.js b/tests/smoke/package.smoke.test.js index a2ead3e..93f904c 100644 --- a/tests/smoke/package.smoke.test.js +++ b/tests/smoke/package.smoke.test.js @@ -17,7 +17,7 @@ const createPackageRoot = path.join(projectRoot, 'packages', 'create-servergen') // assert a live HTTP response. This exercises exactly what a consumer gets from // `npm install servergen`, not the repo working tree. -const PORTS = { express: 5310, node: 5311, typescript: 5312, hono: 5313 }; +const PORTS = { express: 5310, node: 5311, typescript: 5312, hono: 5313, postgres: 5314 }; const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const createBinName = process.platform === 'win32' ? 'create-servergen.cmd' : 'create-servergen'; @@ -356,6 +356,94 @@ describe('published package smoke test', () => { 240000 ); + it( + 'generates, builds, tests, boots a Postgres Prisma Express app, and serves health checks', + async () => { + const port = PORTS.postgres; + const appDir = generate('smokepg', 'express', port, [ + '--typescript', + '--db', + 'postgres', + '--orm', + 'prisma', + '--openapi', + ]); + + expect(fs.existsSync(path.join(appDir, 'src', 'index.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'prisma.config.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'prisma', 'schema.prisma'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'src', 'lib', 'prisma.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'src', 'routes', 'users.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'test', 'users.test.ts'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'docker-compose.yml'))).toBe(true); + + const pkg = fs.readJsonSync(path.join(appDir, 'package.json')); + expect(pkg.type).toBe('module'); + expect(pkg.dependencies['@prisma/client']).toBeDefined(); + expect(pkg.dependencies['@prisma/adapter-pg']).toBeDefined(); + expect(pkg.dependencies.pg).toBeDefined(); + expect(pkg.devDependencies.prisma).toBeDefined(); + + const spec = fs.readFileSync(path.join(appDir, 'docs', 'openapi.yaml'), 'utf-8'); + expect(spec).toContain('/users:'); + expect(spec).toContain('/users/{id}:'); + expect(spec).toContain(`http://localhost:${port}`); + + execFileSync(npmCmd, ['install', '--no-audit', '--no-fund'], { + cwd: appDir, + encoding: 'utf-8', + timeout: 240000, + }); + + execFileSync(npmCmd, ['run', 'db:generate'], { + cwd: appDir, + encoding: 'utf-8', + env: { + ...process.env, + DATABASE_URL: 'postgresql://servergen:servergen@localhost:5432/servergen?schema=public', + }, + timeout: 120000, + }); + + execFileSync(npmCmd, ['run', 'build'], { + cwd: appDir, + encoding: 'utf-8', + timeout: 120000, + }); + + execFileSync(npmCmd, ['test'], { + cwd: appDir, + encoding: 'utf-8', + env: { ...process.env, DATABASE_URL: '' }, + timeout: 120000, + }); + + child = spawn('node', ['dist/index.js'], { + cwd: appDir, + env: { ...process.env, PORT: String(port) }, + stdio: 'ignore', + }); + + try { + const health = await waitForHttp(port, '/health'); + expect(health.status).toBe(200); + expect(health.body).toContain('"status":"ok"'); + + const root = await httpGet(port, '/'); + expect(root.status).toBe(200); + expect(root.body).toContain('Welcome to ServerGen!'); + } finally { + if (child && !child.killed) { + const { code, signal } = await stopProcess(child, 'SIGTERM'); + expect(signal).toBeNull(); + expect(code).toBe(0); + } + child = undefined; + } + }, + 360000 + ); + it( 'generates, builds, tests, boots a Hono app, serves HTTP, and includes OpenAPI docs', async () => { diff --git a/tests/unit/app_generator.test.js b/tests/unit/app_generator.test.js index f398aa0..599cde8 100644 --- a/tests/unit/app_generator.test.js +++ b/tests/unit/app_generator.test.js @@ -17,6 +17,7 @@ function makeDeps() { createExpressApp: vi.fn(), handleViews: vi.fn(), handleConfig: vi.fn(), + handlePostgresPrisma: vi.fn(), addGitIgnore: vi.fn(), addDockerSupport: vi.fn(), addReadme: vi.fn(), @@ -182,7 +183,7 @@ describe('AppGenerator', () => { appName: 'myapp', framework: 'express', view: 'ejs', - db: true, + db: 'mongodb', skipInstall: true, config, }, @@ -220,11 +221,37 @@ describe('AppGenerator', () => { path.join(testDir, 'myapp'), 'myapp', undefined, - undefined, + false, { typescript: true } ); }); + it('passes Postgres Prisma options to createExpressApp', async () => { + const gen = new AppGenerator( + { + appName: 'myapp', + framework: 'express', + db: 'postgres', + orm: 'prisma', + typescript: true, + skipInstall: true, + config, + }, + deps + ); + + await gen.generate(); + + expect(deps.fileCreator.createExpressApp).toHaveBeenCalledWith( + config.paths.templates.express, + path.join(testDir, 'myapp'), + 'myapp', + undefined, + false, + { db: 'postgres', orm: 'prisma', typescript: true } + ); + }); + it('passes the expected args to createNodeApp', async () => { const gen = new AppGenerator( { @@ -405,9 +432,9 @@ describe('AppGenerator', () => { }); describe('setupDatabase', () => { - it('calls handleConfig when db is true', () => { + it('calls handleConfig when db is mongodb', () => { const gen = new AppGenerator( - { appName: 'myapp', db: true, config }, + { appName: 'myapp', db: 'mongodb', config }, deps ); gen.setupDatabase(); @@ -420,7 +447,7 @@ describe('AppGenerator', () => { it('passes the TypeScript option to handleConfig', () => { const gen = new AppGenerator( - { appName: 'myapp', db: true, typescript: true, config }, + { appName: 'myapp', db: 'mongodb', typescript: true, config }, deps ); gen.setupDatabase(); @@ -446,13 +473,43 @@ describe('AppGenerator', () => { expect(deps.fileCreator.handleConfig).not.toHaveBeenCalled(); }); + it('does not treat legacy db true as MongoDB', () => { + const gen = new AppGenerator( + { appName: 'myapp', db: true, config }, + deps + ); + gen.setupDatabase(); + expect(deps.fileCreator.handleConfig).not.toHaveBeenCalled(); + }); + it('does not call handleConfig for the hono framework', () => { const gen = new AppGenerator( - { appName: 'myapp', framework: 'hono', db: true, config }, + { appName: 'myapp', framework: 'hono', db: 'mongodb', config }, + deps + ); + gen.setupDatabase(); + expect(deps.fileCreator.handleConfig).not.toHaveBeenCalled(); + }); + + it('calls handlePostgresPrisma for Express Postgres Prisma apps', () => { + const gen = new AppGenerator( + { + appName: 'myapp', + framework: 'express', + db: 'postgres', + orm: 'prisma', + typescript: true, + config, + }, deps ); gen.setupDatabase(); expect(deps.fileCreator.handleConfig).not.toHaveBeenCalled(); + expect(deps.fileCreator.handlePostgresPrisma).toHaveBeenCalledWith( + path.join(testDir, 'myapp'), + config.paths.templates.express, + 'myapp' + ); }); }); @@ -509,6 +566,55 @@ describe('AppGenerator', () => { ); }); + it('passes Postgres Prisma support options to support-file helpers', () => { + const gen = new AppGenerator( + { + appName: 'myapp', + framework: 'express', + db: 'postgres', + orm: 'prisma', + openapi: true, + typescript: true, + config, + }, + deps + ); + + gen.addSupportFiles(); + + const folderDir = path.join(testDir, 'myapp'); + const supportOptions = { + db: 'postgres', + openapi: true, + orm: 'prisma', + port: 3000, + typescript: true, + }; + expect(deps.fileCreator.addDockerSupport).toHaveBeenCalledWith( + folderDir, + config.paths.templates.express, + supportOptions + ); + expect(deps.fileCreator.addReadme).toHaveBeenCalledWith( + folderDir, + config.paths.templates.express, + { ...supportOptions, appName: 'myapp' } + ); + expect(deps.fileCreator.addEnvExample).toHaveBeenCalledWith( + folderDir, + config.paths.templates.express, + supportOptions + ); + expect(deps.fileCreator.addOpenApiSpec).toHaveBeenCalledWith(folderDir, { + appName: 'myapp', + framework: 'express', + db: 'postgres', + orm: 'prisma', + port: 3000, + view: undefined, + }); + }); + it('adds an OpenAPI spec for hono apps when requested', () => { const gen = new AppGenerator( { diff --git a/tests/unit/file_generator.test.js b/tests/unit/file_generator.test.js index 6457d2c..07162ab 100644 --- a/tests/unit/file_generator.test.js +++ b/tests/unit/file_generator.test.js @@ -9,6 +9,7 @@ import { createNodeApp, handleViews, handleConfig, + handlePostgresPrisma, addGitIgnore, addDockerSupport, addReadme, @@ -211,6 +212,32 @@ describe('file_generator', () => { ); }); + it('writes Postgres Prisma package metadata for TypeScript Express apps', () => { + createExpressApp(templatesDir, testDir, 'my-app', null, false, { + db: 'postgres', + orm: 'prisma', + typescript: true, + }); + const pkg = readPkg(testDir); + const index = readTypeScriptIndex(testDir); + const tsconfig = fs.readJsonSync(path.join(testDir, 'tsconfig.json')); + + expect(pkg.type).toBe('module'); + expect(pkg.dependencies['@prisma/client']).toBe( + DEPENDENCY_VERSIONS['@prisma/client'] + ); + expect(pkg.dependencies['@prisma/adapter-pg']).toBe( + DEPENDENCY_VERSIONS['@prisma/adapter-pg'] + ); + expect(pkg.dependencies.pg).toBe(DEPENDENCY_VERSIONS.pg); + expect(pkg.devDependencies.prisma).toBe(DEPENDENCY_VERSIONS.prisma); + expect(pkg.scripts['db:generate']).toBe('prisma generate'); + expect(pkg.scripts['db:migrate']).toBe('prisma migrate dev'); + expect(index).toContain("import router from './routes/index.js'"); + expect(index).toContain('pathToFileURL(process.argv[1])'); + expect(tsconfig.compilerOptions.module).toBe('NodeNext'); + }); + it('logs the TypeScript express generation message', () => { createExpressApp(templatesDir, testDir, 'my-app', null, false, { typescript: true, @@ -490,6 +517,57 @@ describe('file_generator', () => { }); }); + describe('handlePostgresPrisma', () => { + it('adds Prisma, Docker Compose, user routes, controller, and tests', () => { + createExpressApp(templatesDir, testDir, 'pg-api', null, false, { + db: 'postgres', + orm: 'prisma', + typescript: true, + }); + handlePostgresPrisma(testDir, templatesDir, 'pg-api'); + + expect(fs.existsSync(path.join(testDir, 'docker-compose.yml'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'prisma.config.ts'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'prisma', 'schema.prisma'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'prisma', 'seed.ts'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'src', 'lib', 'prisma.ts'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'src', 'routes', 'users.ts'))).toBe(true); + expect( + fs.existsSync(path.join(testDir, 'src', 'controllers', 'usersController.ts')) + ).toBe(true); + expect(fs.existsSync(path.join(testDir, 'test', 'users.test.ts'))).toBe(true); + expect(logSpy).toHaveBeenCalledWith('Configuring Postgres with Prisma..'); + }); + + it('mounts the generated users router and uses ESM test imports', () => { + createExpressApp(templatesDir, testDir, 'pg-api', null, false, { + db: 'postgres', + orm: 'prisma', + typescript: true, + }); + handlePostgresPrisma(testDir, templatesDir, 'pg-api'); + + const routes = fs.readFileSync( + path.join(testDir, 'src', 'routes', 'index.ts'), + 'utf-8' + ); + const appTest = fs.readFileSync( + path.join(testDir, 'test', 'app.test.ts'), + 'utf-8' + ); + const schema = fs.readFileSync( + path.join(testDir, 'prisma', 'schema.prisma'), + 'utf-8' + ); + + expect(routes).toContain("import usersRouter from './users.js'"); + expect(routes).toContain('router.use(usersRouter);'); + expect(appTest).toContain("from '../src/index.js'"); + expect(schema).toContain('pg-api API'); + expect(schema).toContain('model User'); + }); + }); + describe('addGitIgnore', () => { it('copies the .gitignore file', () => { addGitIgnore(testDir, templatesDir); @@ -538,6 +616,22 @@ describe('file_generator', () => { expect(dockerfile).toContain('npm run build'); expect(dockerfile).toContain('dist/index.js'); }); + + it('uses the Postgres Prisma Dockerfile when configured', () => { + addDockerSupport(testDir, templatesDir, { + db: 'postgres', + orm: 'prisma', + typescript: true, + }); + const dockerfile = fs.readFileSync( + path.join(testDir, 'Dockerfile'), + 'utf-8' + ); + + expect(dockerfile).toContain('npm run db:generate'); + expect(dockerfile).toContain('npm run build'); + expect(dockerfile).toContain('dist/index.js'); + }); }); describe('addReadme', () => { @@ -582,6 +676,20 @@ describe('file_generator', () => { expect(readme).toContain('npm run build'); }); + it('documents Postgres Prisma setup when configured', () => { + addReadme(testDir, templatesDir, { + appName: 'pg-api', + db: 'postgres', + orm: 'prisma', + typescript: true, + }); + const readme = fs.readFileSync(path.join(testDir, 'README.md'), 'utf-8'); + + expect(readme).toContain('## Postgres + Prisma'); + expect(readme).toContain('docker compose up -d'); + expect(readme).toContain('npm run db:migrate'); + }); + it('updates Node README localhost URLs when configured', () => { addReadme(testDir, nodeTemplatesDir, { port: 8081 }); const readme = fs.readFileSync(path.join(testDir, 'README.md'), 'utf-8'); @@ -655,6 +763,25 @@ describe('file_generator', () => { expect(spec).toContain('Rendered welcome page'); expect(spec).not.toContain('$ref: \'#/components/schemas/WelcomeResponse\''); }); + + it('documents users paths and schemas for Postgres Prisma apps', () => { + addOpenApiSpec(testDir, { + appName: 'pg-api', + db: 'postgres', + orm: 'prisma', + port: 3000, + }); + const spec = fs.readFileSync( + path.join(testDir, 'docs', 'openapi.yaml'), + 'utf-8' + ); + + expect(spec).toContain('/users:'); + expect(spec).toContain('/users/{id}:'); + expect(spec).toContain('operationId: createUser'); + expect(spec).toContain('User:'); + expect(spec).toContain('CreateUserInput:'); + }); }); describe('addEnvExample', () => { @@ -675,13 +802,34 @@ describe('file_generator', () => { expect(env).not.toContain('MONGODB_URI'); }); - it('keeps MongoDB env values when MongoDB is enabled', () => { + it('does not treat legacy db true as MongoDB env config', () => { addEnvExample(testDir, templatesDir, { db: true, port: 8080 }); const env = fs.readFileSync(path.join(testDir, '.env.example'), 'utf-8'); + expect(env).toContain('PORT=8080'); + expect(env).not.toContain('MONGODB_URI'); + }); + + it('keeps MongoDB env values when MongoDB is enabled', () => { + addEnvExample(testDir, templatesDir, { db: 'mongodb', port: 8080 }); + const env = fs.readFileSync(path.join(testDir, '.env.example'), 'utf-8'); expect(env).toContain('MONGODB_URI=mongodb://localhost/your_database_name'); expect(env).toContain('PORT=8080'); }); + it('writes DATABASE_URL when Postgres Prisma is enabled', () => { + addEnvExample(testDir, templatesDir, { + db: 'postgres', + orm: 'prisma', + port: 8080, + }); + const env = fs.readFileSync(path.join(testDir, '.env.example'), 'utf-8'); + expect(env).toContain( + 'DATABASE_URL="postgresql://servergen:servergen@localhost:5432/servergen?schema=public"' + ); + expect(env).toContain('PORT=8080'); + expect(env).not.toContain('MONGODB_URI'); + }); + it('does nothing when the template .env.example is absent', () => { const emptyTemplates = path.join(testDir, 'empty-templates'); fs.ensureDirSync(emptyTemplates); diff --git a/tests/unit/interactive.test.js b/tests/unit/interactive.test.js index dd6b2c9..13448c9 100644 --- a/tests/unit/interactive.test.js +++ b/tests/unit/interactive.test.js @@ -55,7 +55,7 @@ describe('interactive prompts', () => { 'Framework (express/node/hono) [express]: ', 'Language (TypeScript/JavaScript) [TypeScript]: ', 'OpenAPI spec? (Y/n): ', - 'Database (none/mongodb) [none]: ', + 'Database (none/mongodb/postgres) [none]: ', 'View engine (none/ejs/pug/hbs) [none]: ', 'Port [3000]: ', 'Install dependencies? (Y/n): ', @@ -81,7 +81,7 @@ describe('interactive prompts', () => { framework: 'express', typescript: false, openapi: false, - db: true, + db: 'mongodb', view: 'pug', port: 8080, skipInstall: true, @@ -98,7 +98,7 @@ describe('interactive prompts', () => { 'ts', 'maybe', 'y', - 'postgres', + 'mysql', 'mongodb', 'jade', 'hbs', @@ -116,7 +116,7 @@ describe('interactive prompts', () => { framework: 'express', typescript: true, openapi: true, - db: true, + db: 'mongodb', view: 'hbs', port: 3001, skipInstall: false, @@ -125,7 +125,7 @@ describe('interactive prompts', () => { expect(harness.messages.join('')).toContain('Please choose express, node, or hono.'); expect(harness.messages.join('')).toContain('Please choose TypeScript or JavaScript.'); expect(harness.messages.join('')).toContain('Please answer yes or no.'); - expect(harness.messages.join('')).toContain('Please choose none or mongodb.'); + expect(harness.messages.join('')).toContain('Please choose none, mongodb, or postgres.'); expect(harness.messages.join('')).toContain('Please choose none, ejs, pug, or hbs.'); expect(harness.messages.join('')).toContain('Please enter a port between 1 and 65535.'); }); @@ -142,6 +142,35 @@ describe('interactive prompts', () => { expect(harness.prompts).toContain('Port [8088]: '); }); + it('supports Postgres and Prisma for TypeScript Express apps', async () => { + const harness = createPromptHarness([ + 'api', + 'express', + 'typescript', + 'y', + 'postgres', + '', + '', + 'n', + ]); + + const result = await promptForInteractiveOptions(harness); + + expect(result).toEqual({ + name: 'api', + framework: 'express', + typescript: true, + openapi: true, + db: 'postgres', + orm: 'prisma', + view: undefined, + port: 3000, + skipInstall: true, + }); + expect(harness.prompts).toContain('ORM (prisma) [prisma]: '); + expect(harness.prompts).not.toContain('View engine (none/ejs/pug/hbs) [none]: '); + }); + it('uses implicit TypeScript and skips Express-only prompts for Hono', async () => { const harness = createPromptHarness(['api', 'hono', 'y', '8787', 'n']); diff --git a/tests/unit/validator.test.js b/tests/unit/validator.test.js index 8b12116..a91de68 100644 --- a/tests/unit/validator.test.js +++ b/tests/unit/validator.test.js @@ -126,7 +126,7 @@ describe('validateOptions', () => { it('rejects --db with the node framework', () => { const result = validateOptions( - { framework: 'node', db: true }, + { framework: 'node', db: 'mongodb' }, validationRules ); expect(result.isValid).toBe(false); @@ -135,21 +135,106 @@ describe('validateOptions', () => { it('rejects --db with the hono framework', () => { const result = validateOptions( - { framework: 'hono', db: true }, + { framework: 'hono', db: 'mongodb' }, validationRules ); expect(result.isValid).toBe(false); expect(result.errors.some((e) => e.includes('express framework'))).toBe(true); }); - it('allows --db with the express framework', () => { + it('allows --db mongodb with the express framework', () => { + const result = validateOptions( + { framework: 'express', db: 'mongodb' }, + validationRules + ); + expect(result.isValid).toBe(true); + }); + + it('rejects the legacy boolean database shortcut', () => { const result = validateOptions( { framework: 'express', db: true }, validationRules ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('Invalid database'))).toBe(true); + }); + + it('allows explicit mongodb with the express framework', () => { + const result = validateOptions( + { framework: 'express', db: 'mongodb' }, + validationRules + ); expect(result.isValid).toBe(true); }); + it('allows postgres with prisma for Express TypeScript apps', () => { + const result = validateOptions( + { framework: 'express', typescript: true, db: 'postgres', orm: 'prisma' }, + validationRules + ); + expect(result.isValid).toBe(true); + }); + + it('rejects postgres without prisma', () => { + const result = validateOptions( + { framework: 'express', typescript: true, db: 'postgres' }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('requires --orm prisma'))).toBe(true); + }); + + it('rejects postgres without TypeScript', () => { + const result = validateOptions( + { framework: 'express', db: 'postgres', orm: 'prisma' }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('requires --typescript'))).toBe(true); + }); + + it('rejects postgres with views', () => { + const result = validateOptions( + { + framework: 'express', + typescript: true, + db: 'postgres', + orm: 'prisma', + view: 'ejs', + }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('does not support --view'))).toBe(true); + }); + + it('rejects --orm without postgres', () => { + const result = validateOptions( + { framework: 'express', db: 'mongodb', orm: 'prisma' }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('--orm option'))).toBe(true); + }); + + it('rejects invalid database values', () => { + const result = validateOptions( + { framework: 'express', db: 'mysql' }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('Invalid database'))).toBe(true); + }); + + it('rejects invalid ORM values', () => { + const result = validateOptions( + { framework: 'express', db: 'postgres', orm: 'sequelize' }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('Invalid ORM'))).toBe(true); + }); + it('rejects --openapi with the node framework', () => { const result = validateOptions( { framework: 'node', openapi: true },