webspresso 0.0.99 → 0.0.101

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 (108) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +4 -2
  3. package/.agents/AUTH.md +309 -0
  4. package/.agents/CLI.md +128 -0
  5. package/.agents/EMAIL.md +84 -2
  6. package/.agents/ERRORS.md +59 -0
  7. package/.agents/EVENTS.md +179 -0
  8. package/.agents/ORM.md +149 -0
  9. package/.agents/PLUGINS.md +44 -7
  10. package/.agents/QUEUE.md +18 -10
  11. package/.agents/ROUTING.md +97 -1
  12. package/.agents/STREAMING.md +35 -0
  13. package/AGENTS.md +2 -0
  14. package/README.md +145 -93
  15. package/bin/commands/add-docker.js +467 -0
  16. package/bin/commands/db-makemigrations.js +83 -0
  17. package/bin/commands/deploy-check.js +339 -0
  18. package/bin/commands/list.js +96 -0
  19. package/bin/commands/make-command.js +93 -0
  20. package/bin/commands/make-event.js +80 -0
  21. package/bin/commands/make-factory.js +72 -0
  22. package/bin/commands/make-mail.js +93 -0
  23. package/bin/commands/make-policy.js +106 -0
  24. package/bin/commands/run.js +195 -0
  25. package/bin/webspresso.js +21 -0
  26. package/core/auth/bouncer.js +196 -0
  27. package/core/auth/index.js +73 -1
  28. package/core/auth/magic-link.js +170 -0
  29. package/core/auth/manager.js +343 -3
  30. package/core/auth/middleware.js +45 -6
  31. package/core/auth/session-manager.js +255 -0
  32. package/core/auth/sessions.js +351 -0
  33. package/core/auth/tokens.js +1 -0
  34. package/core/auth/totp.js +4 -1
  35. package/core/auth/user-agent.js +161 -0
  36. package/core/commands/define-command.js +48 -0
  37. package/core/commands/index.js +18 -0
  38. package/core/commands/registry.js +156 -0
  39. package/core/commands/runner.js +76 -0
  40. package/core/compression/compressible.js +1 -1
  41. package/core/compression/middleware.js +35 -251
  42. package/core/content/service.js +8 -2
  43. package/core/events/discovery.js +142 -0
  44. package/core/events/dispatcher.js +552 -0
  45. package/core/events/index.js +23 -0
  46. package/core/events/signal.js +92 -0
  47. package/core/forms/flash.js +234 -0
  48. package/core/forms/form-builder.js +214 -0
  49. package/core/forms/index.js +16 -0
  50. package/core/mail/index.js +23 -0
  51. package/core/mail/mailable.js +250 -0
  52. package/core/mail/mailer.js +184 -0
  53. package/core/mail/transports.js +238 -0
  54. package/core/orm/factory.js +250 -0
  55. package/core/orm/index.js +8 -1
  56. package/core/orm/migrations/diff.js +185 -0
  57. package/core/orm/migrations/index.js +22 -4
  58. package/core/orm/model.js +55 -1
  59. package/core/orm/repository.js +283 -13
  60. package/core/orm/scopes.js +3 -2
  61. package/core/orm/types.js +6 -1
  62. package/core/queue/cron.js +252 -39
  63. package/core/queue/manager.js +20 -2
  64. package/core/queue/scheduler.js +115 -59
  65. package/core/ssr/stream.js +62 -2
  66. package/index.d.ts +540 -14
  67. package/index.js +44 -0
  68. package/package.json +11 -5
  69. package/plugins/admin-panel/api.js +509 -3
  70. package/plugins/admin-panel/index.js +17 -0
  71. package/plugins/admin-panel/modules/admin-users.js +1 -1
  72. package/plugins/audit-log/admin-component.js +54 -54
  73. package/plugins/audit-log/index.js +2 -2
  74. package/plugins/content/client/inline-edit.js +2 -46
  75. package/plugins/content/models/content-entry.js +4 -3
  76. package/plugins/content/models/content-type.js +4 -3
  77. package/plugins/cors.js +15 -4
  78. package/plugins/email/auth-bridge.js +66 -0
  79. package/plugins/email/bundled-templates.js +15 -0
  80. package/plugins/email/index.js +48 -0
  81. package/plugins/email/preview-handler.js +555 -0
  82. package/plugins/email/service.js +23 -12
  83. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  84. package/plugins/email/transport.js +30 -11
  85. package/plugins/file-manager/admin-component.js +49 -49
  86. package/plugins/file-manager/index.js +4 -4
  87. package/plugins/index.js +2 -0
  88. package/plugins/redirect/index.js +1 -1
  89. package/plugins/webhooks/admin-component.js +608 -0
  90. package/plugins/webhooks/api-handlers.js +237 -0
  91. package/plugins/webhooks/crypto.js +144 -0
  92. package/plugins/webhooks/dispatcher.js +708 -0
  93. package/plugins/webhooks/index.js +263 -0
  94. package/plugins/webhooks/matcher.js +113 -0
  95. package/plugins/webhooks/migration-template.js +75 -0
  96. package/plugins/webhooks/models.js +98 -0
  97. package/plugins/webhooks/sender.js +176 -0
  98. package/src/api/api-loader.js +1 -1
  99. package/src/app-context.js +48 -6
  100. package/src/bundle/index.js +2 -1
  101. package/src/dev-reload/index.js +9 -1
  102. package/src/discovery/scan-routes.js +10 -1
  103. package/src/file-router.js +173 -4
  104. package/src/helpers.js +150 -1
  105. package/src/njk-frontmatter.js +3 -0
  106. package/src/pages/page-loader.js +1 -1
  107. package/src/routing/cascading.js +120 -4
  108. package/src/server.js +180 -26
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Deploy Check Command (Production Preflight Verification)
3
+ * Validates environment, secrets, container hardening, database migrations, and health checks
4
+ * @module bin/commands/deploy-check
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
12
+
13
+ /**
14
+ * Known weak secret patterns that should never be used in production
15
+ */
16
+ const WEAK_SECRET_PATTERNS = [
17
+ /change-me/i,
18
+ /your-secret/i,
19
+ /super-secret/i,
20
+ /secret-key/i,
21
+ /webspresso-secret/i,
22
+ /123456/,
23
+ /password/i,
24
+ /^secret$/i,
25
+ /^default$/i,
26
+ /^test$/i,
27
+ ];
28
+
29
+ /**
30
+ * Load .env file into key-value map without modifying process.env
31
+ * @param {string} envPath
32
+ * @returns {Record<string, string>}
33
+ */
34
+ function parseEnvFile(envPath) {
35
+ if (!fs.existsSync(envPath)) return {};
36
+ const content = fs.readFileSync(envPath, 'utf8');
37
+ const result = {};
38
+ for (const line of content.split('\n')) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed || trimmed.startsWith('#')) continue;
41
+ const eqIdx = trimmed.indexOf('=');
42
+ if (eqIdx > 0) {
43
+ const key = trimmed.slice(0, eqIdx).trim();
44
+ let val = trimmed.slice(eqIdx + 1).trim();
45
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
46
+ val = val.slice(1, -1);
47
+ }
48
+ result[key] = val;
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+
54
+ /**
55
+ * Run production preflight checks
56
+ * @param {Object} options
57
+ * @param {string} [options.cwd=process.cwd()]
58
+ * @param {string} [options.env='production']
59
+ * @returns {Promise<{ passed: boolean, errors: Array<{ check: string, message: string }>, warnings: Array<{ check: string, message: string }>, successes: Array<{ check: string, message: string }> }>}
60
+ */
61
+ async function runDeployCheck(options = {}) {
62
+ const cwd = options.cwd || process.cwd();
63
+ const targetEnv = options.env || 'production';
64
+
65
+ const successes = [];
66
+ const warnings = [];
67
+ const errors = [];
68
+
69
+ const addPass = (check, message) => successes.push({ check, message });
70
+ const addWarn = (check, message) => warnings.push({ check, message });
71
+ const addError = (check, message) => errors.push({ check, message });
72
+
73
+ // Load environment variables from process.env and local .env files
74
+ const localEnv = {
75
+ ...parseEnvFile(path.join(cwd, '.env')),
76
+ ...parseEnvFile(path.join(cwd, `.env.${targetEnv}`)),
77
+ ...process.env,
78
+ };
79
+
80
+ // ----------------------------------------------------
81
+ // 1. Project & Server Basics
82
+ // ----------------------------------------------------
83
+ const pkgPath = path.join(cwd, 'package.json');
84
+ let pkg = {};
85
+ if (!fs.existsSync(pkgPath)) {
86
+ addError('package.json', 'package.json not found in current directory');
87
+ } else {
88
+ try {
89
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
90
+ addPass('package.json', `Readable (${pkg.name || 'unnamed'})`);
91
+ } catch (e) {
92
+ addError('package.json', `Invalid JSON: ${e.message}`);
93
+ }
94
+ }
95
+
96
+ const serverPath = path.join(cwd, 'server.js');
97
+ let serverCode = '';
98
+ if (fs.existsSync(serverPath)) {
99
+ serverCode = fs.readFileSync(serverPath, 'utf8');
100
+ addPass('server.js', 'server.js entrypoint found');
101
+ } else {
102
+ addError('server.js', 'server.js not found (required for production runtime)');
103
+ }
104
+
105
+ // ----------------------------------------------------
106
+ // 2. Production Secrets & Cryptography
107
+ // ----------------------------------------------------
108
+ const sessionSecret = localEnv.SESSION_SECRET || localEnv.AUTH_SESSION_SECRET || localEnv.APP_KEY || '';
109
+ if (!sessionSecret) {
110
+ addError('SESSION_SECRET', 'SESSION_SECRET or APP_KEY environment variable is not set');
111
+ } else if (sessionSecret.length < 32) {
112
+ addError('SESSION_SECRET', `SESSION_SECRET is only ${sessionSecret.length} chars (must be >= 32 chars in production)`);
113
+ } else if (WEAK_SECRET_PATTERNS.some((p) => p.test(sessionSecret))) {
114
+ addError('SESSION_SECRET', 'SESSION_SECRET appears to use a default or easily guessable placeholder');
115
+ } else {
116
+ addPass('SESSION_SECRET', 'Sufficiently strong cryptographic secret configured (>= 32 chars)');
117
+ }
118
+
119
+ const jwtSecret = localEnv.JWT_SECRET || '';
120
+ if (jwtSecret) {
121
+ if (jwtSecret.length < 32) {
122
+ addWarn('JWT_SECRET', `JWT_SECRET is only ${jwtSecret.length} chars (recommend >= 32 chars)`);
123
+ } else if (WEAK_SECRET_PATTERNS.some((p) => p.test(jwtSecret))) {
124
+ addError('JWT_SECRET', 'JWT_SECRET uses an insecure default placeholder');
125
+ } else {
126
+ addPass('JWT_SECRET', 'Strong JWT secret configured (>= 32 chars)');
127
+ }
128
+ }
129
+
130
+ // ----------------------------------------------------
131
+ // 3. Git & File Security (.gitignore / .dockerignore)
132
+ // ----------------------------------------------------
133
+ const gitignorePath = path.join(cwd, '.gitignore');
134
+ if (fs.existsSync(gitignorePath)) {
135
+ const gitignore = fs.readFileSync(gitignorePath, 'utf8');
136
+ if (!gitignore.includes('.env')) {
137
+ addError('.gitignore', '.env is not ignored in .gitignore (risk of leaking secrets to git)');
138
+ } else {
139
+ addPass('.gitignore', '.env files properly excluded from version control');
140
+ }
141
+
142
+ if (gitignore.includes('.sqlite') || gitignore.includes('*.db')) {
143
+ addPass('.gitignore', 'Local database files (*.sqlite, *.db) excluded from git');
144
+ }
145
+ } else {
146
+ addWarn('.gitignore', '.gitignore file not found');
147
+ }
148
+
149
+ const dockerignorePath = path.join(cwd, '.dockerignore');
150
+ if (fs.existsSync(dockerignorePath)) {
151
+ const dignore = fs.readFileSync(dockerignorePath, 'utf8');
152
+ if (!dignore.includes('node_modules') || !dignore.includes('.env')) {
153
+ addWarn('.dockerignore', '.dockerignore should explicitly exclude node_modules and .env');
154
+ } else {
155
+ addPass('.dockerignore', 'node_modules and .env excluded from Docker build context');
156
+ }
157
+ } else {
158
+ addWarn('.dockerignore', '.dockerignore not found (recommend running: npx webspresso add:docker)');
159
+ }
160
+
161
+ // ----------------------------------------------------
162
+ // 4. Docker & Container Hardening
163
+ // ----------------------------------------------------
164
+ const dockerfilePath = path.join(cwd, 'Dockerfile');
165
+ if (fs.existsSync(dockerfilePath)) {
166
+ const dockerfile = fs.readFileSync(dockerfilePath, 'utf8');
167
+
168
+ if (/USER\s+(?!root\b)\w+/i.test(dockerfile)) {
169
+ addPass('Dockerfile User', 'Container executes as non-root unprivileged user');
170
+ } else {
171
+ addWarn('Dockerfile User', 'Container does not appear to switch to a non-root USER (security risk)');
172
+ }
173
+
174
+ if (/HEALTHCHECK/i.test(dockerfile)) {
175
+ addPass('Dockerfile Healthcheck', 'Container HEALTHCHECK instruction configured');
176
+ } else {
177
+ addWarn('Dockerfile Healthcheck', 'No HEALTHCHECK instruction found in Dockerfile');
178
+ }
179
+
180
+ if (/FROM.*AS\s+\w+/i.test(dockerfile)) {
181
+ addPass('Dockerfile Multi-Stage', 'Multi-stage Docker build configured');
182
+ } else {
183
+ addWarn('Dockerfile Multi-Stage', 'Single-stage Dockerfile detected (multi-stage recommended for smaller image size)');
184
+ }
185
+ } else {
186
+ addWarn('Dockerfile', 'Dockerfile not found (run `npx webspresso add:docker` to generate)');
187
+ }
188
+
189
+ // ----------------------------------------------------
190
+ // 5. Database & Migrations
191
+ // ----------------------------------------------------
192
+ const migrationsDir = path.join(cwd, 'migrations');
193
+ if (fs.existsSync(migrationsDir)) {
194
+ const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.js') || f.endsWith('.ts'));
195
+ addPass('Migrations', `Found ${files.length} migration file(s) in migrations/`);
196
+
197
+ // Try verifying migration status if db config exists
198
+ const dbConfig = resolveDbConfigIfExists(cwd, targetEnv);
199
+ if (dbConfig) {
200
+ try {
201
+ const db = createDbInstance(dbConfig);
202
+ const status = await db.knex.migrate.status();
203
+ if (status > 0) {
204
+ addWarn('Pending Migrations', `There are ${status} pending database migration(s) to run (run: npx webspresso db:migrate)`);
205
+ } else if (status === 0) {
206
+ addPass('Pending Migrations', 'All database migrations are applied up to date');
207
+ }
208
+ await db.knex.destroy();
209
+ } catch (err) {
210
+ addWarn('Database Connectivity', `Could not check migration status: ${err.message}`);
211
+ }
212
+ }
213
+ } else {
214
+ addPass('Migrations', 'No migrations directory (skipping migration status check)');
215
+ }
216
+
217
+ // ----------------------------------------------------
218
+ // 6. Server Optimizations (Compression & Graceful Shutdown)
219
+ // ----------------------------------------------------
220
+ if (serverCode) {
221
+ if (serverCode.includes('compression: true') || serverCode.includes('compression:')) {
222
+ addPass('HTTP Compression', 'Response compression (Brotli/Gzip) enabled');
223
+ } else {
224
+ addWarn('HTTP Compression', 'Response compression not explicitly enabled in createApp({ server: { compression: true } })');
225
+ }
226
+
227
+ if (serverCode.includes('shutdown:') || serverCode.includes('ShutdownManager')) {
228
+ addPass('Graceful Shutdown', 'Graceful shutdown handling configured');
229
+ } else {
230
+ addPass('Graceful Shutdown', 'Default SIGTERM graceful shutdown active');
231
+ }
232
+
233
+ if (serverCode.includes('healthCheckPlugin') || serverCode.includes('/health') || serverCode.includes('/healthz')) {
234
+ addPass('Health Check Endpoint', 'Health check route configured');
235
+ } else {
236
+ addWarn('Health Check Endpoint', 'No /health or /healthz route found (recommend healthCheckPlugin or health endpoint for orchestrators)');
237
+ }
238
+ }
239
+
240
+ // ----------------------------------------------------
241
+ // 7. Client Assets & Build Scripts
242
+ // ----------------------------------------------------
243
+ if (pkg.scripts && (pkg.scripts['build:css'] || pkg.scripts['build:js'] || pkg.scripts['build'])) {
244
+ const publicDir = path.join(cwd, 'public');
245
+ if (fs.existsSync(publicDir)) {
246
+ addPass('Asset Build', 'Build scripts and public/ directory found');
247
+ } else {
248
+ addWarn('Asset Build', 'Build script defined in package.json but public/ directory is missing');
249
+ }
250
+ }
251
+
252
+ const passed = errors.length === 0;
253
+
254
+ return {
255
+ passed,
256
+ successes,
257
+ warnings,
258
+ errors,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Register command with Commander
264
+ * @param {import('commander').Command} program
265
+ */
266
+ function registerCommand(program) {
267
+ program
268
+ .command('deploy:check')
269
+ .alias('check:deploy')
270
+ .description('Run production preflight check (secrets, container hardening, database, and optimizations)')
271
+ .option('-e, --env <environment>', 'Target environment to evaluate (default: production)', 'production')
272
+ .option('--strict', 'Fail with exit code 1 if any warnings are detected', false)
273
+ .option('--json', 'Output preflight results as JSON', false)
274
+ .action(async (...actionArgs) => {
275
+ const cmd = actionArgs[actionArgs.length - 1];
276
+ const options = (cmd && typeof cmd.opts === 'function')
277
+ ? cmd.opts()
278
+ : (actionArgs.find((a) => a && typeof a === 'object') || {});
279
+
280
+ const result = await runDeployCheck({
281
+ cwd: process.cwd(),
282
+ env: options.env || 'production',
283
+ });
284
+
285
+ if (options.json) {
286
+ console.log(JSON.stringify(result, null, 2));
287
+ if (!result.passed || (options.strict && result.warnings.length > 0)) {
288
+ process.exit(1);
289
+ }
290
+ return;
291
+ }
292
+
293
+ console.log('\n🚀 Webspresso Production Deployment Preflight');
294
+ console.log('=============================================\n');
295
+
296
+ if (result.successes.length > 0) {
297
+ console.log('Passed Checks:');
298
+ for (const item of result.successes) {
299
+ console.log(` ✅ [${item.check}] ${item.message}`);
300
+ }
301
+ console.log('');
302
+ }
303
+
304
+ if (result.warnings.length > 0) {
305
+ console.log('Warnings:');
306
+ for (const item of result.warnings) {
307
+ console.log(` ⚠️ [${item.check}] ${item.message}`);
308
+ }
309
+ console.log('');
310
+ }
311
+
312
+ if (result.errors.length > 0) {
313
+ console.log('Errors (Must be resolved before production deployment):');
314
+ for (const item of result.errors) {
315
+ console.log(` ❌ [${item.check}] ${item.message}`);
316
+ }
317
+ console.log('');
318
+ }
319
+
320
+ console.log('---------------------------------------------');
321
+ console.log(`Summary: ${result.successes.length} passed, ${result.warnings.length} warning(s), ${result.errors.length} error(s).\n`);
322
+
323
+ const hasFailed = !result.passed || (options.strict && result.warnings.length > 0);
324
+
325
+ if (hasFailed) {
326
+ console.error('❌ Deployment preflight checks FAILED.');
327
+ process.exit(1);
328
+ } else {
329
+ console.log('✅ Deployment preflight checks PASSED! Your application is ready for production.\n');
330
+ }
331
+ });
332
+ }
333
+
334
+ module.exports = {
335
+ registerCommand,
336
+ runDeployCheck,
337
+ parseEnvFile,
338
+ WEAK_SECRET_PATTERNS,
339
+ };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - list Command
5
+ * Lists all built-in and custom project commands with descriptions
6
+ * @module bin/commands/list
7
+ */
8
+
9
+ const path = require('path');
10
+ const { createCommandRegistry } = require('../../core/commands');
11
+
12
+ function registerCommand(program) {
13
+ program
14
+ .command('list')
15
+ .alias('commands')
16
+ .description('List all available Webspresso and custom project management commands')
17
+ .option('-d, --dir <path>', 'Custom commands directory (default: ./commands)')
18
+ .action((options) => {
19
+ const rootDir = process.cwd();
20
+ const commandsDir = options.dir
21
+ ? path.resolve(rootDir, options.dir)
22
+ : path.resolve(rootDir, 'commands');
23
+
24
+ const customRegistry = createCommandRegistry({ commandsDir });
25
+ const customCommands = customRegistry.list();
26
+
27
+ console.log('');
28
+ console.log('☕ Webspresso CLI - Command Palette');
29
+ console.log('===================================');
30
+
31
+ const sections = [
32
+ {
33
+ title: '🚀 Scaffolding & Code Generation',
34
+ commands: [
35
+ ['make:model <name>', 'Scaffold a new ORM model definition in models/'],
36
+ ['make:job <name>', 'Scaffold a background queue job worker in jobs/'],
37
+ ['make:policy <name>', 'Scaffold an authorization policy in policies/'],
38
+ ['make:factory <name>', 'Scaffold a test/seeder model factory in factories/'],
39
+ ['make:event <name>', 'Scaffold an event listener in listeners/'],
40
+ ['make:mail <name>', 'Scaffold a declarative Mailable class in emails/'],
41
+ ['make:command <name>', 'Scaffold a custom management command in commands/'],
42
+ ['page <path>', 'Scaffold a new file-based SSR page'],
43
+ ['api <path>', 'Scaffold a new file-based API route'],
44
+ ],
45
+ },
46
+ {
47
+ title: '💾 Database & Migrations',
48
+ commands: [
49
+ ['db:migrate', 'Run all pending Knex migrations'],
50
+ ['db:makemigrations', 'Auto-diff model schemas against database and generate migration'],
51
+ ['db:rollback', 'Rollback the last batch of migrations'],
52
+ ['db:status', 'Show database connection and migration status'],
53
+ ['db:scaffold', 'Generate initial create table migrations from models/'],
54
+ ['db:make <name>', 'Create an empty Knex migration file'],
55
+ ['seed', 'Run database seeders'],
56
+ ],
57
+ },
58
+ {
59
+ title: '🛠️ Server & Developer Experience',
60
+ commands: [
61
+ ['dev', 'Start local development server with hot-reload'],
62
+ ['start', 'Start production HTTP server'],
63
+ ['build', 'Build client assets and precompile templates'],
64
+ ['routes', 'Inspect all discovered SSR page and API routes'],
65
+ ['doctor', 'Diagnose project configuration, dependencies, and health'],
66
+ ['service [name]', 'Run and test services interactively without HTTP server'],
67
+ ['run <command>', 'Execute a custom management command with full DI context'],
68
+ ['list', 'Display this command index'],
69
+ ],
70
+ },
71
+ ];
72
+
73
+ for (const section of sections) {
74
+ console.log(`\n${section.title}:`);
75
+ for (const [cmd, desc] of section.commands) {
76
+ console.log(` ${cmd.padEnd(28)} ${desc}`);
77
+ }
78
+ }
79
+
80
+ if (customCommands.length > 0) {
81
+ console.log(`\n⚡ Custom Project Commands (${path.relative(rootDir, commandsDir) || 'commands/'}):`);
82
+ for (const cmd of customCommands) {
83
+ console.log(` ${('run ' + cmd.name).padEnd(28)} ${cmd.description || 'Custom management command'}`);
84
+ }
85
+ } else {
86
+ console.log('\n⚡ Custom Project Commands:');
87
+ console.log(' (None discovered. Create with: npx webspresso make:command <name>)');
88
+ }
89
+
90
+ console.log('\nUse "webspresso <command> --help" for more information about any command.\n');
91
+ });
92
+ }
93
+
94
+ module.exports = {
95
+ registerCommand,
96
+ };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - make:command Command
5
+ * Scaffolds a custom management command in commands/<name>.js
6
+ * @module bin/commands/make-command
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ function generateCommandContent(commandName, description = '') {
13
+ return `'use strict';
14
+
15
+ const { defineCommand } = require('webspresso');
16
+
17
+ /**
18
+ * Custom Management Command: ${commandName}
19
+ * Run via: npx webspresso run ${commandName}
20
+ */
21
+ module.exports = defineCommand({
22
+ name: '${commandName}',
23
+ description: '${description || `Execute ${commandName} task`}',
24
+ options: [
25
+ { flags: '-d, --dry-run', description: 'Run without making persistent changes', defaultValue: false },
26
+ ],
27
+ handle: async ({ db, queue, services, mailer, events, logger, args, flags }) => {
28
+ logger.log('Executing "${commandName}"...');
29
+ if (flags.dryRun) {
30
+ logger.log('[Dry Run] No actual changes will be committed.');
31
+ }
32
+
33
+ // Business logic goes here:
34
+ // e.g. const users = await db.getRepository('User').find({ active: false });
35
+
36
+ return { success: true };
37
+ },
38
+ });
39
+ `;
40
+ }
41
+
42
+ function registerCommand(program) {
43
+ program
44
+ .command('make:command <name>')
45
+ .alias('command:make')
46
+ .description('Scaffold a custom management command in commands/<name>.js')
47
+ .option('-d, --desc <description>', 'Command description', '')
48
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
49
+ .action((nameArg, options) => {
50
+ const rootDir = path.resolve(options.root || process.cwd());
51
+ const commandsDir = path.join(rootDir, 'commands');
52
+
53
+ if (!fs.existsSync(commandsDir)) {
54
+ fs.mkdirSync(commandsDir, { recursive: true });
55
+ }
56
+
57
+ const commandName = String(nameArg || '').trim();
58
+ if (!commandName) {
59
+ console.error('❌ Command name is required.');
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+
64
+ // Convert colons or slashes to subdirectories or file path
65
+ // e.g. "users:prune" -> commands/users/prune.js or commands/users-prune.js
66
+ const relPath = commandName.includes(':')
67
+ ? commandName.replace(/:/g, '/') + '.js'
68
+ : `${commandName}.js`;
69
+
70
+ const targetFilePath = path.join(commandsDir, relPath);
71
+ const targetDir = path.dirname(targetFilePath);
72
+
73
+ if (!fs.existsSync(targetDir)) {
74
+ fs.mkdirSync(targetDir, { recursive: true });
75
+ }
76
+
77
+ if (fs.existsSync(targetFilePath)) {
78
+ console.error(`❌ Command file already exists: ${path.relative(rootDir, targetFilePath)}`);
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+
83
+ const content = generateCommandContent(commandName, options.desc);
84
+ fs.writeFileSync(targetFilePath, content, 'utf-8');
85
+ console.log(`\n✅ Created custom management command: ${path.relative(rootDir, targetFilePath)}`);
86
+ console.log(` Execute it with: npx webspresso run ${commandName}\n`);
87
+ });
88
+ }
89
+
90
+ module.exports = {
91
+ registerCommand,
92
+ generateCommandContent,
93
+ };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - make:event Command
5
+ * Scaffolds an event / signal listener in listeners/<name>.js
6
+ * @module bin/commands/make-event
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ function generateEventContent(eventName) {
13
+ return `'use strict';
14
+
15
+ /**
16
+ * Event Listener: ${eventName}
17
+ * Triggered by: app.events.emit('${eventName}', payload)
18
+ */
19
+ module.exports = {
20
+ event: '${eventName}',
21
+
22
+ /**
23
+ * Handle incoming event
24
+ * @param {Object} payload - Event payload
25
+ * @param {Object} ctx - Injected context { events, db, queue, mailer, logger }
26
+ */
27
+ async handle(payload, ctx = {}) {
28
+ const { logger, mailer, queue } = ctx;
29
+ if (logger) {
30
+ logger.log('[Event:${eventName}] Received event payload:', payload);
31
+ }
32
+
33
+ // Example: Dispatch background job or send notification email
34
+ // if (queue) await queue.dispatch('notify', payload);
35
+ },
36
+ };
37
+ `;
38
+ }
39
+
40
+ function registerCommand(program) {
41
+ program
42
+ .command('make:event <name>')
43
+ .alias('event:make')
44
+ .description('Scaffold an event listener in listeners/<name>.js')
45
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
46
+ .action((nameArg, options) => {
47
+ const rootDir = path.resolve(options.root || process.cwd());
48
+ const listenersDir = path.join(rootDir, 'listeners');
49
+
50
+ if (!fs.existsSync(listenersDir)) {
51
+ fs.mkdirSync(listenersDir, { recursive: true });
52
+ }
53
+
54
+ const rawName = String(nameArg || '').trim();
55
+ if (!rawName) {
56
+ console.error('❌ Event name is required.');
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+
61
+ const eventName = rawName.replace(/\.js$/, '');
62
+ const fileName = eventName.replace(/:/g, '-');
63
+ const targetFilePath = path.join(listenersDir, `${fileName}.js`);
64
+
65
+ if (fs.existsSync(targetFilePath)) {
66
+ console.error(`❌ Listener file already exists: ${path.relative(rootDir, targetFilePath)}`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+
71
+ const content = generateEventContent(eventName);
72
+ fs.writeFileSync(targetFilePath, content, 'utf-8');
73
+ console.log(`\n✅ Created event listener: ${path.relative(rootDir, targetFilePath)} (event: "${eventName}")\n`);
74
+ });
75
+ }
76
+
77
+ module.exports = {
78
+ registerCommand,
79
+ generateEventContent,
80
+ };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - make:factory Command
5
+ * Scaffolds an ORM model factory in factories/<name>.factory.js
6
+ * @module bin/commands/make-factory
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ function generateFactoryContent(modelName) {
13
+ const cleanName = modelName.charAt(0).toUpperCase() + modelName.slice(1);
14
+
15
+ return `'use strict';
16
+
17
+ const { defineFactory } = require('webspresso');
18
+
19
+ /**
20
+ * Model Factory: ${cleanName}
21
+ * Used in tests and seeders:
22
+ * const ${cleanName.toLowerCase()} = await ${cleanName}Factory.create({ status: 'active' });
23
+ */
24
+ module.exports = defineFactory('${cleanName}', ({ faker, seq }) => ({
25
+ title: faker ? faker.lorem.words(3) : \`${cleanName} \${seq}\`,
26
+ status: 'active',
27
+ createdAt: new Date(),
28
+ updatedAt: new Date(),
29
+ }));
30
+ `;
31
+ }
32
+
33
+ function registerCommand(program) {
34
+ program
35
+ .command('make:factory <name>')
36
+ .alias('factory:make')
37
+ .description('Scaffold an ORM model factory in factories/<name>.factory.js')
38
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
39
+ .action((nameArg, options) => {
40
+ const rootDir = path.resolve(options.root || process.cwd());
41
+ const factoriesDir = path.join(rootDir, 'factories');
42
+
43
+ if (!fs.existsSync(factoriesDir)) {
44
+ fs.mkdirSync(factoriesDir, { recursive: true });
45
+ }
46
+
47
+ const rawName = String(nameArg || '').trim();
48
+ if (!rawName) {
49
+ console.error('❌ Model name is required for factory.');
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+
54
+ const cleanName = rawName.replace(/\.factory(\.js)?$/, '').replace(/\.js$/, '');
55
+ const targetFilePath = path.join(factoriesDir, `${cleanName.toLowerCase()}.factory.js`);
56
+
57
+ if (fs.existsSync(targetFilePath)) {
58
+ console.error(`❌ Factory file already exists: ${path.relative(rootDir, targetFilePath)}`);
59
+ process.exitCode = 1;
60
+ return;
61
+ }
62
+
63
+ const content = generateFactoryContent(cleanName);
64
+ fs.writeFileSync(targetFilePath, content, 'utf-8');
65
+ console.log(`\n✅ Created model factory: ${path.relative(rootDir, targetFilePath)}\n`);
66
+ });
67
+ }
68
+
69
+ module.exports = {
70
+ registerCommand,
71
+ generateFactoryContent,
72
+ };