diff --git a/bin/servergen.js b/bin/servergen.js index 82eaf24..9247d2d 100644 --- a/bin/servergen.js +++ b/bin/servergen.js @@ -23,23 +23,39 @@ const pkg = require('../package.json'); const config = getConfig(__dirname, process.cwd()); program + .name('servergen') + .description('Scaffold a Node.js or Express application.') .version(pkg.version) - .option('-f, --framework ', 'Enter Name of Framework: Node | Express') - .requiredOption('-n, --name ', 'Enter Name of App') - .option('-v, --view ', 'Name of View Engine: Pug | EJS | HBS') - .option('--db', 'Install Mongoose & the Folder Directory for it') - .option('-p, --port ', 'Set default port for the app', '3000') - .option('--skip-install', 'Skip npm install step') - .option('--debug', 'Enable debug logging') + .argument('[name]', 'name of the app to create (alternative to --name)') + .option('-n, --name ', 'name of the app to create') + .option('-f, --framework ', 'framework: express | node', 'express') + .option('-v, --view ', 'view engine (express only): ejs | pug | hbs') + .option('--db', 'add Mongoose and a MongoDB config (express only)') + .option('-p, --port ', 'port for the generated app (1-65535)', '3000') + .option('--skip-install', 'skip the npm install step') + .option('--debug', 'enable debug logging') + .addHelpText( + 'after', + ` +Examples: + $ servergen my-api create an Express app (default) + $ servergen my-api -f node create a Node 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 -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)` + ) .parse(process.argv); const options = program.opts(); +const positionalName = program.args[0]; if (options.debug) { logger.enableDebug(); } -logger.debug('CLI options received', options); +logger.debug('CLI options received', { ...options, positionalName }); const validationResult = validateOptions(options, config.validation); if (!validationResult.isValid) { @@ -47,7 +63,24 @@ if (!validationResult.isValid) { process.exit(1); } -const appName = fileName.cleanAppName(options.name); +// 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); +} + +const rawName = positionalName || options.name; + +if (!rawName) { + logger.error( + 'Missing app name. Provide it as a positional argument (servergen my-api) or with --name.' + ); + process.exit(1); +} + +const appName = fileName.cleanAppName(rawName); if (!appName) { logger.error( diff --git a/lib/validator.js b/lib/validator.js index c088bea..8ad841d 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -26,6 +26,10 @@ export const validateOptions = (options, validationRules) => { errors.push('View engines are only supported with the express framework. Use --framework express or remove --view.'); } + if (options.framework === 'node' && options.db) { + errors.push('The --db option (Mongoose) is only supported with the express framework. Use --framework express or remove --db.'); + } + if (options.port !== undefined && options.port !== null) { const portNum = Number(options.port); if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { diff --git a/tests/integration/integration.test.js b/tests/integration/integration.test.js index 85efbdd..a9a2aab 100644 --- a/tests/integration/integration.test.js +++ b/tests/integration/integration.test.js @@ -27,6 +27,21 @@ describe('CLI Integration', () => { }); }; + const expectCLIError = (args, messageSubstring) => { + let caught; + try { + runCLI(args); + } catch (e) { + caught = e; + } + expect(caught, `expected the CLI to fail for: ${args}`).toBeDefined(); + const output = `${caught.stdout || ''}${caught.stderr || ''}`; + if (messageSubstring) { + expect(output).toContain(messageSubstring); + } + return caught; + }; + describe('help command', () => { it('displays help information', () => { const output = runCLI('--help'); @@ -35,6 +50,14 @@ describe('CLI Integration', () => { expect(output).toContain('-f, --framework'); }); + it('shows usage examples', () => { + const output = runCLI('--help'); + expect(output).toContain('Examples:'); + expect(output).toContain('servergen my-api'); + expect(output).toContain('-f node'); + expect(output).toContain('--db'); + }); + it('displays version', () => { const output = runCLI('--version'); expect(output).toMatch(/\d+\.\d+\.\d+/); @@ -184,4 +207,116 @@ describe('CLI Integration', () => { expect(fs.existsSync(path.join(testOutput, 'nodenoview', 'views'))).toBe(false); }); }); + + describe('app name resolution', () => { + it('accepts a positional app name', () => { + runCLI('posapp --skip-install'); + expect(fs.existsSync(path.join(testOutput, 'posapp', 'index.js'))).toBe(true); + }); + + it('accepts the legacy --name flag', () => { + runCLI('--name legacyapp --skip-install'); + expect(fs.existsSync(path.join(testOutput, 'legacyapp', 'index.js'))).toBe(true); + }); + + it('sanitizes the positional name', () => { + runCLI('My-Api --skip-install'); + expect(fs.existsSync(path.join(testOutput, 'myapi', 'index.js'))).toBe(true); + }); + + it('accepts the same name supplied both ways', () => { + runCLI('sameapp --name sameapp --skip-install'); + expect(fs.existsSync(path.join(testOutput, 'sameapp', 'index.js'))).toBe(true); + }); + + it('rejects conflicting positional and --name values', () => { + expectCLIError('appone --name apptwo --skip-install', 'Conflicting app names'); + expect(fs.existsSync(path.join(testOutput, 'appone'))).toBe(false); + expect(fs.existsSync(path.join(testOutput, 'apptwo'))).toBe(false); + }); + + it('errors when no name is provided', () => { + expectCLIError('--skip-install', 'Missing app name'); + }); + + it('errors when the name has no alphanumeric characters', () => { + expectCLIError('@@@ --skip-install', 'at least one alphanumeric'); + }); + }); + + describe('defaults', () => { + it('defaults the framework to express and the port to 3000', () => { + runCLI('defaultapp --skip-install'); + + const pkg = JSON.parse( + fs.readFileSync( + path.join(testOutput, 'defaultapp', 'package.json'), + 'utf-8' + ) + ); + expect(pkg.dependencies.express).toBeDefined(); + + const index = fs.readFileSync( + path.join(testOutput, 'defaultapp', 'index.js'), + 'utf-8' + ); + expect(index).toContain('process.env.PORT || 3000'); + }); + }); + + describe('supported flags', () => { + it('supports the pug view engine', () => { + runCLI('pugapp -v pug --skip-install'); + const pkg = JSON.parse( + fs.readFileSync(path.join(testOutput, 'pugapp', 'package.json'), 'utf-8') + ); + expect(pkg.dependencies.pug).toBeDefined(); + }); + + it('supports the hbs view engine', () => { + runCLI('hbsapp -v hbs --skip-install'); + const pkg = JSON.parse( + fs.readFileSync(path.join(testOutput, 'hbsapp', 'package.json'), 'utf-8') + ); + expect(pkg.dependencies.hbs).toBeDefined(); + }); + + it('--skip-install does not install dependencies', () => { + runCLI('skipapp --skip-install'); + expect( + fs.existsSync(path.join(testOutput, 'skipapp', 'node_modules')) + ).toBe(false); + }); + + it('--debug emits debug logging', () => { + const output = runCLI('debugapp --debug --skip-install'); + expect(output).toContain('[DEBUG'); + }); + }); + + describe('invalid options', () => { + it('rejects --db with the node framework', () => { + expectCLIError( + 'nodedb -f node --db --skip-install', + 'only supported with the express framework' + ); + expect(fs.existsSync(path.join(testOutput, 'nodedb'))).toBe(false); + }); + + it('rejects an invalid framework', () => { + expectCLIError('badfw -f flask --skip-install', 'Invalid framework'); + }); + + it('rejects an invalid view engine', () => { + expectCLIError('badview -v jade --skip-install', 'Invalid view engine'); + }); + + it('rejects an out-of-range port', () => { + expectCLIError('badport -p 99999 --skip-install', 'Invalid port'); + }); + + it('rejects a port of zero', () => { + expectCLIError('zeroport -p 0 --skip-install', 'Invalid port'); + }); + }); }); diff --git a/tests/unit/validator.test.js b/tests/unit/validator.test.js index 84dd0f8..32fc61f 100644 --- a/tests/unit/validator.test.js +++ b/tests/unit/validator.test.js @@ -118,6 +118,23 @@ describe('validateOptions', () => { const result = validateOptions({ framework: 'node' }, validationRules); expect(result.isValid).toBe(true); }); + + it('rejects --db with the node framework', () => { + const result = validateOptions( + { framework: 'node', db: true }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('express framework'))).toBe(true); + }); + + it('allows --db with the express framework', () => { + const result = validateOptions( + { framework: 'express', db: true }, + validationRules + ); + expect(result.isValid).toBe(true); + }); }); describe('combined validation', () => { diff --git a/vitest.config.js b/vitest.config.js index 4df99d3..252de92 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -6,13 +6,16 @@ export default defineConfig({ coverage: { provider: 'istanbul', reporter: ['text', 'html'], - include: ['lib/**/*.js', 'bin/**/*.js', 'index.js'], + // bin/servergen.js is the CLI entry point: it is exercised end to end by + // the integration tests (which run it as a subprocess), so in-process + // istanbul cannot see that coverage. Measure the importable library code. + include: ['lib/**/*.js', 'index.js'], exclude: ['tests/**', 'node_modules/**'], thresholds: { - statements: 80, - branches: 80, - functions: 85, - lines: 80, + statements: 90, + branches: 85, + functions: 90, + lines: 90, }, }, },