Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions bin/servergen.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,64 @@ 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 <type>', 'Enter Name of Framework: Node | Express')
.requiredOption('-n, --name <type>', 'Enter Name of App')
.option('-v, --view <type>', 'Name of View Engine: Pug | EJS | HBS')
.option('--db', 'Install Mongoose & the Folder Directory for it')
.option('-p, --port <number>', '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>', 'name of the app to create')
.option('-f, --framework <type>', 'framework: express | node', 'express')
.option('-v, --view <type>', 'view engine (express only): ejs | pug | hbs')
.option('--db', 'add Mongoose and a MongoDB config (express only)')
.option('-p, --port <number>', '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) {
validationResult.errors.forEach((error) => logger.error(error));
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(
Expand Down
4 changes: 4 additions & 0 deletions lib/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
135 changes: 135 additions & 0 deletions tests/integration/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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+/);
Expand Down Expand Up @@ -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');
});
});
});
17 changes: 17 additions & 0 deletions tests/unit/validator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
13 changes: 8 additions & 5 deletions vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
Expand Down
Loading