diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d247d17..be2750b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,10 @@ name: Release # Release pipeline: # 1) smoke: pack, install the tarball, scaffold Node and Express apps, boot their # servers and verify a live HTTP response. Kept out of the per-PR ci.yml job. -# 2) publish: on a version tag, publish to npm using OIDC trusted publishing -# (no long-lived token; provenance is attached automatically), then create the -# matching GitHub Release so npm and GitHub show the same latest version. +# 2) publish: on a version tag, publish the CLI and npm-create wrapper to npm +# using OIDC trusted publishing (no long-lived token; provenance is attached +# automatically), then create the matching GitHub Release so npm and GitHub +# show the same latest version. on: push: tags: @@ -54,9 +55,13 @@ jobs: # Explicit --tag latest: 2.1.0+ is the active release line and supersedes # the historical 2021 servergen@2.0.0 package on the npm registry. - - name: Publish to npm + - name: Publish servergen to npm run: npm publish --tag latest + - name: Publish create-servergen to npm + run: npm publish --tag latest + working-directory: packages/create-servergen + - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a79ec29..7f7f046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ ## Unreleased -- Add an Express-only `--openapi` option that generates `docs/openapi.yaml` and documents it in the generated README. -- Add `--typescript` support for generated Express apps with `src/index.ts`, `tsconfig.json`, `tsx` development, `tsc` builds to `dist`, generated TypeScript tests, and Docker support. -- Keep JavaScript output unchanged by default and reject `--typescript` with the plain Node framework. +## 2.3.0 - 2026-06-24 + +- Add TypeScript support for generated Express apps with `src/index.ts`, `tsconfig.json`, `tsx` development, `tsc` builds to `dist`, generated TypeScript tests, and Docker support. +- Add OpenAPI spec support with an Express-only `--openapi` option that generates `docs/openapi.yaml` and documents it in the generated README. +- Add interactive mode so new projects can be configured through prompts instead of requiring every option up front. +- Add npm create support so the recommended first-run path can use `npm create servergen@latest`. ## 2.2.3 - 2026-06-23 diff --git a/README.md b/README.md index d7780da..3e66a54 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ npm scripts, optional Express views, and optional Mongoose/MongoDB config. Requires Node.js 20 or higher. ```bash -npx servergen@latest my-api +npm create servergen@latest cd my-api npm start ``` @@ -33,8 +33,15 @@ Expected response: {"status":"ok"} ``` -ServerGen creates an Express app by default and runs `npm install` in the -generated app unless `--skip-install` is used. +The create flow guides you through the app name and options interactively, +creates an Express app by default, and runs `npm install` in the generated app +unless install is skipped. + +Prefer the direct CLI path when you already know the app name and options: + +```bash +npx servergen@latest my-api +``` ## What Gets Generated @@ -77,6 +84,7 @@ servergen [options] [name] ### Examples ```bash +npm create servergen@latest npx servergen@latest my-api npx servergen@latest my-api --framework node npx servergen@latest my-api --view ejs @@ -93,7 +101,13 @@ steps, see the [scaffold examples](https://github.com/theinfosecguy/ServerGen/tr ## Install Options -Use `npx` when you want to create an app without adding ServerGen to another +Use the interactive create flow for the shortest first run: + +```bash +npm create servergen@latest +``` + +Use `npx` when you want the direct CLI path without adding ServerGen to another project: ```bash diff --git a/bin/servergen.js b/bin/servergen.js index 0ac5c93..c424b2d 100644 --- a/bin/servergen.js +++ b/bin/servergen.js @@ -14,6 +14,10 @@ import { validateOptions } from '../lib/validator.js'; import { createGenerator } from '../index.js'; import * as fileName from '../lib/fileName.js'; import * as logger from '../lib/logger.js'; +import { + isInteractiveTerminal, + runInteractiveWizard, +} from '../lib/interactive.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -48,7 +52,8 @@ Examples: $ servergen my-api --typescript Express app with TypeScript $ servergen my-api -p 8080 use a custom port $ servergen my-api --skip-install scaffold without running npm install - $ servergen --name my-api name via flag (equivalent to positional)` + $ servergen --name my-api name via flag (equivalent to positional) + $ servergen start the interactive wizard` ) .parse(process.argv); @@ -61,56 +66,86 @@ if (options.debug) { logger.debug('CLI options received', { ...options, positionalName }); -const validationResult = validateOptions(options, config.validation); -if (!validationResult.isValid) { - validationResult.errors.forEach((error) => logger.error(error)); - process.exit(1); -} - -// Resolve the app name from the positional argument or the --name flag. -if (positionalName && options.name && positionalName !== options.name) { - logger.error( - `Conflicting app names: "${positionalName}" (positional) and "${options.name}" (--name). Provide only one.` - ); - process.exit(1); -} +/** + * Main function to run the application generator. + */ +const main = async () => { + if (positionalName && options.name && positionalName !== options.name) { + logger.error( + `Conflicting app names: "${positionalName}" (positional) and "${options.name}" (--name). Provide only one.` + ); + process.exit(1); + } -const rawName = positionalName || options.name; + let rawName = positionalName || options.name; + const resolvedOptions = { ...options }; + + if (!rawName) { + if (!isInteractiveTerminal(process.stdin, process.stdout)) { + logger.error( + 'Missing app name. Provide it as a positional argument (servergen my-api) or with --name.' + ); + process.exit(1); + } + + let wizardOptions; + try { + wizardOptions = await runInteractiveWizard({ + input: process.stdin, + output: process.stdout, + defaults: { + port: resolvedOptions.port, + }, + }); + } catch (err) { + logger.error('Interactive wizard cancelled.'); + logger.debug('Interactive wizard error', err); + process.exit(1); + } + + rawName = wizardOptions.name; + Object.assign(resolvedOptions, wizardOptions, { + port: String(wizardOptions.port), + }); + + logger.debug('Interactive wizard answers received', wizardOptions); + } -if (!rawName) { - logger.error( - 'Missing app name. Provide it as a positional argument (servergen my-api) or with --name.' - ); - process.exit(1); -} + const validationResult = validateOptions(resolvedOptions, config.validation); + if (!validationResult.isValid) { + validationResult.errors.forEach((error) => logger.error(error)); + process.exit(1); + } -const appName = fileName.cleanAppName(rawName); + const appName = fileName.cleanAppName(rawName); -if (!appName) { - logger.error( - 'App name must contain at least one alphanumeric character. Please provide a valid name.' - ); - process.exit(1); -} + if (!appName) { + logger.error( + 'App name must contain at least one alphanumeric character. Please provide a valid name.' + ); + process.exit(1); + } -const port = parseInt(options.port, 10) || 3000; -const skipInstall = options.skipInstall || false; + const port = parseInt(resolvedOptions.port, 10) || 3000; + const skipInstall = resolvedOptions.skipInstall || false; -logger.debug('Parsed configuration', { appName, port, framework: options.framework, skipInstall, typescript: options.typescript }); + logger.debug('Parsed configuration', { + appName, + port, + framework: resolvedOptions.framework, + skipInstall, + typescript: resolvedOptions.typescript, + }); -/** - * Main function to run the application generator. - */ -const main = async () => { const generator = createGenerator({ appName, - framework: options.framework, - view: options.view, - db: options.db, - openapi: options.openapi, + framework: resolvedOptions.framework, + view: resolvedOptions.view, + db: resolvedOptions.db, + openapi: resolvedOptions.openapi, port, skipInstall, - typescript: options.typescript, + typescript: resolvedOptions.typescript, config, }); diff --git a/docs/examples/README.md b/docs/examples/README.md index ce562b7..1a2ab60 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -2,7 +2,13 @@ Copy-paste examples for current ServerGen flows. -These examples use `npx --yes servergen@latest` to follow the current published CLI. If you need reproducible output, replace `latest` with a pinned version. If you have ServerGen installed globally, you can replace that prefix with `servergen`. +For a guided first run, use the interactive create flow: + +```sh +npm create servergen@latest +``` + +The command-by-command examples use `npx --yes servergen@latest` to follow the current published CLI. If you need reproducible output, replace `latest` with a pinned version. If you have ServerGen installed globally, you can replace that prefix with `servergen`. ## Examples @@ -16,9 +22,10 @@ These examples use `npx --yes servergen@latest` to follow the current published ## Shared Notes - ServerGen creates a new app directory in your current working directory. +- `npm create servergen@latest` starts the interactive flow; the `npx --yes servergen@latest ...` examples show direct CLI equivalents. - The default framework is Express. - 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`, `--db`, and `--typescript`. +- Express-only options: `--view ejs|pug|hbs`, `--db`, `--openapi`, and `--typescript`. - Generated apps include Docker support files. Express apps also include `.env.example`; Node apps do not. diff --git a/docs/release.md b/docs/release.md index 8e53ed8..23a1564 100644 --- a/docs/release.md +++ b/docs/release.md @@ -28,6 +28,11 @@ the files consumers need: - `CHANGELOG.md` - `LICENSE` +This checkout includes the root `servergen` package and the +`packages/create-servergen` npm-create wrapper. Confirm the wrapper publishes +the `create-servergen` package name and delegates to the released `servergen` +CLI without duplicating generation logic. + The generated app smoke expectations are: - The packaged CLI installs from the packed tarball, not the working tree. @@ -82,6 +87,11 @@ git add package.json CHANGELOG.md git commit -m "chore: release x.y.z" ``` +Update `packages/create-servergen/package.json` in the same release change. The +`servergen` and `create-servergen` package versions should normally match for +adoption releases so `npm create servergen@latest` and `npx servergen@latest` +resolve to the same documented behavior. + ## Tag and release workflow Create an annotated version tag from the exact commit that should be released: @@ -97,15 +107,44 @@ the following: 1. Installs dependencies on Node.js 22. 2. Runs `npm run test:package`. -3. Publishes the package to npm with `npm publish --tag latest`. -4. Uses npm trusted publishing through GitHub OIDC, so no long-lived npm token is +3. Publishes `servergen` to npm with `npm publish --tag latest`. +4. Publishes `create-servergen` from `packages/create-servergen`. +5. Uses npm trusted publishing through GitHub OIDC, so no long-lived npm token is required and npm provenance is attached to the package. -5. Creates the matching GitHub Release, or marks the existing matching release as +6. Creates the matching GitHub Release, or marks the existing matching release as latest. Do not run a separate local `npm publish` for normal releases. Let the tag workflow publish once. +### Publishing `create-servergen` + +The release workflow publishes the root `servergen` package first, then +publishes `packages/create-servergen`. Before tagging, configure npm trusted +publishing for both npm packages against this GitHub repository and the +`Release` workflow so both publishes use OIDC provenance. + +If `create-servergen` has never been published before, bootstrap the package +name before relying on trusted publishing. npm trusted publisher settings are +configured from an existing package's settings page, so the first publish may +require an npm owner to publish the wrapper manually or with a short-lived token. +After the package exists, configure its trusted publisher for the `Release` +workflow before using tag-based releases. + +The package smoke test packs and installs both local tarballs, then verifies the +wrapper can create an app through the installed `create-servergen` bin. The +wrapper publish is ordered after the root CLI publish because it depends on the +just-published `servergen` version. + +Post-release verification should check both package names: + +```sh +npm view servergen version dist-tags --json +npm view create-servergen version dist-tags --json +npm view "servergen@$VERSION" dist.attestations --json +npm view "create-servergen@$VERSION" dist.attestations --json +``` + ## Post-release verification Set the version once and use it in the checks: diff --git a/lib/interactive.js b/lib/interactive.js new file mode 100644 index 0000000..25741cb --- /dev/null +++ b/lib/interactive.js @@ -0,0 +1,196 @@ +/** + * Interactive CLI prompts for ServerGen. + * @module lib/interactive + */ + +import { createInterface } from 'node:readline/promises'; + +const trimLower = (value) => value.trim().toLowerCase(); + +const writeValidationMessage = (output, message) => { + if (output?.write) { + output.write(`${message}\n`); + } +}; + +const askRequired = async ({ question, output, prompt, errorMessage }) => { + while (true) { + const answer = (await question(prompt)).trim(); + if (answer) { + return answer; + } + writeValidationMessage(output, errorMessage); + } +}; + +const askChoice = async ({ + question, + output, + prompt, + choices, + defaultValue, + errorMessage, +}) => { + while (true) { + const answer = trimLower(await question(prompt)); + if (!answer) { + return defaultValue; + } + if (Object.prototype.hasOwnProperty.call(choices, answer)) { + return choices[answer]; + } + writeValidationMessage(output, errorMessage); + } +}; + +const askPort = async ({ question, output, defaultPort }) => { + while (true) { + const answer = trimLower(await question(`Port [${defaultPort}]: `)); + const port = answer ? Number(answer) : defaultPort; + if (Number.isInteger(port) && port >= 1 && port <= 65535) { + return port; + } + writeValidationMessage(output, 'Please enter a port between 1 and 65535.'); + } +}; + +/** + * Returns whether stdin/stdout can support an interactive prompt. + * @param {Object} input - Input stream. + * @param {Object} output - Output stream. + * @returns {boolean} Whether the terminal is interactive. + */ +export const isInteractiveTerminal = (input, output) => { + return Boolean(input?.isTTY && output?.isTTY); +}; + +/** + * Prompts for the interactive ServerGen options using an injected question + * function so tests can provide scripted answers without a real TTY. + * @param {Object} deps - Prompt dependencies. + * @param {Function} deps.question - Async prompt function. + * @param {Object} [deps.output] - Output stream for validation messages. + * @param {Object} [deps.defaults] - Prompt defaults. + * @returns {Promise} Normalized generation options. + */ +export const promptForInteractiveOptions = async ({ + question, + output, + defaults = {}, +}) => { + const defaultPort = Number(defaults.port) || 3000; + + const name = await askRequired({ + question, + output, + prompt: 'Project name: ', + errorMessage: 'Project name is required.', + }); + + const typescript = await askChoice({ + question, + output, + prompt: 'Language (TypeScript/JavaScript) [TypeScript]: ', + choices: { + ts: true, + typescript: true, + js: false, + javascript: false, + }, + defaultValue: true, + errorMessage: 'Please choose TypeScript or JavaScript.', + }); + + const openapi = await askChoice({ + question, + output, + prompt: 'OpenAPI spec? (Y/n): ', + choices: { + y: true, + yes: true, + n: false, + no: false, + }, + defaultValue: true, + errorMessage: 'Please answer yes or no.', + }); + + const db = await askChoice({ + question, + output, + prompt: 'Database (none/mongodb) [none]: ', + choices: { + none: false, + no: false, + n: false, + mongo: true, + mongodb: true, + mongoose: true, + }, + defaultValue: false, + errorMessage: 'Please choose none or mongodb.', + }); + + const view = await askChoice({ + question, + output, + prompt: 'View engine (none/ejs/pug/hbs) [none]: ', + choices: { + none: undefined, + no: undefined, + n: undefined, + ejs: 'ejs', + pug: 'pug', + hbs: 'hbs', + }, + defaultValue: undefined, + errorMessage: 'Please choose none, ejs, pug, or hbs.', + }); + + const port = await askPort({ question, output, defaultPort }); + + const installDependencies = await askChoice({ + question, + output, + prompt: 'Install dependencies? (Y/n): ', + choices: { + y: true, + yes: true, + n: false, + no: false, + }, + defaultValue: true, + errorMessage: 'Please answer yes or no.', + }); + + return { + name, + typescript, + openapi, + db, + view, + port, + skipInstall: !installDependencies, + }; +}; + +/** + * Runs the interactive wizard with Node's built-in readline/promises. + * @param {Object} deps - Prompt dependencies. + * @param {Object} deps.input - Input stream. + * @param {Object} deps.output - Output stream. + * @param {Object} [deps.defaults] - Prompt defaults. + * @returns {Promise} Normalized generation options. + */ +export const runInteractiveWizard = async ({ input, output, defaults }) => { + const readline = createInterface({ input, output }); + try { + return await promptForInteractiveOptions({ + question: (prompt) => readline.question(prompt), + output, + defaults, + }); + } finally { + readline.close(); + } +}; diff --git a/package.json b/package.json index cb60468..99559ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "servergen", - "version": "2.2.3", + "version": "2.3.0", "description": "CLI that scaffolds production-ready Node.js and Express apps with MVC structure, optional view engines, MongoDB, and Docker support.", "type": "module", "main": "./index.js", diff --git a/packages/create-servergen/README.md b/packages/create-servergen/README.md new file mode 100644 index 0000000..59e4805 --- /dev/null +++ b/packages/create-servergen/README.md @@ -0,0 +1,17 @@ +# create-servergen + +`create-servergen` is the npm create wrapper for the `servergen` CLI. + +After this package is published, users can scaffold interactively with: + +```sh +npm create servergen@latest +``` + +Pass arguments after `--` to use the direct CLI path: + +```sh +npm create servergen@latest -- my-api --skip-install +``` + +The wrapper delegates to the installed `servergen` CLI and passes all arguments through unchanged. diff --git a/packages/create-servergen/bin/create-servergen.js b/packages/create-servergen/bin/create-servergen.js new file mode 100755 index 0000000..d5d74bd --- /dev/null +++ b/packages/create-servergen/bin/create-servergen.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { spawn } from 'child_process'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const servergenBin = require.resolve('servergen/bin/servergen.js'); + +const child = spawn(process.execPath, [servergenBin, ...process.argv.slice(2)], { + stdio: 'inherit', +}); + +child.on('error', (err) => { + console.error(`Failed to start servergen: ${err.message}`); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); +}); diff --git a/packages/create-servergen/package.json b/packages/create-servergen/package.json new file mode 100644 index 0000000..743a18f --- /dev/null +++ b/packages/create-servergen/package.json @@ -0,0 +1,37 @@ +{ + "name": "create-servergen", + "version": "2.3.0", + "description": "npm create wrapper for the servergen CLI.", + "type": "module", + "bin": { + "create-servergen": "bin/create-servergen.js" + }, + "files": [ + "bin", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/theinfosecguy/ServerGen.git", + "directory": "packages/create-servergen" + }, + "keywords": [ + "create", + "create-servergen", + "servergen", + "scaffold", + "cli" + ], + "author": "Keshav Malik", + "license": "MIT", + "bugs": { + "url": "https://github.com/theinfosecguy/ServerGen/issues" + }, + "homepage": "https://github.com/theinfosecguy/ServerGen#readme", + "dependencies": { + "servergen": "^2.3.0" + } +} diff --git a/tests/smoke/package.smoke.test.js b/tests/smoke/package.smoke.test.js index a710e36..30abcdf 100644 --- a/tests/smoke/package.smoke.test.js +++ b/tests/smoke/package.smoke.test.js @@ -8,6 +8,7 @@ import { execFileSync, spawn } from 'child_process'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.join(__dirname, '..', '..'); +const createPackageRoot = path.join(projectRoot, 'packages', 'create-servergen'); // Heavy, release-gated smoke test. It proves the PUBLISHED package works end to // end: build the tarball with `npm pack`, install it into a throwaway project so @@ -18,6 +19,8 @@ const projectRoot = path.join(__dirname, '..', '..'); const PORTS = { express: 5310, node: 5311, typescript: 5312 }; const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const createBinName = + process.platform === 'win32' ? 'create-servergen.cmd' : 'create-servergen'; // Sleep helper for retry backoff. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -67,6 +70,7 @@ describe('published package smoke test', () => { let packDir; // holds the built tarball let installDir; // throwaway project where the tarball is installed let installedBin; // /node_modules/servergen/bin/servergen.js + let createBin; // /node_modules/.bin/create-servergen const workDirs = []; // generation cwds, cleaned up in afterAll let child; // currently running server, killed in afterEach @@ -91,6 +95,20 @@ describe('published package smoke test', () => { const tarballPath = path.join(packDir, tarballName); expect(fs.existsSync(tarballPath)).toBe(true); + const createPackOutput = execFileSync( + npmCmd, + ['pack', '--pack-destination', packDir], + { cwd: createPackageRoot, encoding: 'utf-8', timeout: 120000 } + ); + const createTarballName = createPackOutput + .trim() + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .pop(); + const createTarballPath = path.join(packDir, createTarballName); + expect(fs.existsSync(createTarballPath)).toBe(true); + // Stand up a throwaway project and install the tarball into it so that // servergen + its production deps resolve under node_modules, exactly like // a real consumer install. @@ -101,10 +119,18 @@ describe('published package smoke test', () => { }); execFileSync( npmCmd, - ['install', tarballPath, '--no-audit', '--no-fund'], + ['install', tarballPath, createTarballPath, '--no-audit', '--no-fund'], { cwd: installDir, encoding: 'utf-8', timeout: 180000 } ); + const lockfile = fs.readJsonSync(path.join(installDir, 'package-lock.json')); + expect(lockfile.packages['node_modules/servergen'].resolved).toContain( + tarballName + ); + expect(lockfile.packages['node_modules/create-servergen'].resolved).toContain( + createTarballName + ); + installedBin = path.join( installDir, 'node_modules', @@ -113,6 +139,9 @@ describe('published package smoke test', () => { 'servergen.js' ); expect(fs.existsSync(installedBin)).toBe(true); + + createBin = path.join(installDir, 'node_modules', '.bin', createBinName); + expect(fs.existsSync(createBin)).toBe(true); }, 240000); afterEach(() => { @@ -185,6 +214,26 @@ describe('published package smoke test', () => { proc.kill(signal); }); + it('packs and installs create-servergen, then delegates through its bin', () => { + const workDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'servergen-create-smoke-work-') + ); + workDirs.push(workDir); + + execFileSync( + createBin, + ['wrappednode', '-f', 'node', '-p', '5322', '--skip-install'], + { cwd: workDir, encoding: 'utf-8', timeout: 120000 } + ); + + const appDir = path.join(workDir, 'wrappednode'); + expect(fs.existsSync(path.join(appDir, 'index.js'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'package.json'))).toBe(true); + expect(fs.readFileSync(path.join(appDir, 'README.md'), 'utf-8')).toContain( + 'http://127.0.0.1:5322' + ); + }, 120000); + it( 'generates, builds, tests, boots an Express app, serves HTTP, and stops cleanly', async () => { diff --git a/tests/unit/interactive.test.js b/tests/unit/interactive.test.js new file mode 100644 index 0000000..2f6b4d5 --- /dev/null +++ b/tests/unit/interactive.test.js @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { + isInteractiveTerminal, + promptForInteractiveOptions, +} from '../../lib/interactive.js'; + +const createPromptHarness = (answers) => { + const prompts = []; + const messages = []; + return { + prompts, + output: { + write(message) { + messages.push(message); + }, + }, + messages, + question: async (prompt) => { + prompts.push(prompt); + if (answers.length === 0) { + throw new Error(`No scripted answer for prompt: ${prompt}`); + } + return answers.shift(); + }, + }; +}; + +describe('interactive prompts', () => { + it('uses wizard defaults for empty optional answers', async () => { + const harness = createPromptHarness([ + 'My API', + '', + '', + '', + '', + '', + '', + ]); + + const result = await promptForInteractiveOptions(harness); + + expect(result).toEqual({ + name: 'My API', + typescript: true, + openapi: true, + db: false, + view: undefined, + port: 3000, + skipInstall: false, + }); + expect(harness.prompts).toEqual([ + 'Project name: ', + 'Language (TypeScript/JavaScript) [TypeScript]: ', + 'OpenAPI spec? (Y/n): ', + 'Database (none/mongodb) [none]: ', + 'View engine (none/ejs/pug/hbs) [none]: ', + 'Port [3000]: ', + 'Install dependencies? (Y/n): ', + ]); + }); + + it('maps explicit answers to generator options', async () => { + const harness = createPromptHarness([ + 'api', + 'javascript', + 'no', + 'mongoose', + 'pug', + '8080', + 'n', + ]); + + const result = await promptForInteractiveOptions(harness); + + expect(result).toEqual({ + name: 'api', + typescript: false, + openapi: false, + db: true, + view: 'pug', + port: 8080, + skipInstall: true, + }); + }); + + it('retries invalid choices and keeps prompting until valid', async () => { + const harness = createPromptHarness([ + '', + 'api', + 'ruby', + 'ts', + 'maybe', + 'y', + 'postgres', + 'mongodb', + 'jade', + 'hbs', + 'abc', + '65536', + '3001', + 'sure', + 'yes', + ]); + + const result = await promptForInteractiveOptions(harness); + + expect(result).toMatchObject({ + name: 'api', + typescript: true, + openapi: true, + db: true, + view: 'hbs', + port: 3001, + skipInstall: false, + }); + expect(harness.messages.join('')).toContain('Project name is required.'); + 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, ejs, pug, or hbs.'); + expect(harness.messages.join('')).toContain('Please enter a port between 1 and 65535.'); + }); + + it('allows custom default ports', async () => { + const harness = createPromptHarness(['api', '', '', '', '', '', '']); + + const result = await promptForInteractiveOptions({ + ...harness, + defaults: { port: '8088' }, + }); + + expect(result.port).toBe(8088); + expect(harness.prompts).toContain('Port [8088]: '); + }); +}); + +describe('isInteractiveTerminal', () => { + it('requires both input and output TTY streams', () => { + expect(isInteractiveTerminal({ isTTY: true }, { isTTY: true })).toBe(true); + expect(isInteractiveTerminal({ isTTY: false }, { isTTY: true })).toBe(false); + expect(isInteractiveTerminal({ isTTY: true }, { isTTY: false })).toBe(false); + }); +});