webspresso 0.0.97 → 0.0.99
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 +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
package/bin/commands/api.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* API Command
|
|
5
|
-
* Add a new API endpoint with modern Webspresso schema & handler contract
|
|
5
|
+
* Add a new API endpoint or full REST CRUD suite with modern Webspresso schema & handler contract
|
|
6
6
|
* @module bin/commands/api
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -18,6 +18,31 @@ const path = require('path');
|
|
|
18
18
|
|
|
19
19
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
20
20
|
|
|
21
|
+
function singularize(word) {
|
|
22
|
+
if (!word) return '';
|
|
23
|
+
const lower = word.toLowerCase();
|
|
24
|
+
if (lower.endsWith('ies')) {
|
|
25
|
+
return lower.slice(0, -3) + 'y';
|
|
26
|
+
}
|
|
27
|
+
if (lower.endsWith('ses')) {
|
|
28
|
+
return lower.slice(0, -2);
|
|
29
|
+
}
|
|
30
|
+
if (lower.endsWith('s') && !lower.endsWith('ss')) {
|
|
31
|
+
return lower.slice(0, -1);
|
|
32
|
+
}
|
|
33
|
+
return lower;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toPascalCase(str) {
|
|
37
|
+
if (!str) return '';
|
|
38
|
+
const clean = str.replace(/[^a-zA-Z0-9]/g, ' ');
|
|
39
|
+
return clean
|
|
40
|
+
.split(' ')
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
43
|
+
.join('');
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
function parseRouteAndMethod(rawInput, optionMethod) {
|
|
22
47
|
let route = String(rawInput || '').trim();
|
|
23
48
|
let method = optionMethod ? String(optionMethod).toUpperCase() : null;
|
|
@@ -45,18 +70,160 @@ function parseRouteAndMethod(rawInput, optionMethod) {
|
|
|
45
70
|
};
|
|
46
71
|
}
|
|
47
72
|
|
|
73
|
+
function generateCrudFiles(apiDir, route, modelName, middlewareStr) {
|
|
74
|
+
const cleanRoute = route.replace(/^\/+|\/+$/g, '');
|
|
75
|
+
const baseDir = path.join(apiDir, cleanRoute);
|
|
76
|
+
const detailDir = path.join(baseDir, '[id]');
|
|
77
|
+
fs.mkdirSync(detailDir, { recursive: true });
|
|
78
|
+
|
|
79
|
+
const files = [
|
|
80
|
+
{
|
|
81
|
+
path: path.join(apiDir, `${cleanRoute}.get.js`),
|
|
82
|
+
label: `GET /api/${cleanRoute} (List)`,
|
|
83
|
+
content: `'use strict';
|
|
84
|
+
|
|
85
|
+
const { defineApi } = require('webspresso');
|
|
86
|
+
|
|
87
|
+
module.exports = defineApi({
|
|
88
|
+
schema: ({ z }) => ({
|
|
89
|
+
query: z.object({
|
|
90
|
+
page: z.coerce.number().optional().default(1),
|
|
91
|
+
limit: z.coerce.number().optional().default(20),
|
|
92
|
+
}),
|
|
93
|
+
}),${middlewareStr}
|
|
94
|
+
handler: async (req, res) => {
|
|
95
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
96
|
+
const { page, limit } = req.input.query;
|
|
97
|
+
const items = repo ? await repo.query().paginate({ page, limit }) : [];
|
|
98
|
+
return res.json(items);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
`,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
path: path.join(apiDir, `${cleanRoute}.post.js`),
|
|
105
|
+
label: `POST /api/${cleanRoute} (Create)`,
|
|
106
|
+
content: `'use strict';
|
|
107
|
+
|
|
108
|
+
const { defineApi } = require('webspresso');
|
|
109
|
+
|
|
110
|
+
module.exports = defineApi({
|
|
111
|
+
schema: ({ z }) => ({
|
|
112
|
+
body: z.object({
|
|
113
|
+
title: z.string().min(1).optional(),
|
|
114
|
+
}).passthrough(),
|
|
115
|
+
}),${middlewareStr}
|
|
116
|
+
handler: async (req, res) => {
|
|
117
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
118
|
+
const created = repo ? await repo.create(req.input.body) : req.input.body;
|
|
119
|
+
return res.status(201).json(created);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
`,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
path: path.join(detailDir, '[id].get.js'),
|
|
126
|
+
label: `GET /api/${cleanRoute}/:id (Detail)`,
|
|
127
|
+
content: `'use strict';
|
|
128
|
+
|
|
129
|
+
const { defineApi } = require('webspresso');
|
|
130
|
+
|
|
131
|
+
module.exports = defineApi({
|
|
132
|
+
schema: ({ z }) => ({
|
|
133
|
+
params: z.object({
|
|
134
|
+
id: z.coerce.number().or(z.string().min(1)),
|
|
135
|
+
}),
|
|
136
|
+
}),${middlewareStr}
|
|
137
|
+
handler: async (req, res) => {
|
|
138
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
139
|
+
const item = repo ? await repo.findById(req.params.id) : null;
|
|
140
|
+
if (!item) {
|
|
141
|
+
return res.status(404).json({ error: '${modelName} not found' });
|
|
142
|
+
}
|
|
143
|
+
return res.json(item);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
`,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
path: path.join(detailDir, '[id].patch.js'),
|
|
150
|
+
label: `PATCH /api/${cleanRoute}/:id (Update)`,
|
|
151
|
+
content: `'use strict';
|
|
152
|
+
|
|
153
|
+
const { defineApi } = require('webspresso');
|
|
154
|
+
|
|
155
|
+
module.exports = defineApi({
|
|
156
|
+
schema: ({ z }) => ({
|
|
157
|
+
params: z.object({
|
|
158
|
+
id: z.coerce.number().or(z.string().min(1)),
|
|
159
|
+
}),
|
|
160
|
+
body: z.record(z.unknown()).optional(),
|
|
161
|
+
}),${middlewareStr}
|
|
162
|
+
handler: async (req, res) => {
|
|
163
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
164
|
+
const updated = repo ? await repo.update(req.params.id, req.input.body || {}) : null;
|
|
165
|
+
if (!updated) {
|
|
166
|
+
return res.status(404).json({ error: '${modelName} not found' });
|
|
167
|
+
}
|
|
168
|
+
return res.json(updated);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
`,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
path: path.join(detailDir, '[id].delete.js'),
|
|
175
|
+
label: `DELETE /api/${cleanRoute}/:id (Delete)`,
|
|
176
|
+
content: `'use strict';
|
|
177
|
+
|
|
178
|
+
const { defineApi } = require('webspresso');
|
|
179
|
+
|
|
180
|
+
module.exports = defineApi({
|
|
181
|
+
schema: ({ z }) => ({
|
|
182
|
+
params: z.object({
|
|
183
|
+
id: z.coerce.number().or(z.string().min(1)),
|
|
184
|
+
}),
|
|
185
|
+
}),${middlewareStr}
|
|
186
|
+
handler: async (req, res) => {
|
|
187
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
188
|
+
if (repo) {
|
|
189
|
+
await repo.delete(req.params.id);
|
|
190
|
+
}
|
|
191
|
+
return res.status(204).end();
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
`,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
for (const f of files) {
|
|
199
|
+
fs.writeFileSync(f.path, f.content, 'utf8');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return files;
|
|
203
|
+
}
|
|
204
|
+
|
|
48
205
|
function registerCommand(program) {
|
|
49
206
|
program
|
|
50
207
|
.command('api [route]')
|
|
208
|
+
.alias('make:api')
|
|
209
|
+
.alias('api:make')
|
|
51
210
|
.description('Add a new API endpoint to pages/api/ with schema validation and handler')
|
|
52
211
|
.option('-m, --method <method>', 'HTTP method (GET, POST, PUT, PATCH, DELETE)', 'GET')
|
|
212
|
+
.option('--crud', 'Generate full REST CRUD endpoints (list, create, get, update, delete)')
|
|
213
|
+
.option('--model <model>', 'Associated ORM model name for repository calls')
|
|
53
214
|
.option('--auth', 'Protect with session auth middleware')
|
|
54
215
|
.option('--jwt', 'Protect with JWT bearer middleware')
|
|
216
|
+
.option('--root <dir>', 'Root directory of the project', process.cwd())
|
|
55
217
|
.action(async (routeArg, options) => {
|
|
56
|
-
const
|
|
218
|
+
const rootDir = path.resolve(options.root || process.cwd());
|
|
219
|
+
const pagesDir = path.join(rootDir, 'pages');
|
|
57
220
|
if (!fs.existsSync(pagesDir)) {
|
|
58
|
-
|
|
59
|
-
|
|
221
|
+
fs.mkdirSync(pagesDir, { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const apiDir = path.join(pagesDir, 'api');
|
|
225
|
+
if (!fs.existsSync(apiDir)) {
|
|
226
|
+
fs.mkdirSync(apiDir, { recursive: true });
|
|
60
227
|
}
|
|
61
228
|
|
|
62
229
|
let route;
|
|
@@ -83,17 +250,39 @@ function registerCommand(program) {
|
|
|
83
250
|
message: 'HTTP method:',
|
|
84
251
|
choices: VALID_METHODS,
|
|
85
252
|
default: options.method ? options.method.toUpperCase() : 'GET',
|
|
253
|
+
when: () => !options.crud,
|
|
86
254
|
},
|
|
87
255
|
]);
|
|
88
256
|
|
|
89
|
-
const parsed = parseRouteAndMethod(answers.route, answers.method);
|
|
257
|
+
const parsed = parseRouteAndMethod(answers.route, answers.method || options.method);
|
|
90
258
|
route = parsed.route;
|
|
91
259
|
method = parsed.method;
|
|
92
260
|
}
|
|
93
261
|
|
|
262
|
+
const middlewares = [];
|
|
263
|
+
if (options.auth) middlewares.push("'auth'");
|
|
264
|
+
if (options.jwt) middlewares.push("'jwt'");
|
|
265
|
+
const middlewareStr = middlewares.length > 0 ? `\n middleware: [${middlewares.join(', ')}],` : '';
|
|
266
|
+
|
|
267
|
+
// CRUD Generation Mode
|
|
268
|
+
if (options.crud) {
|
|
269
|
+
const lastSegment = route.split('/').filter(Boolean).pop() || route;
|
|
270
|
+
const singular = singularize(lastSegment);
|
|
271
|
+
const modelName = options.model ? String(options.model).trim() : toPascalCase(singular);
|
|
272
|
+
|
|
273
|
+
const createdFiles = generateCrudFiles(apiDir, route, modelName, middlewareStr);
|
|
274
|
+
console.log(`\n✅ Generated REST CRUD API endpoints for "${modelName}" in pages/api/${route}:`);
|
|
275
|
+
for (const cf of createdFiles) {
|
|
276
|
+
console.log(` - ${cf.label} → ${path.relative(process.cwd(), cf.path)}`);
|
|
277
|
+
}
|
|
278
|
+
console.log('');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Single Route Generation Mode
|
|
94
283
|
const methodUpper = String(method).toUpperCase();
|
|
95
284
|
const methodLower = methodUpper.toLowerCase();
|
|
96
|
-
const routePath = path.join(
|
|
285
|
+
const routePath = path.join(apiDir, route);
|
|
97
286
|
const dirPath = path.dirname(routePath);
|
|
98
287
|
const fileName = path.basename(routePath);
|
|
99
288
|
|
|
@@ -101,11 +290,6 @@ function registerCommand(program) {
|
|
|
101
290
|
|
|
102
291
|
const apiFile = path.join(dirPath, `${fileName}.${methodLower}.js`);
|
|
103
292
|
|
|
104
|
-
const middlewares = [];
|
|
105
|
-
if (options.auth) middlewares.push("'auth'");
|
|
106
|
-
if (options.jwt) middlewares.push("'jwt'");
|
|
107
|
-
const middlewareStr = middlewares.length > 0 ? `\n middleware: [${middlewares.join(', ')}],` : '';
|
|
108
|
-
|
|
109
293
|
const hasParams = route.includes('[');
|
|
110
294
|
const hasBody = ['POST', 'PUT', 'PATCH'].includes(methodUpper);
|
|
111
295
|
|
|
@@ -114,10 +298,12 @@ function registerCommand(program) {
|
|
|
114
298
|
/**
|
|
115
299
|
* ${methodUpper} /api/${route}
|
|
116
300
|
*/
|
|
117
|
-
|
|
301
|
+
const { defineApi } = require('webspresso');
|
|
302
|
+
|
|
303
|
+
module.exports = defineApi({
|
|
118
304
|
// Input validation schema (Zod)
|
|
119
305
|
schema: ({ z }) => ({
|
|
120
|
-
${hasParams ? 'params: z.object({ id: z.string().min(1) }),\n ' : ''}${hasBody ? 'body: z.object({\n // Define body schema here\n }),\n ' : ''}query: z.object({\n // Define query params here\n }),
|
|
306
|
+
${hasParams ? 'params: z.object({ id: z.coerce.number().or(z.string().min(1)) }),\n ' : ''}${hasBody ? 'body: z.object({\n // Define body schema here\n }).passthrough(),\n ' : ''}query: z.object({\n // Define query params here\n }),
|
|
121
307
|
}),${middlewareStr}
|
|
122
308
|
|
|
123
309
|
// Request Handler
|
|
@@ -131,7 +317,7 @@ module.exports = {
|
|
|
131
317
|
timestamp: new Date().toISOString(),
|
|
132
318
|
});
|
|
133
319
|
},
|
|
134
|
-
};
|
|
320
|
+
});
|
|
135
321
|
`;
|
|
136
322
|
|
|
137
323
|
fs.writeFileSync(apiFile, apiContent, 'utf8');
|
|
@@ -139,4 +325,10 @@ module.exports = {
|
|
|
139
325
|
});
|
|
140
326
|
}
|
|
141
327
|
|
|
142
|
-
module.exports = {
|
|
328
|
+
module.exports = {
|
|
329
|
+
registerCommand,
|
|
330
|
+
parseRouteAndMethod,
|
|
331
|
+
singularize,
|
|
332
|
+
toPascalCase,
|
|
333
|
+
generateCrudFiles,
|
|
334
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build Command
|
|
3
|
+
* Build production assets (Tailwind CSS and esbuild JS bundle with manifest.json)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
const { buildBundle } = require('../../src/bundle');
|
|
10
|
+
|
|
11
|
+
function registerCommand(program) {
|
|
12
|
+
program
|
|
13
|
+
.command('build')
|
|
14
|
+
.alias('build:assets')
|
|
15
|
+
.description('Build production assets (Tailwind CSS and esbuild JS with manifest.json)')
|
|
16
|
+
.option('--no-css', 'Skip CSS build')
|
|
17
|
+
.option('--no-js', 'Skip JS bundle build')
|
|
18
|
+
.option('--no-hash', 'Disable content hashing in asset filenames')
|
|
19
|
+
.action(async (options = {}) => {
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
22
|
+
if (!fs.existsSync(pkgPath)) {
|
|
23
|
+
console.error('❌ Not a Webspresso project! Run this command in your project directory.');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let packageJson = {};
|
|
28
|
+
try {
|
|
29
|
+
packageJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
|
|
32
|
+
console.log('\n📦 Building Webspresso production assets...\n');
|
|
33
|
+
|
|
34
|
+
let cssBuilt = false;
|
|
35
|
+
let jsBuilt = false;
|
|
36
|
+
|
|
37
|
+
// 1. Build CSS (Tailwind)
|
|
38
|
+
if (options.css !== false) {
|
|
39
|
+
const hasCssScript = Boolean(packageJson.scripts && packageJson.scripts['build:css']);
|
|
40
|
+
const hasInputCss = fs.existsSync(path.join(cwd, 'src', 'input.css'));
|
|
41
|
+
|
|
42
|
+
if (hasCssScript) {
|
|
43
|
+
try {
|
|
44
|
+
console.log('🎨 Compiling Tailwind CSS...');
|
|
45
|
+
execSync('npm run build:css', { stdio: 'inherit', cwd });
|
|
46
|
+
cssBuilt = true;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('❌ CSS build failed:', err.message);
|
|
49
|
+
}
|
|
50
|
+
} else if (hasInputCss) {
|
|
51
|
+
console.log('ℹ️ src/input.css found but no "build:css" script. Run "npx webspresso add tailwind" to configure.');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Build JS Bundle with manifest.json (esbuild)
|
|
56
|
+
if (options.js !== false) {
|
|
57
|
+
const hasClientEntry = fs.existsSync(path.join(cwd, 'src', 'client', 'main.js')) ||
|
|
58
|
+
fs.existsSync(path.join(cwd, 'src', 'client', 'main.ts')) ||
|
|
59
|
+
fs.existsSync(path.join(cwd, 'src', 'client', 'index.js'));
|
|
60
|
+
const hasEsbuild = Boolean(
|
|
61
|
+
(packageJson.devDependencies && packageJson.devDependencies.esbuild) ||
|
|
62
|
+
(packageJson.dependencies && packageJson.dependencies.esbuild)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (hasClientEntry || hasEsbuild) {
|
|
66
|
+
try {
|
|
67
|
+
console.log('⚡ Bundling client JavaScript with esbuild...');
|
|
68
|
+
const result = await buildBundle({
|
|
69
|
+
cwd,
|
|
70
|
+
hash: options.hash !== false,
|
|
71
|
+
minify: true,
|
|
72
|
+
sourcemap: true,
|
|
73
|
+
outdir: path.join(cwd, 'public', 'dist'),
|
|
74
|
+
});
|
|
75
|
+
jsBuilt = true;
|
|
76
|
+
console.log(`✅ Client assets bundled and manifest created at: ${path.relative(cwd, result.manifestPath)}`);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// Fallback to npm run build:js if custom script exists
|
|
79
|
+
if (packageJson.scripts && packageJson.scripts['build:js']) {
|
|
80
|
+
try {
|
|
81
|
+
execSync('npm run build:js', { stdio: 'inherit', cwd });
|
|
82
|
+
jsBuilt = true;
|
|
83
|
+
} catch (subErr) {
|
|
84
|
+
console.error('❌ JS build failed:', subErr.message);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
console.error('❌ JS bundle build failed:', err.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log('\n✨ Asset build complete!\n');
|
|
94
|
+
if (cssBuilt) console.log(' ✓ CSS: public/css/style.css');
|
|
95
|
+
if (jsBuilt) console.log(' ✓ JS: public/dist/ & manifest.json');
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { registerCommand };
|
package/bin/commands/dev.js
CHANGED
|
@@ -32,6 +32,7 @@ function registerCommand(program) {
|
|
|
32
32
|
.description('Start development server')
|
|
33
33
|
.option('-p, --port <port>', 'Port number', '3000')
|
|
34
34
|
.option('--no-css', 'Skip CSS watch (if Tailwind is set up)')
|
|
35
|
+
.option('--no-js', 'Skip client JS watch (if esbuild / watch:js is set up)')
|
|
35
36
|
.action((options) => {
|
|
36
37
|
if (!fs.existsSync('server.js')) {
|
|
37
38
|
console.error('❌ server.js not found! Make sure you are in a Webspresso project.');
|
|
@@ -41,56 +42,84 @@ function registerCommand(program) {
|
|
|
41
42
|
process.env.PORT = options.port;
|
|
42
43
|
process.env.NODE_ENV = 'development';
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
let packageJson = {};
|
|
46
|
+
if (fs.existsSync('package.json')) {
|
|
47
|
+
try {
|
|
48
|
+
packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
|
49
|
+
} catch (_) {}
|
|
50
|
+
}
|
|
51
|
+
const hasTailwindV3 = fs.existsSync('tailwind.config.js') && fs.existsSync('src/input.css');
|
|
52
|
+
const hasTailwindV4 = fs.existsSync('src/input.css') && Boolean(
|
|
53
|
+
(packageJson.devDependencies && (packageJson.devDependencies['@tailwindcss/cli'] || packageJson.devDependencies['tailwindcss'])) ||
|
|
54
|
+
(packageJson.scripts && packageJson.scripts['watch:css'])
|
|
55
|
+
);
|
|
56
|
+
const hasTailwind = hasTailwindV3 || hasTailwindV4;
|
|
45
57
|
const shouldWatchCss = hasTailwind && options.css !== false;
|
|
58
|
+
|
|
59
|
+
const hasJsWatch = Boolean(
|
|
60
|
+
(packageJson.scripts && packageJson.scripts['watch:js']) ||
|
|
61
|
+
fs.existsSync('src/client/main.js') ||
|
|
62
|
+
fs.existsSync('src/client/main.ts') ||
|
|
63
|
+
fs.existsSync('esbuild.config.js')
|
|
64
|
+
);
|
|
65
|
+
const shouldWatchJs = hasJsWatch && options.js !== false && Boolean(packageJson.scripts && packageJson.scripts['watch:js']);
|
|
46
66
|
|
|
47
67
|
// Build watch arguments
|
|
48
68
|
const watchArgs = buildWatchArgs();
|
|
49
69
|
const watchDirs = watchArgs.filter(a => a.startsWith('--watch-path')).map(a => a.split('=')[1]);
|
|
50
70
|
|
|
71
|
+
const watchingLabels = ['server.js'];
|
|
72
|
+
if (watchDirs.length) watchingLabels.push(...watchDirs);
|
|
73
|
+
if (shouldWatchCss) watchingLabels.push('CSS');
|
|
74
|
+
if (shouldWatchJs) watchingLabels.push('JS (esbuild)');
|
|
75
|
+
|
|
76
|
+
console.log(`\n🚀 Starting development server on port ${options.port}...`);
|
|
77
|
+
console.log(` Watching: ${watchingLabels.join(', ')}\n`);
|
|
78
|
+
|
|
79
|
+
const managedProcesses = [];
|
|
80
|
+
let isCleaningUp = false;
|
|
81
|
+
|
|
82
|
+
const cleanup = (code = 0) => {
|
|
83
|
+
if (isCleaningUp) return;
|
|
84
|
+
isCleaningUp = true;
|
|
85
|
+
for (const p of managedProcesses) {
|
|
86
|
+
try {
|
|
87
|
+
if (p && !p.killed) {
|
|
88
|
+
p.kill('SIGTERM');
|
|
89
|
+
}
|
|
90
|
+
} catch (_) {}
|
|
91
|
+
}
|
|
92
|
+
process.exit(code);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
process.once('SIGINT', () => cleanup(0));
|
|
96
|
+
process.once('SIGTERM', () => cleanup(0));
|
|
97
|
+
|
|
51
98
|
if (shouldWatchCss) {
|
|
52
|
-
console.log(`\n🚀 Starting development server on port ${options.port}...`);
|
|
53
|
-
console.log(` Watching: server.js${watchDirs.length ? ', ' + watchDirs.join(', ') : ''}, CSS\n`);
|
|
54
|
-
|
|
55
|
-
// Start CSS watch
|
|
56
99
|
const cssWatch = spawn('npm', ['run', 'watch:css'], {
|
|
57
|
-
stdio: 'inherit',
|
|
58
|
-
shell: true
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// Start server with watch paths
|
|
62
|
-
const server = spawn('node', watchArgs, {
|
|
63
100
|
stdio: 'inherit',
|
|
64
101
|
shell: true,
|
|
65
|
-
env: { ...process.env, PORT: options.port, NODE_ENV: 'development' }
|
|
66
102
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
process.on('SIGINT', cleanup);
|
|
76
|
-
process.on('SIGTERM', cleanup);
|
|
77
|
-
|
|
78
|
-
cssWatch.on('exit', cleanup);
|
|
79
|
-
server.on('exit', cleanup);
|
|
80
|
-
} else {
|
|
81
|
-
console.log(`\n🚀 Starting development server on port ${options.port}...`);
|
|
82
|
-
console.log(` Watching: server.js${watchDirs.length ? ', ' + watchDirs.join(', ') : ''}\n`);
|
|
83
|
-
|
|
84
|
-
const child = spawn('node', watchArgs, {
|
|
103
|
+
managedProcesses.push(cssWatch);
|
|
104
|
+
cssWatch.on('exit', () => cleanup(0));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (shouldWatchJs) {
|
|
108
|
+
const jsWatch = spawn('npm', ['run', 'watch:js'], {
|
|
85
109
|
stdio: 'inherit',
|
|
86
110
|
shell: true,
|
|
87
|
-
env: { ...process.env, PORT: options.port, NODE_ENV: 'development' }
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
child.on('exit', (code) => {
|
|
91
|
-
process.exit(code || 0);
|
|
92
111
|
});
|
|
112
|
+
managedProcesses.push(jsWatch);
|
|
113
|
+
jsWatch.on('exit', () => cleanup(0));
|
|
93
114
|
}
|
|
115
|
+
|
|
116
|
+
const server = spawn('node', watchArgs, {
|
|
117
|
+
stdio: 'inherit',
|
|
118
|
+
shell: true,
|
|
119
|
+
env: { ...process.env, PORT: options.port, NODE_ENV: 'development' },
|
|
120
|
+
});
|
|
121
|
+
managedProcesses.push(server);
|
|
122
|
+
server.on('exit', (code) => cleanup(code || 0));
|
|
94
123
|
});
|
|
95
124
|
}
|
|
96
125
|
|
package/bin/commands/doctor.js
CHANGED
|
@@ -215,6 +215,32 @@ function registerCommand(program) {
|
|
|
215
215
|
warnings += 1;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
console.log('\nTailwind CSS');
|
|
219
|
+
console.log('------------');
|
|
220
|
+
const inputCssPath = path.join(cwd, 'src', 'input.css');
|
|
221
|
+
if (fs.existsSync(inputCssPath)) {
|
|
222
|
+
let isV4 = false;
|
|
223
|
+
try {
|
|
224
|
+
const inputCssContent = fs.readFileSync(inputCssPath, 'utf8');
|
|
225
|
+
isV4 = inputCssContent.includes('@import "tailwindcss"') || inputCssContent.includes("@import 'tailwindcss'");
|
|
226
|
+
} catch (_) {}
|
|
227
|
+
|
|
228
|
+
if (isV4) {
|
|
229
|
+
line('✓', 'Tailwind CSS v4 detected (@import "tailwindcss" — Oxide engine, zero-config)');
|
|
230
|
+
} else {
|
|
231
|
+
line('✓', 'Tailwind CSS v3 detected (PostCSS / tailwind.config.js)');
|
|
232
|
+
}
|
|
233
|
+
const outputCss = path.join(cwd, 'public', 'css', 'style.css');
|
|
234
|
+
if (fs.existsSync(outputCss)) {
|
|
235
|
+
line('✓', 'public/css/style.css compiled output present');
|
|
236
|
+
} else {
|
|
237
|
+
line('⚠', 'public/css/style.css not found. Run "npm run build:css" to compile stylesheets.');
|
|
238
|
+
warnings += 1;
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
line('○', 'No src/input.css found (Tailwind CSS not configured)');
|
|
242
|
+
}
|
|
243
|
+
|
|
218
244
|
console.log('\nAI Agent Guidelines');
|
|
219
245
|
console.log('-------------------');
|
|
220
246
|
const agentsMdPath = path.join(cwd, 'AGENTS.md');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune old email log records (CLI)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { loadDbConfig, createDbInstance } = require('../utils/db');
|
|
8
|
+
|
|
9
|
+
function registerCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('email:prune')
|
|
12
|
+
.description('Delete email log rows older than the given retention window')
|
|
13
|
+
.option('--days <n>', 'Delete rows older than this many days', '90')
|
|
14
|
+
.option('--table <name>', 'Table name', 'email_logs')
|
|
15
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
16
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
const days = parseInt(options.days, 10);
|
|
19
|
+
if (Number.isNaN(days) || days < 1) {
|
|
20
|
+
console.error('❌ --days must be a positive integer');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
25
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
26
|
+
console.log(` Environment: ${options.env}\n`);
|
|
27
|
+
|
|
28
|
+
const knex = await createDbInstance(config, options.env);
|
|
29
|
+
const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const { purgeEmailLogs } = require('../../plugins/email/purge');
|
|
33
|
+
const deleted = await purgeEmailLogs(knex, {
|
|
34
|
+
tableName: options.table,
|
|
35
|
+
olderThan,
|
|
36
|
+
});
|
|
37
|
+
console.log(`✅ Deleted ${deleted} email log row(s) with created_at before ${olderThan.toISOString()}.\n`);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('❌ email:prune failed:', err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
} finally {
|
|
42
|
+
await knex.destroy();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { registerCommand };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso CLI - make:job Command
|
|
5
|
+
* Scaffolds a background queue job worker in jobs/<name>.js
|
|
6
|
+
* @module bin/commands/make-job
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
function generateJobContent(jobName, queueName = 'default', retries = 3) {
|
|
13
|
+
return `'use strict';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Background Job: ${jobName}
|
|
17
|
+
* Dispatched via: req.queue.dispatch('${jobName}', payload)
|
|
18
|
+
*/
|
|
19
|
+
module.exports = {
|
|
20
|
+
name: '${jobName}',
|
|
21
|
+
queue: '${queueName}',
|
|
22
|
+
retries: ${retries},
|
|
23
|
+
backoff: {
|
|
24
|
+
type: 'exponential',
|
|
25
|
+
delay: 1000,
|
|
26
|
+
},
|
|
27
|
+
handler: async (payload, ctx) => {
|
|
28
|
+
const { db, logger } = ctx;
|
|
29
|
+
if (logger) logger.info('[Job:${jobName}] Processing job:', payload);
|
|
30
|
+
|
|
31
|
+
// Business logic goes here:
|
|
32
|
+
// const repo = db ? db.getRepository('...') : null;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function registerCommand(program) {
|
|
39
|
+
program
|
|
40
|
+
.command('make:job <name>')
|
|
41
|
+
.alias('job:make')
|
|
42
|
+
.description('Scaffold a background queue job worker in jobs/<name>.js')
|
|
43
|
+
.option('-q, --queue <queue>', 'Target queue name', 'default')
|
|
44
|
+
.option('-r, --retries <number>', 'Number of retries upon failure', '3')
|
|
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 jobsDir = path.join(rootDir, 'jobs');
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(jobsDir)) {
|
|
51
|
+
fs.mkdirSync(jobsDir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const jobName = String(nameArg || '').trim();
|
|
55
|
+
if (!jobName) {
|
|
56
|
+
console.error('❌ Job name is required.');
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Support nested folders e.g. emails/welcome
|
|
62
|
+
const targetFilePath = path.join(jobsDir, `${jobName}.js`);
|
|
63
|
+
const targetDir = path.dirname(targetFilePath);
|
|
64
|
+
|
|
65
|
+
if (!fs.existsSync(targetDir)) {
|
|
66
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fs.existsSync(targetFilePath)) {
|
|
70
|
+
console.error(`❌ Job file already exists: jobs/${jobName}.js`);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const retries = parseInt(options.retries, 10) || 3;
|
|
76
|
+
const content = generateJobContent(jobName, options.queue || 'default', retries);
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(targetFilePath, content, 'utf-8');
|
|
79
|
+
console.log(`\n✅ Created background job: jobs/${jobName}.js (queue: "${options.queue || 'default'}", retries: ${retries})\n`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
registerCommand,
|
|
85
|
+
generateJobContent,
|
|
86
|
+
};
|