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.
Files changed (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -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 pagesDir = path.resolve(process.cwd(), 'pages');
218
+ const rootDir = path.resolve(options.root || process.cwd());
219
+ const pagesDir = path.join(rootDir, 'pages');
57
220
  if (!fs.existsSync(pagesDir)) {
58
- console.error('❌ Not a Webspresso project! (pages/ directory not found)');
59
- process.exit(1);
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(pagesDir, 'api', route);
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
- module.exports = {
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 = { registerCommand, parseRouteAndMethod };
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 };
@@ -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
- const hasTailwind = fs.existsSync('tailwind.config.js') && fs.existsSync('src/input.css');
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
- // Handle exit
69
- const cleanup = () => {
70
- cssWatch.kill();
71
- server.kill();
72
- process.exit(0);
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
 
@@ -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
+ };