create-listablelabs-api 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +142 -0
  2. package/bin/cli.js +37 -0
  3. package/bin/commands/add.js +460 -0
  4. package/bin/commands/create.js +481 -0
  5. package/package.json +39 -0
  6. package/templates/base/.dockerignore +19 -0
  7. package/templates/base/.env.example +18 -0
  8. package/templates/base/.eslintrc.js +31 -0
  9. package/templates/base/Dockerfile +48 -0
  10. package/templates/base/README.md +295 -0
  11. package/templates/base/docker-compose.yml +55 -0
  12. package/templates/base/jest.config.js +24 -0
  13. package/templates/base/package.json +41 -0
  14. package/templates/base/src/app.js +103 -0
  15. package/templates/base/src/config/index.js +36 -0
  16. package/templates/base/src/controllers/exampleController.js +148 -0
  17. package/templates/base/src/database/baseModel.js +160 -0
  18. package/templates/base/src/database/index.js +108 -0
  19. package/templates/base/src/middlewares/errorHandler.js +155 -0
  20. package/templates/base/src/middlewares/index.js +49 -0
  21. package/templates/base/src/middlewares/rateLimiter.js +85 -0
  22. package/templates/base/src/middlewares/requestLogger.js +50 -0
  23. package/templates/base/src/middlewares/validator.js +107 -0
  24. package/templates/base/src/models/example.js +117 -0
  25. package/templates/base/src/models/index.js +6 -0
  26. package/templates/base/src/routes/v1/exampleRoutes.js +89 -0
  27. package/templates/base/src/routes/v1/index.js +19 -0
  28. package/templates/base/src/server.js +80 -0
  29. package/templates/base/src/utils/logger.js +61 -0
  30. package/templates/base/src/utils/response.js +117 -0
  31. package/templates/base/tests/app.test.js +215 -0
  32. package/templates/base/tests/setup.js +33 -0
@@ -0,0 +1,481 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const inquirer = require('inquirer');
5
+ const ora = require('ora');
6
+ const { execSync } = require('child_process');
7
+
8
+ const TEMPLATE_DIR = path.join(__dirname, '..', '..', 'templates', 'base');
9
+
10
+ async function create(projectName, options) {
11
+ console.log(chalk.cyan(`
12
+ ╦ ╦╔═╗╔╦╗╔═╗╔╗ ╦ ╔═╗╦ ╔═╗╔╗ ╔═╗
13
+ ║ ║╚═╗ ║ ╠═╣╠╩╗║ ║╣ ║ ╠═╣╠╩╗╚═╗
14
+ ╩═╝╩╚═╝ ╩ ╩ ╩╚═╝╩═╝╚═╝╩═╝╩ ╩╚═╝╚═╝
15
+ `));
16
+ console.log(chalk.gray(' Microservice Generator\n'));
17
+
18
+ // Validate project name
19
+ const validName = projectName
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9-]/g, '-')
22
+ .replace(/-+/g, '-')
23
+ .replace(/^-|-$/g, '');
24
+
25
+ if (validName !== projectName) {
26
+ console.log(chalk.yellow(` Project name normalized to: ${validName}\n`));
27
+ }
28
+
29
+ const targetDir = path.resolve(options.directory, validName);
30
+
31
+ // Check if directory exists
32
+ if (fs.existsSync(targetDir)) {
33
+ const { overwrite } = await inquirer.prompt([
34
+ {
35
+ type: 'confirm',
36
+ name: 'overwrite',
37
+ message: `Directory ${validName} already exists. Overwrite?`,
38
+ default: false,
39
+ },
40
+ ]);
41
+
42
+ if (!overwrite) {
43
+ console.log(chalk.red(' Aborted.'));
44
+ process.exit(1);
45
+ }
46
+ fs.removeSync(targetDir);
47
+ }
48
+
49
+ // Get project configuration
50
+ let config;
51
+ if (options.yes) {
52
+ config = {
53
+ serviceName: validName,
54
+ description: `${validName} microservice`,
55
+ port: 3000,
56
+ features: ['logging', 'validation', 'rate-limiting', 'error-handling'],
57
+ mongodbUri: '',
58
+ };
59
+ } else {
60
+ config = await inquirer.prompt([
61
+ {
62
+ type: 'input',
63
+ name: 'serviceName',
64
+ message: 'Service name:',
65
+ default: validName,
66
+ },
67
+ {
68
+ type: 'input',
69
+ name: 'description',
70
+ message: 'Description:',
71
+ default: `${validName} microservice`,
72
+ },
73
+ {
74
+ type: 'number',
75
+ name: 'port',
76
+ message: 'Port:',
77
+ default: 3000,
78
+ },
79
+ {
80
+ type: 'input',
81
+ name: 'mongodbUri',
82
+ message: 'MongoDB Atlas URI (leave blank to configure later):',
83
+ default: '',
84
+ },
85
+ {
86
+ type: 'checkbox',
87
+ name: 'features',
88
+ message: 'Select additional features:',
89
+ choices: [
90
+ { name: 'JWT Authentication', value: 'auth', checked: false },
91
+ { name: 'Swagger/OpenAPI Docs', value: 'swagger', checked: false },
92
+ ],
93
+ },
94
+ ]);
95
+ }
96
+
97
+ // Create project
98
+ const spinner = ora('Creating project structure...').start();
99
+
100
+ try {
101
+ // Copy template files
102
+ await fs.copy(TEMPLATE_DIR, targetDir);
103
+
104
+ // Update package.json
105
+ const pkgPath = path.join(targetDir, 'package.json');
106
+ const pkg = await fs.readJson(pkgPath);
107
+ pkg.name = config.serviceName;
108
+ pkg.description = config.description;
109
+
110
+ // Add auth dependencies if selected
111
+ if (config.features.includes('auth')) {
112
+ pkg.dependencies.jsonwebtoken = '^9.0.2';
113
+ pkg.dependencies.bcryptjs = '^2.4.3';
114
+ }
115
+
116
+ // Add swagger if selected
117
+ if (config.features.includes('swagger')) {
118
+ pkg.dependencies['swagger-jsdoc'] = '^6.2.8';
119
+ pkg.dependencies['swagger-ui-express'] = '^5.0.0';
120
+ }
121
+
122
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
123
+
124
+ // Update .env.example and create .env
125
+ const envPath = path.join(targetDir, '.env.example');
126
+ let envContent = await fs.readFile(envPath, 'utf8');
127
+ envContent = envContent.replace('SERVICE_NAME=your-service-name', `SERVICE_NAME=${config.serviceName}`);
128
+ envContent = envContent.replace('PORT=3000', `PORT=${config.port}`);
129
+
130
+ // Set MongoDB URI if provided
131
+ if (config.mongodbUri) {
132
+ envContent = envContent.replace(
133
+ 'MONGODB_URI=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/<database>?retryWrites=true&w=majority',
134
+ `MONGODB_URI=${config.mongodbUri}`
135
+ );
136
+ }
137
+
138
+ // Add JWT env vars if auth selected
139
+ if (config.features.includes('auth')) {
140
+ envContent += `
141
+ # JWT Authentication
142
+ JWT_SECRET=your-super-secret-key-change-in-production
143
+ JWT_EXPIRES_IN=7d
144
+ `;
145
+ }
146
+
147
+ await fs.writeFile(envPath, envContent);
148
+ await fs.copy(envPath, path.join(targetDir, '.env'));
149
+
150
+ // Add auth middleware if selected
151
+ if (config.features.includes('auth')) {
152
+ await createAuthMiddleware(targetDir);
153
+ }
154
+
155
+ // Add swagger if selected
156
+ if (config.features.includes('swagger')) {
157
+ await createSwaggerSetup(targetDir);
158
+ }
159
+
160
+ spinner.succeed('Project structure created');
161
+
162
+ // Initialize git
163
+ if (!options.skipGit) {
164
+ const gitSpinner = ora('Initializing git repository...').start();
165
+ try {
166
+ execSync('git init', { cwd: targetDir, stdio: 'ignore' });
167
+ execSync('git add -A', { cwd: targetDir, stdio: 'ignore' });
168
+ execSync('git commit -m "Initial commit from @listablelabs/create-api"', {
169
+ cwd: targetDir,
170
+ stdio: 'ignore',
171
+ });
172
+ gitSpinner.succeed('Git repository initialized');
173
+ } catch (err) {
174
+ gitSpinner.warn('Git initialization failed (git may not be installed)');
175
+ }
176
+ }
177
+
178
+ // Install dependencies
179
+ if (!options.skipInstall) {
180
+ const installSpinner = ora('Installing dependencies...').start();
181
+ try {
182
+ execSync('npm install', { cwd: targetDir, stdio: 'ignore' });
183
+ installSpinner.succeed('Dependencies installed');
184
+ } catch (err) {
185
+ installSpinner.fail('Failed to install dependencies. Run npm install manually.');
186
+ }
187
+ }
188
+
189
+ // Success message
190
+ console.log(chalk.green('\n ✔ Project created successfully!\n'));
191
+ console.log(chalk.white(' Stack: Express + Zod + Pino + MongoDB Atlas\n'));
192
+ console.log(chalk.white(' Next steps:\n'));
193
+ console.log(chalk.cyan(` cd ${validName}`));
194
+ if (options.skipInstall) {
195
+ console.log(chalk.cyan(' npm install'));
196
+ }
197
+ if (!config.mongodbUri) {
198
+ console.log(chalk.yellow(' # Add your MongoDB Atlas URI to .env'));
199
+ }
200
+ console.log(chalk.cyan(' npm run dev\n'));
201
+ console.log(chalk.gray(` Your API will be running at http://localhost:${config.port}`));
202
+ console.log(chalk.gray(' Health check: GET /health'));
203
+ console.log(chalk.gray(' API routes: GET /api/v1/examples\n'));
204
+
205
+ } catch (err) {
206
+ spinner.fail('Failed to create project');
207
+ console.error(chalk.red(err.message));
208
+ process.exit(1);
209
+ }
210
+ }
211
+
212
+ async function createAuthMiddleware(targetDir) {
213
+ const authContent = `const jwt = require('jsonwebtoken');
214
+ const { UnauthorizedError } = require('./errorHandler');
215
+
216
+ /**
217
+ * JWT Authentication middleware
218
+ * Verifies JWT token from Authorization header
219
+ */
220
+ const authenticate = (req, res, next) => {
221
+ const authHeader = req.headers.authorization;
222
+
223
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
224
+ throw new UnauthorizedError('No token provided');
225
+ }
226
+
227
+ const token = authHeader.split(' ')[1];
228
+
229
+ try {
230
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
231
+ req.user = decoded;
232
+ next();
233
+ } catch (err) {
234
+ throw new UnauthorizedError('Invalid or expired token');
235
+ }
236
+ };
237
+
238
+ /**
239
+ * Optional authentication - sets req.user if valid token exists
240
+ */
241
+ const optionalAuth = (req, res, next) => {
242
+ const authHeader = req.headers.authorization;
243
+
244
+ if (authHeader && authHeader.startsWith('Bearer ')) {
245
+ const token = authHeader.split(' ')[1];
246
+ try {
247
+ req.user = jwt.verify(token, process.env.JWT_SECRET);
248
+ } catch (err) {
249
+ // Token invalid, but that's okay for optional auth
250
+ }
251
+ }
252
+
253
+ next();
254
+ };
255
+
256
+ /**
257
+ * Generate JWT token
258
+ */
259
+ const generateToken = (payload) => {
260
+ return jwt.sign(payload, process.env.JWT_SECRET, {
261
+ expiresIn: process.env.JWT_EXPIRES_IN || '7d',
262
+ });
263
+ };
264
+
265
+ module.exports = { authenticate, optionalAuth, generateToken };
266
+ `;
267
+
268
+ await fs.writeFile(path.join(targetDir, 'src', 'middlewares', 'auth.js'), authContent);
269
+
270
+ // Update middleware index
271
+ const indexPath = path.join(targetDir, 'src', 'middlewares', 'index.js');
272
+ let indexContent = await fs.readFile(indexPath, 'utf8');
273
+ indexContent = indexContent.replace(
274
+ "const { defaultLimiter, strictLimiter, createLimiter } = require('./rateLimiter');",
275
+ `const { defaultLimiter, strictLimiter, createLimiter } = require('./rateLimiter');
276
+ const { authenticate, optionalAuth, generateToken } = require('./auth');`
277
+ );
278
+ indexContent = indexContent.replace(
279
+ ' createLimiter,\n};',
280
+ ` createLimiter,
281
+
282
+ // Authentication
283
+ authenticate,
284
+ optionalAuth,
285
+ generateToken,
286
+ };`
287
+ );
288
+ await fs.writeFile(indexPath, indexContent);
289
+ }
290
+
291
+ async function createDatabaseConnection(targetDir, dbType) {
292
+ let dbContent;
293
+
294
+ if (dbType === 'postgres') {
295
+ dbContent = `const { Pool } = require('pg');
296
+ const { logger } = require('../utils/logger');
297
+ const config = require('../config');
298
+
299
+ const pool = new Pool({
300
+ host: process.env.DB_HOST,
301
+ port: process.env.DB_PORT,
302
+ database: process.env.DB_NAME,
303
+ user: process.env.DB_USER,
304
+ password: process.env.DB_PASSWORD,
305
+ max: 20, // Max connections in pool
306
+ idleTimeoutMillis: 30000,
307
+ connectionTimeoutMillis: 2000,
308
+ });
309
+
310
+ pool.on('connect', () => {
311
+ logger.debug('New database connection established');
312
+ });
313
+
314
+ pool.on('error', (err) => {
315
+ logger.error({ err }, 'Unexpected database error');
316
+ });
317
+
318
+ /**
319
+ * Query helper with logging
320
+ */
321
+ const query = async (text, params) => {
322
+ const start = Date.now();
323
+ try {
324
+ const result = await pool.query(text, params);
325
+ const duration = Date.now() - start;
326
+ logger.debug({ query: text, duration, rows: result.rowCount }, 'Database query executed');
327
+ return result;
328
+ } catch (err) {
329
+ logger.error({ err, query: text }, 'Database query failed');
330
+ throw err;
331
+ }
332
+ };
333
+
334
+ /**
335
+ * Get a client from the pool for transactions
336
+ */
337
+ const getClient = () => pool.connect();
338
+
339
+ /**
340
+ * Health check for database
341
+ */
342
+ const healthCheck = async () => {
343
+ try {
344
+ await pool.query('SELECT 1');
345
+ return true;
346
+ } catch (err) {
347
+ return false;
348
+ }
349
+ };
350
+
351
+ module.exports = { pool, query, getClient, healthCheck };
352
+ `;
353
+ } else if (dbType === 'mongodb') {
354
+ dbContent = `const mongoose = require('mongoose');
355
+ const { logger } = require('../utils/logger');
356
+
357
+ const connectDB = async () => {
358
+ try {
359
+ const conn = await mongoose.connect(process.env.MONGODB_URI, {
360
+ maxPoolSize: 10,
361
+ });
362
+ logger.info({ host: conn.connection.host }, 'MongoDB connected');
363
+ } catch (err) {
364
+ logger.fatal({ err }, 'MongoDB connection failed');
365
+ process.exit(1);
366
+ }
367
+ };
368
+
369
+ mongoose.connection.on('disconnected', () => {
370
+ logger.warn('MongoDB disconnected');
371
+ });
372
+
373
+ mongoose.connection.on('error', (err) => {
374
+ logger.error({ err }, 'MongoDB error');
375
+ });
376
+
377
+ /**
378
+ * Health check for database
379
+ */
380
+ const healthCheck = async () => {
381
+ return mongoose.connection.readyState === 1;
382
+ };
383
+
384
+ module.exports = { connectDB, healthCheck };
385
+ `;
386
+ } else if (dbType === 'mysql') {
387
+ dbContent = `const mysql = require('mysql2/promise');
388
+ const { logger } = require('../utils/logger');
389
+
390
+ const pool = mysql.createPool({
391
+ host: process.env.DB_HOST,
392
+ port: process.env.DB_PORT,
393
+ database: process.env.DB_NAME,
394
+ user: process.env.DB_USER,
395
+ password: process.env.DB_PASSWORD,
396
+ waitForConnections: true,
397
+ connectionLimit: 20,
398
+ queueLimit: 0,
399
+ });
400
+
401
+ /**
402
+ * Query helper with logging
403
+ */
404
+ const query = async (sql, params) => {
405
+ const start = Date.now();
406
+ try {
407
+ const [rows] = await pool.execute(sql, params);
408
+ const duration = Date.now() - start;
409
+ logger.debug({ query: sql, duration }, 'Database query executed');
410
+ return rows;
411
+ } catch (err) {
412
+ logger.error({ err, query: sql }, 'Database query failed');
413
+ throw err;
414
+ }
415
+ };
416
+
417
+ /**
418
+ * Health check for database
419
+ */
420
+ const healthCheck = async () => {
421
+ try {
422
+ await pool.query('SELECT 1');
423
+ return true;
424
+ } catch (err) {
425
+ return false;
426
+ }
427
+ };
428
+
429
+ module.exports = { pool, query, healthCheck };
430
+ `;
431
+ }
432
+
433
+ await fs.ensureDir(path.join(targetDir, 'src', 'database'));
434
+ await fs.writeFile(path.join(targetDir, 'src', 'database', 'index.js'), dbContent);
435
+ }
436
+
437
+ async function createSwaggerSetup(targetDir) {
438
+ const swaggerContent = `const swaggerJsdoc = require('swagger-jsdoc');
439
+ const swaggerUi = require('swagger-ui-express');
440
+ const config = require('../config');
441
+
442
+ const options = {
443
+ definition: {
444
+ openapi: '3.0.0',
445
+ info: {
446
+ title: \`\${config.serviceName} API\`,
447
+ version: '1.0.0',
448
+ description: \`API documentation for \${config.serviceName}\`,
449
+ },
450
+ servers: [
451
+ {
452
+ url: \`http://localhost:\${config.port}\`,
453
+ description: 'Development server',
454
+ },
455
+ ],
456
+ components: {
457
+ securitySchemes: {
458
+ bearerAuth: {
459
+ type: 'http',
460
+ scheme: 'bearer',
461
+ bearerFormat: 'JWT',
462
+ },
463
+ },
464
+ },
465
+ },
466
+ apis: ['./src/routes/**/*.js'],
467
+ };
468
+
469
+ const specs = swaggerJsdoc(options);
470
+
471
+ const setupSwagger = (app) => {
472
+ app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
473
+ };
474
+
475
+ module.exports = { setupSwagger, specs };
476
+ `;
477
+
478
+ await fs.writeFile(path.join(targetDir, 'src', 'utils', 'swagger.js'), swaggerContent);
479
+ }
480
+
481
+ module.exports = create;
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "create-listablelabs-api",
3
+ "version": "1.0.1",
4
+ "description": "CLI to scaffold ListableLabs microservices",
5
+ "bin": {
6
+ "create-listablelabs-api": "./bin/cli.js",
7
+ "listablelabs": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "templates"
12
+ ],
13
+ "keywords": [
14
+ "listablelabs",
15
+ "microservice",
16
+ "api",
17
+ "scaffold",
18
+ "cli",
19
+ "express",
20
+ "mongodb",
21
+ "zod"
22
+ ],
23
+ "author": "ListableLabs",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "chalk": "^4.1.2",
27
+ "commander": "^11.1.0",
28
+ "fs-extra": "^11.2.0",
29
+ "inquirer": "^8.2.6",
30
+ "ora": "^5.4.1"
31
+ },
32
+ "engines": {
33
+ "node": ">=16.0.0"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/listablelabs/create-api.git"
38
+ }
39
+ }
@@ -0,0 +1,19 @@
1
+ node_modules
2
+ npm-debug.log
3
+ .env
4
+ .env.local
5
+ .git
6
+ .gitignore
7
+ .dockerignore
8
+ Dockerfile
9
+ docker-compose*.yml
10
+ README.md
11
+ .nyc_output
12
+ coverage
13
+ .vscode
14
+ .idea
15
+ *.md
16
+ tests
17
+ __tests__
18
+ *.test.js
19
+ *.spec.js
@@ -0,0 +1,18 @@
1
+ # Application
2
+ NODE_ENV=development
3
+ PORT=3000
4
+ SERVICE_NAME=your-service-name
5
+
6
+ # Logging
7
+ LOG_LEVEL=info
8
+
9
+ # Rate Limiting
10
+ RATE_LIMIT_WINDOW_MS=60000
11
+ RATE_LIMIT_MAX_REQUESTS=100
12
+
13
+ # MongoDB Atlas
14
+ MONGODB_URI=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/<database>?retryWrites=true&w=majority
15
+
16
+ # JWT (uncomment and configure as needed)
17
+ # JWT_SECRET=your-super-secret-key
18
+ # JWT_EXPIRES_IN=1d
@@ -0,0 +1,31 @@
1
+ module.exports = {
2
+ env: {
3
+ node: true,
4
+ es2022: true,
5
+ jest: true,
6
+ },
7
+ extends: ['eslint:recommended'],
8
+ parserOptions: {
9
+ ecmaVersion: 'latest',
10
+ sourceType: 'module',
11
+ },
12
+ rules: {
13
+ // Error prevention
14
+ 'no-console': 'warn', // Use logger instead
15
+ 'no-unused-vars': ['error', { argsIgnorePattern: '^_|next' }],
16
+ 'no-return-await': 'error',
17
+
18
+ // Style consistency
19
+ 'semi': ['error', 'always'],
20
+ 'quotes': ['error', 'single', { avoidEscape: true }],
21
+ 'comma-dangle': ['error', 'always-multiline'],
22
+ 'indent': ['error', 2],
23
+
24
+ // Best practices
25
+ 'eqeqeq': ['error', 'always'],
26
+ 'curly': ['error', 'all'],
27
+ 'no-var': 'error',
28
+ 'prefer-const': 'error',
29
+ 'prefer-arrow-callback': 'error',
30
+ },
31
+ };
@@ -0,0 +1,48 @@
1
+ # Build stage
2
+ FROM node:20-alpine AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Copy package files
7
+ COPY package*.json ./
8
+
9
+ # Install dependencies (including devDependencies for build)
10
+ RUN npm ci
11
+
12
+ # Copy source code
13
+ COPY . .
14
+
15
+ # Production stage
16
+ FROM node:20-alpine AS production
17
+
18
+ # Create non-root user for security
19
+ RUN addgroup -g 1001 -S nodejs && \
20
+ adduser -S nodejs -u 1001
21
+
22
+ WORKDIR /app
23
+
24
+ # Copy package files
25
+ COPY package*.json ./
26
+
27
+ # Install only production dependencies
28
+ RUN npm ci --only=production && \
29
+ npm cache clean --force
30
+
31
+ # Copy application code
32
+ COPY --from=builder /app/src ./src
33
+
34
+ # Set ownership to non-root user
35
+ RUN chown -R nodejs:nodejs /app
36
+
37
+ # Switch to non-root user
38
+ USER nodejs
39
+
40
+ # Expose port
41
+ EXPOSE 3000
42
+
43
+ # Health check
44
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
45
+ CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
46
+
47
+ # Start the application
48
+ CMD ["node", "src/server.js"]