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.
- package/.agents/ADMIN.md +47 -4
- package/.agents/AGENTS.md +3 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/AUTH.md +63 -0
- package/.agents/CLI.md +81 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/EMAIL.md +84 -2
- package/.agents/EVENTS.md +179 -0
- package/.agents/ORM.md +59 -0
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +163 -3
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +7 -0
- package/README.md +18 -5
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/db-makemigrations.js +83 -0
- package/bin/commands/list.js +96 -0
- package/bin/commands/make-command.js +93 -0
- package/bin/commands/make-event.js +80 -0
- package/bin/commands/make-factory.js +72 -0
- package/bin/commands/make-mail.js +93 -0
- package/bin/commands/make-policy.js +106 -0
- package/bin/commands/routes.js +8 -0
- package/bin/commands/run.js +195 -0
- package/bin/webspresso.js +17 -0
- package/core/applySchema.js +18 -1
- package/core/auth/bouncer.js +196 -0
- package/core/auth/index.js +10 -0
- package/core/auth/middleware.js +15 -1
- package/core/commands/define-command.js +48 -0
- package/core/commands/index.js +18 -0
- package/core/commands/registry.js +156 -0
- package/core/commands/runner.js +76 -0
- package/core/compression/compressible.js +1 -1
- package/core/compression/middleware.js +35 -251
- package/core/content/service.js +8 -2
- package/core/events/discovery.js +142 -0
- package/core/events/dispatcher.js +552 -0
- package/core/events/index.js +23 -0
- package/core/events/signal.js +92 -0
- package/core/forms/flash.js +234 -0
- package/core/forms/form-builder.js +214 -0
- package/core/forms/index.js +16 -0
- package/core/mail/index.js +23 -0
- package/core/mail/mailable.js +250 -0
- package/core/mail/mailer.js +184 -0
- package/core/mail/transports.js +238 -0
- package/core/orm/factory.js +250 -0
- package/core/orm/index.js +6 -0
- package/core/orm/migrations/diff.js +185 -0
- package/core/orm/migrations/index.js +22 -4
- package/core/orm/model.js +4 -0
- package/index.d.ts +422 -1
- package/index.js +111 -16
- package/package.json +9 -5
- package/plugins/admin-panel/api.js +509 -3
- package/plugins/admin-panel/index.js +29 -0
- package/plugins/content/models/content-entry.js +4 -3
- package/plugins/content/models/content-type.js +4 -3
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +1 -1
- package/plugins/email/index.js +48 -0
- package/plugins/email/preview-handler.js +555 -0
- package/plugins/email/service.js +23 -12
- package/plugins/email/transport.js +30 -11
- package/plugins/file-manager/index.js +13 -0
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- package/plugins/redirect/index.js +1 -1
- package/src/api/api-loader.js +18 -2
- package/src/api/validation-i18n.js +231 -0
- package/src/app-context.js +48 -6
- package/src/dev-reload/index.js +9 -1
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +80 -5
- package/src/file-router.js +153 -91
- package/src/helpers.js +143 -0
- package/src/njk-frontmatter.js +17 -1
- package/src/pages/page-loader.js +1 -1
- package/src/plugin-manager.js +12 -0
- package/src/routing/cascading.js +14 -10
- package/src/routing/route-table.js +1 -1
- package/src/server.js +243 -34
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/template/precompile.js +247 -0
package/bin/webspresso.js
CHANGED
|
@@ -26,6 +26,7 @@ const { registerCommand: registerDbRollback } = require('./commands/db-rollback'
|
|
|
26
26
|
const { registerCommand: registerDbStatus } = require('./commands/db-status');
|
|
27
27
|
const { registerCommand: registerDbMake } = require('./commands/db-make');
|
|
28
28
|
const { registerCommand: registerDbScaffold } = require('./commands/db-scaffold');
|
|
29
|
+
const { registerCommand: registerDbMakeMigrations } = require('./commands/db-makemigrations');
|
|
29
30
|
const { registerCommand: registerSeed } = require('./commands/seed');
|
|
30
31
|
const { registerCommand: registerAdminSetup } = require('./commands/admin-setup');
|
|
31
32
|
const { registerCommand: registerAdminPassword } = require('./commands/admin-password');
|
|
@@ -49,6 +50,13 @@ const { registerCommand: registerOpenapi } = require('./commands/openapi');
|
|
|
49
50
|
const { registerCommand: registerTypegen } = require('./commands/typegen');
|
|
50
51
|
const { registerCommand: registerAddDocker } = require('./commands/add-docker');
|
|
51
52
|
const { registerCommand: registerDeployCheck } = require('./commands/deploy-check');
|
|
53
|
+
const { registerCommand: registerRun } = require('./commands/run');
|
|
54
|
+
const { registerCommand: registerList } = require('./commands/list');
|
|
55
|
+
const { registerCommand: registerMakeCommand } = require('./commands/make-command');
|
|
56
|
+
const { registerCommand: registerMakePolicy } = require('./commands/make-policy');
|
|
57
|
+
const { registerCommand: registerMakeFactory } = require('./commands/make-factory');
|
|
58
|
+
const { registerCommand: registerMakeEvent } = require('./commands/make-event');
|
|
59
|
+
const { registerCommand: registerMakeMail } = require('./commands/make-mail');
|
|
52
60
|
|
|
53
61
|
registerNew(program);
|
|
54
62
|
registerPage(program);
|
|
@@ -64,6 +72,7 @@ registerDbRollback(program);
|
|
|
64
72
|
registerDbStatus(program);
|
|
65
73
|
registerDbMake(program);
|
|
66
74
|
registerDbScaffold(program);
|
|
75
|
+
registerDbMakeMigrations(program);
|
|
67
76
|
registerSeed(program);
|
|
68
77
|
registerAdminSetup(program);
|
|
69
78
|
registerAdminPassword(program);
|
|
@@ -84,8 +93,16 @@ registerAgentsInit(program);
|
|
|
84
93
|
registerRoutes(program);
|
|
85
94
|
registerMakeModel(program);
|
|
86
95
|
registerMakeJob(program);
|
|
96
|
+
registerMakeCommand(program);
|
|
97
|
+
registerMakePolicy(program);
|
|
98
|
+
registerMakeFactory(program);
|
|
99
|
+
registerMakeEvent(program);
|
|
100
|
+
registerMakeMail(program);
|
|
101
|
+
registerRun(program);
|
|
102
|
+
registerList(program);
|
|
87
103
|
registerOpenapi(program);
|
|
88
104
|
registerTypegen(program);
|
|
89
105
|
|
|
90
106
|
// Parse arguments
|
|
91
107
|
program.parse();
|
|
108
|
+
|
package/core/applySchema.js
CHANGED
|
@@ -49,8 +49,25 @@ function applySchema(req, compiledSchema) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
let _translateValidationIssues = null;
|
|
53
|
+
function getTranslateValidationIssues() {
|
|
54
|
+
if (!_translateValidationIssues) {
|
|
55
|
+
try {
|
|
56
|
+
_translateValidationIssues = require('../src/api/validation-i18n').translateValidationIssues;
|
|
57
|
+
} catch {
|
|
58
|
+
_translateValidationIssues = (issues) => issues;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return _translateValidationIssues;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function translateValidationIssues(issues, options) {
|
|
65
|
+
return getTranslateValidationIssues()(issues, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
module.exports = {
|
|
53
|
-
applySchema
|
|
69
|
+
applySchema,
|
|
70
|
+
translateValidationIssues,
|
|
54
71
|
};
|
|
55
72
|
|
|
56
73
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Auth - Bouncer & Policy Authorization Engine (AdonisJS Bouncer & Django Permissions-inspired)
|
|
3
|
+
* Context-aware authorization helper for routes, handlers, and templates
|
|
4
|
+
* @module core/auth/bouncer
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { defaultPolicyManager, AuthorizationError } = require('./policy');
|
|
8
|
+
const { ForbiddenError, UnauthorizedError } = require('../errors');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Context-aware Bouncer instance bound to a specific user
|
|
12
|
+
*/
|
|
13
|
+
class Bouncer {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object|null} user - The subject user (or null for guest)
|
|
16
|
+
* @param {import('./policy').PolicyManager} [policyManager]
|
|
17
|
+
*/
|
|
18
|
+
constructor(user = null, policyManager = defaultPolicyManager) {
|
|
19
|
+
this.user = user;
|
|
20
|
+
this.policyManager = policyManager;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a new Bouncer instance for another user
|
|
25
|
+
* @param {Object|null} user
|
|
26
|
+
* @returns {Bouncer}
|
|
27
|
+
*/
|
|
28
|
+
forUser(user) {
|
|
29
|
+
return new Bouncer(user, this.policyManager);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse policy and action from arguments
|
|
34
|
+
* Supports:
|
|
35
|
+
* 1. ('Post.update', post) -> policy='Post', action='update', resource=post
|
|
36
|
+
* 2. ('Post', 'update', post) -> policy='Post', action='update', resource=post
|
|
37
|
+
* 3. ('gate-name', resource) -> policy=null, action='gate-name', resource=resource
|
|
38
|
+
* 4. (functionRule, resource) -> direct function check
|
|
39
|
+
* @protected
|
|
40
|
+
*/
|
|
41
|
+
_parseArgs(actionOrPolicy, ...rest) {
|
|
42
|
+
if (typeof actionOrPolicy === 'function') {
|
|
43
|
+
return {
|
|
44
|
+
isFn: true,
|
|
45
|
+
fn: actionOrPolicy,
|
|
46
|
+
resource: rest[0],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof actionOrPolicy === 'string') {
|
|
51
|
+
// 1. Dot-notation: 'Post.update'
|
|
52
|
+
if (actionOrPolicy.includes('.')) {
|
|
53
|
+
const parts = actionOrPolicy.split('.');
|
|
54
|
+
const policy = parts[0];
|
|
55
|
+
const action = parts.slice(1).join('.');
|
|
56
|
+
return { policy, action, resource: rest[0] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Separate strings: ('Post', 'update', post)
|
|
60
|
+
if (rest.length > 0 && typeof rest[0] === 'string') {
|
|
61
|
+
const policy = actionOrPolicy;
|
|
62
|
+
const action = rest[0];
|
|
63
|
+
const resource = rest[1];
|
|
64
|
+
return { policy, action, resource };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Single gate / permission name: ('edit-settings', setting)
|
|
68
|
+
return { policy: null, action: actionOrPolicy, resource: rest[0] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { policy: null, action: String(actionOrPolicy), resource: rest[0] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if current user is allowed to perform an action
|
|
76
|
+
* @param {string|Function} actionOrPolicy
|
|
77
|
+
* @param {...any} rest
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
allows(actionOrPolicy, ...rest) {
|
|
81
|
+
const parsed = this._parseArgs(actionOrPolicy, ...rest);
|
|
82
|
+
|
|
83
|
+
// Direct function rule
|
|
84
|
+
if (parsed.isFn) {
|
|
85
|
+
try {
|
|
86
|
+
return Boolean(parsed.fn(this.user, parsed.resource));
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { policy, action, resource } = parsed;
|
|
93
|
+
|
|
94
|
+
// 1. Check Policy Manager (Gates & Policies)
|
|
95
|
+
if (policy) {
|
|
96
|
+
if (this.policyManager.hasPolicy(policy)) {
|
|
97
|
+
return this.policyManager.can(this.user, action, policy, resource);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
if (this.policyManager.hasGate(action)) {
|
|
101
|
+
return this.policyManager.can(this.user, action, null, resource);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Django-style user permissions matrix check
|
|
106
|
+
// e.g. user.permissions = ['post.create', 'post.update'] or user.hasPerm(...)
|
|
107
|
+
if (this.user) {
|
|
108
|
+
const fullPerm = policy ? `${policy.toLowerCase()}.${action.toLowerCase()}` : action.toLowerCase();
|
|
109
|
+
|
|
110
|
+
// Check hasPerm / hasPermission method on user object
|
|
111
|
+
if (typeof this.user.hasPerm === 'function') {
|
|
112
|
+
if (this.user.hasPerm(fullPerm, resource)) return true;
|
|
113
|
+
}
|
|
114
|
+
if (typeof this.user.hasPermission === 'function') {
|
|
115
|
+
if (this.user.hasPermission(fullPerm, resource)) return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check permissions array or set
|
|
119
|
+
const perms = this.user.permissions;
|
|
120
|
+
if (Array.isArray(perms) || perms instanceof Set) {
|
|
121
|
+
const permSet = perms instanceof Set ? perms : new Set(perms);
|
|
122
|
+
if (permSet.has('*') || permSet.has(fullPerm)) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
if (policy && permSet.has(`${policy.toLowerCase()}.*`)) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// If user has superuser / is_admin flag
|
|
131
|
+
if (this.user.isSuperuser || this.user.is_superuser) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check if current user is denied from performing an action
|
|
141
|
+
* @param {string|Function} actionOrPolicy
|
|
142
|
+
* @param {...any} rest
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
denies(actionOrPolicy, ...rest) {
|
|
146
|
+
return !this.allows(actionOrPolicy, ...rest);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Authorize an action. Throws ForbiddenError (or UnauthorizedError) if access is denied.
|
|
151
|
+
* @param {string|Function} actionOrPolicy
|
|
152
|
+
* @param {...any} rest
|
|
153
|
+
* @throws {ForbiddenError|UnauthorizedError}
|
|
154
|
+
*/
|
|
155
|
+
authorize(actionOrPolicy, ...rest) {
|
|
156
|
+
if (this.denies(actionOrPolicy, ...rest)) {
|
|
157
|
+
if (!this.user) {
|
|
158
|
+
throw new UnauthorizedError('Authentication required to perform this action');
|
|
159
|
+
}
|
|
160
|
+
const label = typeof actionOrPolicy === 'string' ? actionOrPolicy : 'action';
|
|
161
|
+
throw new ForbiddenError(`You are not authorized to perform "${label}"`);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create a Bouncer instance for a user
|
|
169
|
+
* @param {Object|null} user
|
|
170
|
+
* @param {import('./policy').PolicyManager} [policyManager]
|
|
171
|
+
* @returns {Bouncer}
|
|
172
|
+
*/
|
|
173
|
+
function createBouncer(user = null, policyManager = defaultPolicyManager) {
|
|
174
|
+
return new Bouncer(user, policyManager);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Express middleware that attaches `req.bouncer` automatically
|
|
179
|
+
* @param {Object} [options={}]
|
|
180
|
+
* @returns {import('express').RequestHandler}
|
|
181
|
+
*/
|
|
182
|
+
function createBouncerMiddleware(options = {}) {
|
|
183
|
+
const policyManager = options.policyManager || defaultPolicyManager;
|
|
184
|
+
|
|
185
|
+
return function bouncerMiddleware(req, res, next) {
|
|
186
|
+
const user = req.user || req.auth?.user || null;
|
|
187
|
+
req.bouncer = new Bouncer(user, policyManager);
|
|
188
|
+
next();
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
Bouncer,
|
|
194
|
+
createBouncer,
|
|
195
|
+
createBouncerMiddleware,
|
|
196
|
+
};
|
package/core/auth/index.js
CHANGED
|
@@ -225,6 +225,16 @@ module.exports = {
|
|
|
225
225
|
// Classes
|
|
226
226
|
AuthManager,
|
|
227
227
|
PolicyManager,
|
|
228
|
+
Bouncer: require('./bouncer').Bouncer,
|
|
229
|
+
createBouncer: require('./bouncer').createBouncer,
|
|
230
|
+
createBouncerMiddleware: require('./bouncer').createBouncerMiddleware,
|
|
231
|
+
|
|
232
|
+
// Policies & Authorization (AdonisJS Bouncer & Django Permissions-inspired)
|
|
233
|
+
definePolicy: require('./policy').definePolicy,
|
|
234
|
+
defineGate: require('./policy').defineGate,
|
|
235
|
+
can: require('./policy').can,
|
|
236
|
+
cannot: require('./policy').cannot,
|
|
237
|
+
authorize: require('./policy').authorize,
|
|
228
238
|
|
|
229
239
|
// Errors
|
|
230
240
|
AuthenticationError,
|
package/core/auth/middleware.js
CHANGED
|
@@ -44,7 +44,21 @@ function createAuthMiddleware(authManager) {
|
|
|
44
44
|
/**
|
|
45
45
|
* Cookie parser middleware (needed for signed cookies / remember me)
|
|
46
46
|
*/
|
|
47
|
-
const
|
|
47
|
+
const baseCookieMiddleware = cookieParser(config.session?.secret);
|
|
48
|
+
const cookieMiddleware = (req, res, next) => {
|
|
49
|
+
if (!req.cookies) {
|
|
50
|
+
return baseCookieMiddleware(req, res, next);
|
|
51
|
+
}
|
|
52
|
+
if (config.session?.secret) {
|
|
53
|
+
if (!req.secret) {
|
|
54
|
+
req.secret = config.session.secret;
|
|
55
|
+
}
|
|
56
|
+
if (!req.signedCookies || Object.keys(req.signedCookies).length === 0) {
|
|
57
|
+
req.signedCookies = cookieParser.signedCookies(req.cookies, config.session.secret);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
next();
|
|
61
|
+
};
|
|
48
62
|
|
|
49
63
|
/**
|
|
50
64
|
* Authenticate middleware
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso - Custom Command Definition
|
|
5
|
+
* Inspired by Django BaseCommand & AdonisJS Ace CLI
|
|
6
|
+
* @module core/commands/define-command
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Define a custom CLI management command
|
|
11
|
+
* @param {Object} definition
|
|
12
|
+
* @param {string} definition.name - Command identifier (e.g. 'users:sync' or 'send-invoices')
|
|
13
|
+
* @param {string} [definition.description] - Human-readable command description
|
|
14
|
+
* @param {Array<{flags: string, description?: string, defaultValue?: any}>} [definition.options] - CLI flags
|
|
15
|
+
* @param {Function} definition.handle - Execution function receiving ({ db, queue, services, mailer, events, logger, args, flags })
|
|
16
|
+
* @returns {Object} Validated command specification
|
|
17
|
+
*/
|
|
18
|
+
function defineCommand(definition) {
|
|
19
|
+
if (!definition || typeof definition !== 'object') {
|
|
20
|
+
throw new TypeError('defineCommand requires a configuration object');
|
|
21
|
+
}
|
|
22
|
+
if (!definition.name || typeof definition.name !== 'string' || !definition.name.trim()) {
|
|
23
|
+
throw new TypeError('Command definition must specify a non-empty string "name"');
|
|
24
|
+
}
|
|
25
|
+
const handleFn = definition.handle || definition.run;
|
|
26
|
+
if (typeof handleFn !== 'function') {
|
|
27
|
+
throw new TypeError(`Command "${definition.name}" must provide an executable "handle" function`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const options = Array.isArray(definition.options)
|
|
31
|
+
? definition.options.map((opt) => ({
|
|
32
|
+
flags: String(opt.flags || opt.name || '').trim(),
|
|
33
|
+
description: opt.description || '',
|
|
34
|
+
defaultValue: opt.defaultValue !== undefined ? opt.defaultValue : opt.default,
|
|
35
|
+
})).filter((opt) => opt.flags.length > 0)
|
|
36
|
+
: [];
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: definition.name.trim(),
|
|
40
|
+
description: definition.description || '',
|
|
41
|
+
options,
|
|
42
|
+
handle: handleFn,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
defineCommand,
|
|
48
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso - Management Commands Core Module
|
|
5
|
+
* @module core/commands
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { defineCommand } = require('./define-command');
|
|
9
|
+
const { createCommandRegistry, findCommandFiles, deriveCommandName } = require('./registry');
|
|
10
|
+
const { executeCommand } = require('./runner');
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
defineCommand,
|
|
14
|
+
createCommandRegistry,
|
|
15
|
+
findCommandFiles,
|
|
16
|
+
deriveCommandName,
|
|
17
|
+
executeCommand,
|
|
18
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso - Command Registry & Auto-Discovery
|
|
5
|
+
* Scans commands/ directory and registers custom management commands
|
|
6
|
+
* @module core/commands/registry
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { defineCommand } = require('./define-command');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Recursively find command files in directory
|
|
15
|
+
* @param {string} dir
|
|
16
|
+
* @returns {string[]}
|
|
17
|
+
*/
|
|
18
|
+
function findCommandFiles(dir) {
|
|
19
|
+
if (!fs.existsSync(dir)) return [];
|
|
20
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
21
|
+
const files = [];
|
|
22
|
+
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const fullPath = path.join(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
files.push(...findCommandFiles(fullPath));
|
|
27
|
+
} else if (
|
|
28
|
+
entry.isFile() &&
|
|
29
|
+
entry.name.endsWith('.js') &&
|
|
30
|
+
!entry.name.startsWith('_') &&
|
|
31
|
+
!entry.name.endsWith('.test.js') &&
|
|
32
|
+
!entry.name.endsWith('.spec.js')
|
|
33
|
+
) {
|
|
34
|
+
files.push(fullPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return files;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive command name from relative path
|
|
43
|
+
* e.g. "users/prune.js" -> "users:prune", "send-invoices.js" -> "send-invoices"
|
|
44
|
+
* @param {string} relPath
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function deriveCommandName(relPath) {
|
|
48
|
+
const withoutExt = relPath.replace(/\.js$/, '');
|
|
49
|
+
const segments = withoutExt.split(/[\\/]/).filter(Boolean);
|
|
50
|
+
return segments.join(':');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a CommandRegistry instance
|
|
55
|
+
* @param {Object} [options]
|
|
56
|
+
* @param {string} [options.commandsDir] - Root directory for commands (default: ./commands)
|
|
57
|
+
* @param {boolean} [options.autoLoad=true] - Whether to discover files immediately
|
|
58
|
+
* @returns {Object} CommandRegistry
|
|
59
|
+
*/
|
|
60
|
+
function createCommandRegistry(options = {}) {
|
|
61
|
+
const commandsDir = options.commandsDir
|
|
62
|
+
? path.resolve(process.cwd(), options.commandsDir)
|
|
63
|
+
: path.resolve(process.cwd(), 'commands');
|
|
64
|
+
|
|
65
|
+
/** @type {Map<string, Object>} */
|
|
66
|
+
const commands = new Map();
|
|
67
|
+
|
|
68
|
+
function register(cmdDef) {
|
|
69
|
+
if (!cmdDef || typeof cmdDef !== 'object') {
|
|
70
|
+
throw new TypeError('Invalid command definition object');
|
|
71
|
+
}
|
|
72
|
+
const def = typeof cmdDef.handle === 'function' && defIsNormalized(cmdDef)
|
|
73
|
+
? cmdDef
|
|
74
|
+
: defineCommand(cmdDef);
|
|
75
|
+
|
|
76
|
+
commands.set(def.name, def);
|
|
77
|
+
return def;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function defIsNormalized(obj) {
|
|
81
|
+
return typeof obj.name === 'string' && typeof obj.handle === 'function' && Array.isArray(obj.options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function load() {
|
|
85
|
+
commands.clear();
|
|
86
|
+
const files = findCommandFiles(commandsDir);
|
|
87
|
+
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
try {
|
|
90
|
+
delete require.cache[require.resolve(file)];
|
|
91
|
+
const exported = require(file);
|
|
92
|
+
const rel = path.relative(commandsDir, file);
|
|
93
|
+
const derivedName = deriveCommandName(rel);
|
|
94
|
+
|
|
95
|
+
let cmdDef = null;
|
|
96
|
+
|
|
97
|
+
if (typeof exported === 'function') {
|
|
98
|
+
cmdDef = defineCommand({
|
|
99
|
+
name: derivedName,
|
|
100
|
+
handle: exported,
|
|
101
|
+
});
|
|
102
|
+
} else if (exported && typeof exported === 'object') {
|
|
103
|
+
// Check if exported is an ES default or CJS module
|
|
104
|
+
const target = exported.default || exported;
|
|
105
|
+
const name = target.name || derivedName;
|
|
106
|
+
const handle = target.handle || target.run;
|
|
107
|
+
|
|
108
|
+
if (typeof handle === 'function') {
|
|
109
|
+
cmdDef = defineCommand({
|
|
110
|
+
name,
|
|
111
|
+
description: target.description || '',
|
|
112
|
+
options: target.options || [],
|
|
113
|
+
handle,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (cmdDef) {
|
|
119
|
+
commands.set(cmdDef.name, cmdDef);
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.warn(`[commands] Failed to load command from "${file}":`, err.message);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (options.autoLoad !== false) {
|
|
128
|
+
load();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
register,
|
|
133
|
+
reload: load,
|
|
134
|
+
has(name) {
|
|
135
|
+
return commands.has(name);
|
|
136
|
+
},
|
|
137
|
+
get(name) {
|
|
138
|
+
return commands.get(name) || null;
|
|
139
|
+
},
|
|
140
|
+
list() {
|
|
141
|
+
return Array.from(commands.values());
|
|
142
|
+
},
|
|
143
|
+
names() {
|
|
144
|
+
return Array.from(commands.keys());
|
|
145
|
+
},
|
|
146
|
+
get commandsDir() {
|
|
147
|
+
return commandsDir;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
createCommandRegistry,
|
|
154
|
+
findCommandFiles,
|
|
155
|
+
deriveCommandName,
|
|
156
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso - Command Runner & Execution Engine
|
|
5
|
+
* Injects framework services, database, queue, mailer, events and manages execution lifecycle
|
|
6
|
+
* @module core/commands/runner
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a command definition with dependency injection
|
|
11
|
+
* @param {Object} commandDef - Command specification returned by defineCommand
|
|
12
|
+
* @param {Object} [executionOptions]
|
|
13
|
+
* @param {Array<string>} [executionOptions.args] - Positional CLI arguments
|
|
14
|
+
* @param {Object} [executionOptions.flags] - Named flags / options
|
|
15
|
+
* @param {Object} [executionOptions.db] - Knex / ORM database instance
|
|
16
|
+
* @param {Object} [executionOptions.queue] - Background job queue dispatcher
|
|
17
|
+
* @param {Object} [executionOptions.services] - Service registry or caller
|
|
18
|
+
* @param {Object} [executionOptions.mailer] - Mailer service
|
|
19
|
+
* @param {Object} [executionOptions.events] - Application event dispatcher
|
|
20
|
+
* @param {Object} [executionOptions.logger] - Logger instance (default: console)
|
|
21
|
+
* @returns {Promise<{ success: boolean, result?: any, error?: Error, durationMs: number }>}
|
|
22
|
+
*/
|
|
23
|
+
async function executeCommand(commandDef, executionOptions = {}) {
|
|
24
|
+
if (!commandDef || typeof commandDef !== 'object') {
|
|
25
|
+
throw new TypeError('executeCommand requires a command definition');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const handleFn = commandDef.handle || commandDef.run;
|
|
29
|
+
if (typeof handleFn !== 'function') {
|
|
30
|
+
throw new TypeError(`Command "${commandDef.name || 'anonymous'}" has no executable handle function`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
args = [],
|
|
35
|
+
flags = {},
|
|
36
|
+
db = null,
|
|
37
|
+
queue = null,
|
|
38
|
+
services = null,
|
|
39
|
+
mailer = null,
|
|
40
|
+
events = null,
|
|
41
|
+
logger = console,
|
|
42
|
+
} = executionOptions;
|
|
43
|
+
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
const context = {
|
|
46
|
+
db,
|
|
47
|
+
queue,
|
|
48
|
+
services,
|
|
49
|
+
mailer,
|
|
50
|
+
events,
|
|
51
|
+
logger,
|
|
52
|
+
args: Array.isArray(args) ? args : [],
|
|
53
|
+
flags: flags && typeof flags === 'object' ? flags : {},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = await handleFn(context);
|
|
58
|
+
const durationMs = Date.now() - start;
|
|
59
|
+
return {
|
|
60
|
+
success: true,
|
|
61
|
+
result,
|
|
62
|
+
durationMs,
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const durationMs = Date.now() - start;
|
|
66
|
+
return {
|
|
67
|
+
success: false,
|
|
68
|
+
error,
|
|
69
|
+
durationMs,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
executeCommand,
|
|
76
|
+
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
const COMPRESSIBLE_TYPE_REGEX = /^(?:text\/[a-z0-9.+_-]+|application\/(?:javascript|x-javascript|json|ld\+json|manifest\+json|graphql\+json|geo\+json|xml|atom\+xml|rss\+xml|xhtml\+xml|x-yaml|yaml|vnd\.api\+json|schema\+json|hal\+json)|image\/svg\+xml)$/i;
|
|
9
9
|
|
|
10
10
|
// Regex patterns for already-compressed or non-compressible types
|
|
11
|
-
const INCOMPRESSIBLE_TYPE_REGEX = /^(?:image\/(?:jpeg|png|gif|webp|avif|heic|heif)|video\/[a-z0-9.+_-]+|audio\/[a-z0-9.+_-]+|application\/(?:zip|gzip|x-gzip|x-bzip2|x-compress|x-7z-compressed|x-rar-compressed|pdf|wasm|octet-stream))$/i;
|
|
11
|
+
const INCOMPRESSIBLE_TYPE_REGEX = /^(?:text\/event-stream|image\/(?:jpeg|png|gif|webp|avif|heic|heif)|video\/[a-z0-9.+_-]+|audio\/[a-z0-9.+_-]+|application\/(?:zip|gzip|x-gzip|x-bzip2|x-compress|x-7z-compressed|x-rar-compressed|pdf|wasm|octet-stream))$/i;
|
|
12
12
|
|
|
13
13
|
const compressibleCache = new Map();
|
|
14
14
|
const MAX_MIME_CACHE_SIZE = 256;
|