webspresso 0.0.100 → 0.0.102

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 (88) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +3 -1
  3. package/.agents/ARCHITECTURE.md +11 -0
  4. package/.agents/AUTH.md +63 -0
  5. package/.agents/CLI.md +81 -0
  6. package/.agents/CONVENTIONS.md +4 -0
  7. package/.agents/EMAIL.md +84 -2
  8. package/.agents/EVENTS.md +179 -0
  9. package/.agents/ORM.md +59 -0
  10. package/.agents/PLUGINS.md +32 -6
  11. package/.agents/ROUTING.md +163 -3
  12. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  13. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  14. package/AGENTS.md +7 -0
  15. package/README.md +18 -5
  16. package/bin/commands/add-tailwind.js +10 -0
  17. package/bin/commands/build.js +34 -0
  18. package/bin/commands/db-makemigrations.js +83 -0
  19. package/bin/commands/list.js +96 -0
  20. package/bin/commands/make-command.js +93 -0
  21. package/bin/commands/make-event.js +80 -0
  22. package/bin/commands/make-factory.js +72 -0
  23. package/bin/commands/make-mail.js +93 -0
  24. package/bin/commands/make-policy.js +106 -0
  25. package/bin/commands/routes.js +8 -0
  26. package/bin/commands/run.js +195 -0
  27. package/bin/webspresso.js +17 -0
  28. package/core/applySchema.js +18 -1
  29. package/core/auth/bouncer.js +196 -0
  30. package/core/auth/index.js +10 -0
  31. package/core/auth/middleware.js +15 -1
  32. package/core/commands/define-command.js +48 -0
  33. package/core/commands/index.js +18 -0
  34. package/core/commands/registry.js +156 -0
  35. package/core/commands/runner.js +76 -0
  36. package/core/compression/compressible.js +1 -1
  37. package/core/compression/middleware.js +35 -251
  38. package/core/content/service.js +8 -2
  39. package/core/events/discovery.js +142 -0
  40. package/core/events/dispatcher.js +552 -0
  41. package/core/events/index.js +23 -0
  42. package/core/events/signal.js +92 -0
  43. package/core/forms/flash.js +234 -0
  44. package/core/forms/form-builder.js +214 -0
  45. package/core/forms/index.js +16 -0
  46. package/core/mail/index.js +23 -0
  47. package/core/mail/mailable.js +250 -0
  48. package/core/mail/mailer.js +184 -0
  49. package/core/mail/transports.js +238 -0
  50. package/core/orm/factory.js +250 -0
  51. package/core/orm/index.js +6 -0
  52. package/core/orm/migrations/diff.js +185 -0
  53. package/core/orm/migrations/index.js +22 -4
  54. package/core/orm/model.js +4 -0
  55. package/index.d.ts +422 -1
  56. package/index.js +111 -16
  57. package/package.json +9 -5
  58. package/plugins/admin-panel/api.js +509 -3
  59. package/plugins/admin-panel/index.js +29 -0
  60. package/plugins/content/models/content-entry.js +4 -3
  61. package/plugins/content/models/content-type.js +4 -3
  62. package/plugins/cors.js +2 -2
  63. package/plugins/csrf/index.js +1 -1
  64. package/plugins/email/index.js +48 -0
  65. package/plugins/email/preview-handler.js +555 -0
  66. package/plugins/email/service.js +23 -12
  67. package/plugins/email/transport.js +30 -11
  68. package/plugins/file-manager/index.js +13 -0
  69. package/plugins/index.js +2 -0
  70. package/plugins/lazy.js +258 -0
  71. package/plugins/redirect/index.js +1 -1
  72. package/src/api/api-loader.js +18 -2
  73. package/src/api/validation-i18n.js +231 -0
  74. package/src/app-context.js +48 -6
  75. package/src/dev-reload/index.js +9 -1
  76. package/src/discovery/route-manifest.js +197 -0
  77. package/src/discovery/scan-routes.js +80 -5
  78. package/src/file-router.js +153 -91
  79. package/src/helpers.js +143 -0
  80. package/src/njk-frontmatter.js +17 -1
  81. package/src/pages/page-loader.js +1 -1
  82. package/src/plugin-manager.js +12 -0
  83. package/src/routing/cascading.js +14 -10
  84. package/src/routing/route-table.js +1 -1
  85. package/src/server.js +243 -34
  86. package/src/services/builtins/system.js +4 -6
  87. package/src/services/registry.js +12 -2
  88. package/src/template/precompile.js +247 -0
@@ -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
+ };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - make:mail Command
5
+ * Scaffolds a declarative Mailable class in emails/<name>.js
6
+ * @module bin/commands/make-mail
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ function generateMailContent(rawName) {
13
+ const baseName = rawName.replace(/email$/i, '').replace(/mail$/i, '').trim();
14
+ const cleanBase = baseName.charAt(0).toUpperCase() + baseName.slice(1);
15
+ const className = `${cleanBase}Email`;
16
+ const tplName = cleanBase.toLowerCase();
17
+
18
+ return `'use strict';
19
+
20
+ const { Mailable } = require('webspresso');
21
+
22
+ /**
23
+ * Mailable: ${className}
24
+ * Send via: req.mailer.send(new ${className}(user))
25
+ */
26
+ class ${className} extends Mailable {
27
+ constructor(recipient, data = {}) {
28
+ super();
29
+ this.recipient = recipient;
30
+ this.data = data;
31
+ }
32
+
33
+ /**
34
+ * Build email definition
35
+ */
36
+ build() {
37
+ const toAddress = typeof this.recipient === 'string' ? this.recipient : this.recipient?.email;
38
+ const toName = typeof this.recipient === 'object' ? this.recipient?.name : undefined;
39
+
40
+ this.to(toAddress, toName)
41
+ .subject('Hello from Webspresso')
42
+ .view('${tplName}', this.data);
43
+ }
44
+ }
45
+
46
+ module.exports = {
47
+ ${className},
48
+ };
49
+ `;
50
+ }
51
+
52
+ function registerCommand(program) {
53
+ program
54
+ .command('make:mail <name>')
55
+ .alias('mail:make')
56
+ .alias('make:mailable')
57
+ .description('Scaffold a declarative Mailable class in emails/<name>.js')
58
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
59
+ .action((nameArg, options) => {
60
+ const rootDir = path.resolve(options.root || process.cwd());
61
+ const emailsDir = path.join(rootDir, 'emails');
62
+
63
+ if (!fs.existsSync(emailsDir)) {
64
+ fs.mkdirSync(emailsDir, { recursive: true });
65
+ }
66
+
67
+ const rawName = String(nameArg || '').trim();
68
+ if (!rawName) {
69
+ console.error('❌ Mailable name is required.');
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+
74
+ const cleanName = rawName.replace(/\.js$/, '');
75
+ const fileName = cleanName.endsWith('Email') ? cleanName : `${cleanName}Email`;
76
+ const targetFilePath = path.join(emailsDir, `${fileName}.js`);
77
+
78
+ if (fs.existsSync(targetFilePath)) {
79
+ console.error(`❌ Mailable file already exists: ${path.relative(rootDir, targetFilePath)}`);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+
84
+ const content = generateMailContent(cleanName);
85
+ fs.writeFileSync(targetFilePath, content, 'utf-8');
86
+ console.log(`\n✅ Created Mailable class: ${path.relative(rootDir, targetFilePath)}\n`);
87
+ });
88
+ }
89
+
90
+ module.exports = {
91
+ registerCommand,
92
+ generateMailContent,
93
+ };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - make:policy Command
5
+ * Scaffolds an authorization policy in policies/<name>.policy.js
6
+ * @module bin/commands/make-policy
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ function generatePolicyContent(policyName) {
13
+ const modelName = policyName.replace(/policy$/i, '').trim();
14
+ const cleanName = modelName.charAt(0).toUpperCase() + modelName.slice(1);
15
+
16
+ return `'use strict';
17
+
18
+ /**
19
+ * Authorization Policy: ${cleanName}
20
+ * Invoked via req.bouncer.authorize('${cleanName.toLowerCase()}.update', record)
21
+ * or {% if fsy.can('${cleanName.toLowerCase()}.update', record) %}
22
+ */
23
+ module.exports = {
24
+ name: '${cleanName.toLowerCase()}',
25
+
26
+ /**
27
+ * Determine whether the user can view any records
28
+ */
29
+ async viewAny(user) {
30
+ return true;
31
+ },
32
+
33
+ /**
34
+ * Determine whether the user can view the specific record
35
+ */
36
+ async view(user, record) {
37
+ return true;
38
+ },
39
+
40
+ /**
41
+ * Determine whether the user can create records
42
+ */
43
+ async create(user) {
44
+ return Boolean(user);
45
+ },
46
+
47
+ /**
48
+ * Determine whether the user can update the record
49
+ */
50
+ async update(user, record) {
51
+ if (!user) return false;
52
+ if (user.isAdmin || user.role === 'admin') return true;
53
+ return record && (record.userId === user.id || record.user_id === user.id);
54
+ },
55
+
56
+ /**
57
+ * Determine whether the user can delete the record
58
+ */
59
+ async delete(user, record) {
60
+ if (!user) return false;
61
+ return Boolean(user.isAdmin || user.role === 'admin');
62
+ },
63
+ };
64
+ `;
65
+ }
66
+
67
+ function registerCommand(program) {
68
+ program
69
+ .command('make:policy <name>')
70
+ .alias('policy:make')
71
+ .description('Scaffold an authorization policy in policies/<name>.policy.js')
72
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
73
+ .action((nameArg, options) => {
74
+ const rootDir = path.resolve(options.root || process.cwd());
75
+ const policiesDir = path.join(rootDir, 'policies');
76
+
77
+ if (!fs.existsSync(policiesDir)) {
78
+ fs.mkdirSync(policiesDir, { recursive: true });
79
+ }
80
+
81
+ const rawName = String(nameArg || '').trim();
82
+ if (!rawName) {
83
+ console.error('❌ Policy name is required.');
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+
88
+ const policyBase = rawName.replace(/\.policy(\.js)?$/, '').replace(/\.js$/, '');
89
+ const targetFilePath = path.join(policiesDir, `${policyBase}.policy.js`);
90
+
91
+ if (fs.existsSync(targetFilePath)) {
92
+ console.error(`❌ Policy file already exists: ${path.relative(rootDir, targetFilePath)}`);
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+
97
+ const content = generatePolicyContent(policyBase);
98
+ fs.writeFileSync(targetFilePath, content, 'utf-8');
99
+ console.log(`\n✅ Created authorization policy: ${path.relative(rootDir, targetFilePath)}\n`);
100
+ });
101
+ }
102
+
103
+ module.exports = {
104
+ registerCommand,
105
+ generatePolicyContent,
106
+ };
@@ -19,10 +19,18 @@ function registerCommand(program) {
19
19
  .option('-a, --api', 'Filter to only API endpoints')
20
20
  .option('-p, --pages', 'Filter to only SSR page routes')
21
21
  .option('-j, --json', 'Output routes as JSON')
22
+ .option('-m, --manifest', 'Generate or update .webspresso/routes.manifest.json')
22
23
  .option('--root <dir>', 'Root directory of the project', process.cwd())
23
24
  .action((options) => {
24
25
  const rootDir = path.resolve(options.root || process.cwd());
25
26
 
27
+ if (options.manifest) {
28
+ const { generateRouteManifest } = require('../../src/discovery/route-manifest');
29
+ const result = generateRouteManifest({ rootDir });
30
+ console.log(`✅ AOT route manifest written to: ${path.relative(rootDir, result.manifestPath)} (${result.routeCount} routes)`);
31
+ return;
32
+ }
33
+
26
34
  const descriptors = scanRoutes({
27
35
  rootDir,
28
36
  pages: fs.existsSync(path.join(rootDir, 'pages')) ? { dir: 'pages' } : true,
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - run Command
5
+ * Executes custom management commands with injected framework context
6
+ * @module bin/commands/run
7
+ */
8
+
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const { createCommandRegistry, executeCommand } = require('../../core/commands');
12
+ const { createEventDispatcher } = require('../../core/events');
13
+ const { createMailer } = require('../../core/mail');
14
+ const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
15
+
16
+ /**
17
+ * Helper to parse unknown CLI options into a flags object
18
+ * e.g. ['--dry-run', '--limit', '50', 'arg1'] -> flags: { dryRun: true, limit: 50 }, args: ['arg1']
19
+ * @param {string[]} rawArgs
20
+ * @returns {{ flags: Object, args: string[] }}
21
+ */
22
+ function parseRawArgs(rawArgs = []) {
23
+ const flags = {};
24
+ const args = [];
25
+
26
+ for (let i = 0; i < rawArgs.length; i++) {
27
+ const item = rawArgs[i];
28
+ if (item.startsWith('--')) {
29
+ const withoutDashes = item.slice(2);
30
+ if (withoutDashes.includes('=')) {
31
+ const [k, v] = withoutDashes.split('=');
32
+ flags[camelCase(k)] = parseVal(v);
33
+ } else {
34
+ const next = rawArgs[i + 1];
35
+ if (next && !next.startsWith('-')) {
36
+ flags[camelCase(withoutDashes)] = parseVal(next);
37
+ i++;
38
+ } else {
39
+ flags[camelCase(withoutDashes)] = true;
40
+ }
41
+ }
42
+ } else if (item.startsWith('-') && item.length === 2) {
43
+ const flagChar = item.slice(1);
44
+ const next = rawArgs[i + 1];
45
+ if (next && !next.startsWith('-')) {
46
+ flags[flagChar] = parseVal(next);
47
+ i++;
48
+ } else {
49
+ flags[flagChar] = true;
50
+ }
51
+ } else {
52
+ args.push(item);
53
+ }
54
+ }
55
+
56
+ return { flags, args };
57
+ }
58
+
59
+ function camelCase(str) {
60
+ return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
61
+ }
62
+
63
+ function parseVal(val) {
64
+ if (val === 'true') return true;
65
+ if (val === 'false') return false;
66
+ if (val === 'null') return null;
67
+ if (!Number.isNaN(Number(val)) && val.trim() !== '') return Number(val);
68
+ return val;
69
+ }
70
+
71
+ function registerCommand(program) {
72
+ program
73
+ .command('run [commandName] [args...]')
74
+ .alias('command:run')
75
+ .description('Run a custom management command from commands/ with injected framework context')
76
+ .option('-d, --dir <path>', 'Custom commands directory (default: ./commands)')
77
+ .option('-c, --config <path>', 'Path to database config file (optional)')
78
+ .option('-E, --env <environment>', 'Environment name (default: development)')
79
+ .allowUnknownOption(true)
80
+ .action(async (commandName, rawArgs = [], options = {}) => {
81
+ const rootDir = process.cwd();
82
+ const commandsDir = options.dir
83
+ ? path.resolve(rootDir, options.dir)
84
+ : path.resolve(rootDir, 'commands');
85
+
86
+ const registry = createCommandRegistry({ commandsDir });
87
+ const available = registry.list();
88
+
89
+ if (!commandName) {
90
+ if (available.length === 0) {
91
+ console.log(`\n⚠️ No custom commands found in "${path.relative(rootDir, commandsDir) || 'commands/'}".`);
92
+ console.log(' Create your first custom command with:');
93
+ console.log(' npx webspresso make:command <name>\n');
94
+ return;
95
+ }
96
+
97
+ console.log(`\n⚡ Available Custom Management Commands (${available.length}):\n`);
98
+ const maxLen = Math.max(...available.map((c) => c.name.length), 10);
99
+ for (const cmd of available) {
100
+ console.log(` webspresso run ${cmd.name.padEnd(maxLen + 2)} ${cmd.description ? `# ${cmd.description}` : ''}`);
101
+ }
102
+ console.log('');
103
+ return;
104
+ }
105
+
106
+ if (!registry.has(commandName)) {
107
+ console.error(`\n❌ Error: Command "${commandName}" not found in ${path.relative(rootDir, commandsDir) || 'commands/'}.`);
108
+ if (available.length > 0) {
109
+ console.log(` Available commands: ${registry.names().join(', ')}\n`);
110
+ } else {
111
+ console.log(' Use `npx webspresso make:command <name>` to create it.\n');
112
+ }
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+
117
+ const cmdDef = registry.get(commandName);
118
+ const { flags, args } = parseRawArgs(rawArgs);
119
+
120
+ // Boot Context
121
+ let knexInstance = null;
122
+ let db = null;
123
+ let services = null;
124
+ const events = createEventDispatcher();
125
+ const mailer = createMailer({ backend: 'console' });
126
+
127
+ // 1. Database
128
+ try {
129
+ const dbInfo = resolveDbConfigIfExists(options.config);
130
+ if (dbInfo) {
131
+ knexInstance = await createDbInstance(dbInfo.config, options.env);
132
+ const { createDatabase } = require('../../core/orm');
133
+ db = createDatabase({ knex: knexInstance });
134
+ }
135
+ } catch (err) {
136
+ console.warn('⚠️ Could not initialize database for command:', err.message);
137
+ }
138
+
139
+ // 2. Services
140
+ try {
141
+ const servicesDir = path.resolve(rootDir, 'services');
142
+ if (fs.existsSync(servicesDir)) {
143
+ const { createServiceRegistry } = require('../../src/services');
144
+ services = createServiceRegistry({ servicesDir, isDev: true });
145
+ }
146
+ } catch (err) {
147
+ console.warn('⚠️ Could not initialize service registry for command:', err.message);
148
+ }
149
+
150
+ // 3. Queue
151
+ let queue = null;
152
+ try {
153
+ const { createQueueDispatcher } = require('../../core/queue');
154
+ queue = createQueueDispatcher({ db: knexInstance });
155
+ } catch (_) {
156
+ queue = {
157
+ dispatch: async () => {},
158
+ };
159
+ }
160
+
161
+ console.log(`\n🚀 [webspresso run] Executing "${commandName}"...`);
162
+ const executionResult = await executeCommand(cmdDef, {
163
+ args,
164
+ flags,
165
+ db,
166
+ queue,
167
+ services,
168
+ mailer,
169
+ events,
170
+ logger: console,
171
+ });
172
+
173
+ // Cleanup
174
+ if (knexInstance && typeof knexInstance.destroy === 'function') {
175
+ try {
176
+ await knexInstance.destroy();
177
+ } catch (_) {}
178
+ }
179
+
180
+ if (executionResult.success) {
181
+ console.log(`\n✨ Command "${commandName}" completed successfully in ${executionResult.durationMs}ms.\n`);
182
+ } else {
183
+ console.error(`\n❌ Command "${commandName}" failed after ${executionResult.durationMs}ms:`);
184
+ if (executionResult.error) {
185
+ console.error(executionResult.error.stack || executionResult.error.message);
186
+ }
187
+ process.exitCode = 1;
188
+ }
189
+ });
190
+ }
191
+
192
+ module.exports = {
193
+ registerCommand,
194
+ parseRawArgs,
195
+ };