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
@@ -0,0 +1,250 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - make:model Command
5
+ * Scaffolds an ORM model in models/<Name>.js with zdb schema and optional migration
6
+ * @module bin/commands/make-model
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ function pluralize(word) {
13
+ if (!word) return '';
14
+ const lower = word.toLowerCase();
15
+ if (lower.endsWith('y') && !/[aeiou]y$/.test(lower)) {
16
+ return lower.slice(0, -1) + 'ies';
17
+ }
18
+ if (lower.endsWith('s') || lower.endsWith('sh') || lower.endsWith('ch') || lower.endsWith('x') || lower.endsWith('z')) {
19
+ return lower + 'es';
20
+ }
21
+ return lower + 's';
22
+ }
23
+
24
+ function parseFields(fieldsArg) {
25
+ if (!fieldsArg || typeof fieldsArg !== 'string') return [];
26
+ const parts = fieldsArg.split(',').map((p) => p.trim()).filter(Boolean);
27
+ const fields = [];
28
+
29
+ for (const part of parts) {
30
+ const [name, type = 'string', modifier = ''] = part.split(':').map((s) => s.trim());
31
+ if (!name) continue;
32
+ fields.push({
33
+ name,
34
+ type: type.toLowerCase(),
35
+ isNullable: modifier.toLowerCase() === 'nullable' || modifier.toLowerCase() === 'optional' || name.endsWith('?'),
36
+ });
37
+ }
38
+
39
+ return fields;
40
+ }
41
+
42
+ function generateSchemaLines(fields, softDeletes = false) {
43
+ const lines = [' id: zdb.id(),'];
44
+
45
+ for (const f of fields) {
46
+ if (f.name === 'id') continue;
47
+ let expr = 'zdb.string().min(1)';
48
+
49
+ switch (f.type) {
50
+ case 'text':
51
+ expr = 'zdb.text().optional()';
52
+ break;
53
+ case 'integer':
54
+ case 'int':
55
+ expr = f.isNullable ? 'zdb.integer().nullable().optional()' : 'zdb.integer().defaultTo(0)';
56
+ break;
57
+ case 'number':
58
+ case 'float':
59
+ case 'decimal':
60
+ expr = f.isNullable ? 'zdb.number().nullable().optional()' : 'zdb.number().defaultTo(0)';
61
+ break;
62
+ case 'boolean':
63
+ case 'bool':
64
+ expr = 'zdb.boolean().defaultTo(false)';
65
+ break;
66
+ case 'json':
67
+ expr = 'zdb.json().optional()';
68
+ break;
69
+ case 'date':
70
+ case 'datetime':
71
+ expr = 'zdb.date().optional()';
72
+ break;
73
+ case 'email':
74
+ expr = 'zdb.email().optional()';
75
+ break;
76
+ case 'file':
77
+ expr = 'zdb.file().optional()';
78
+ break;
79
+ case 'id':
80
+ expr = 'zdb.id()';
81
+ break;
82
+ case 'string':
83
+ default:
84
+ expr = f.isNullable ? 'zdb.string().nullable().optional()' : 'zdb.string().min(1)';
85
+ break;
86
+ }
87
+
88
+ lines.push(` ${f.name}: ${expr},`);
89
+ }
90
+
91
+ if (softDeletes) {
92
+ lines.push(' deletedAt: zdb.softDelete().optional(),');
93
+ }
94
+
95
+ lines.push(' createdAt: zdb.timestamps().createdAt,');
96
+ lines.push(' updatedAt: zdb.timestamps().updatedAt,');
97
+
98
+ return lines.join('\n');
99
+ }
100
+
101
+ function generateModelContent(modelName, tableName, fields, softDeletes = false) {
102
+ const schemaCode = generateSchemaLines(fields, softDeletes);
103
+ const softScope = softDeletes ? `,\n scopes: {\n default: (q) => q.whereNull('deletedAt'),\n }` : '';
104
+
105
+ return `'use strict';
106
+
107
+ const { defineModel, zdb } = require('webspresso');
108
+
109
+ module.exports = defineModel({
110
+ name: '${modelName}',
111
+ table: '${tableName}',
112
+ schema: {
113
+ ${schemaCode}
114
+ }${softScope},
115
+ });
116
+ `;
117
+ }
118
+
119
+ function generateMigrationContent(tableName, fields, softDeletes = false) {
120
+ const colLines = [' table.increments(\'id\').primary();'];
121
+
122
+ for (const f of fields) {
123
+ if (f.name === 'id') continue;
124
+ switch (f.type) {
125
+ case 'text':
126
+ colLines.push(` table.text('${f.name}')${f.isNullable ? '.nullable()' : ''};`);
127
+ break;
128
+ case 'integer':
129
+ case 'int':
130
+ colLines.push(` table.integer('${f.name}')${f.isNullable ? '.nullable()' : '.defaultTo(0)'};`);
131
+ break;
132
+ case 'number':
133
+ case 'float':
134
+ case 'decimal':
135
+ colLines.push(` table.float('${f.name}')${f.isNullable ? '.nullable()' : '.defaultTo(0)'};`);
136
+ break;
137
+ case 'boolean':
138
+ case 'bool':
139
+ colLines.push(` table.boolean('${f.name}').defaultTo(false);`);
140
+ break;
141
+ case 'json':
142
+ colLines.push(` table.json('${f.name}').nullable();`);
143
+ break;
144
+ case 'date':
145
+ case 'datetime':
146
+ colLines.push(` table.datetime('${f.name}').nullable();`);
147
+ break;
148
+ default:
149
+ colLines.push(` table.string('${f.name}')${f.isNullable ? '.nullable()' : '.notNullable()'};`);
150
+ break;
151
+ }
152
+ }
153
+
154
+ if (softDeletes) {
155
+ colLines.push(' table.datetime(\'deletedAt\').nullable();');
156
+ }
157
+
158
+ colLines.push(' table.timestamps(true, true);');
159
+
160
+ return `'use strict';
161
+
162
+ /**
163
+ * Migration: Create ${tableName} table
164
+ */
165
+ exports.up = function(knex) {
166
+ return knex.schema.createTable('${tableName}', (table) => {
167
+ ${colLines.join('\n')}
168
+ });
169
+ };
170
+
171
+ exports.down = function(knex) {
172
+ return knex.schema.dropTableIfExists('${tableName}');
173
+ };
174
+ `;
175
+ }
176
+
177
+ function registerCommand(program) {
178
+ program
179
+ .command('make:model <name>')
180
+ .alias('model:make')
181
+ .description('Scaffold an ORM model in models/<Name>.js with zdb schema')
182
+ .option('-f, --fields <fields>', 'Comma-separated fields (e.g. title:string,content:text,published:boolean)')
183
+ .option('-t, --table <table>', 'Custom table name (defaults to pluralized lowercase)')
184
+ .option('-m, --migration', 'Generate corresponding Knex migration file in migrations/')
185
+ .option('--soft-deletes', 'Include deletedAt column and default scope for soft delete')
186
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
187
+ .action((nameArg, options) => {
188
+ const rootDir = path.resolve(options.root || process.cwd());
189
+ const modelsDir = path.join(rootDir, 'models');
190
+ const migrationsDir = path.join(rootDir, 'migrations');
191
+
192
+ if (!fs.existsSync(modelsDir)) {
193
+ fs.mkdirSync(modelsDir, { recursive: true });
194
+ }
195
+
196
+ // Format Model Name (PascalCase, e.g. post -> Post, blog_post -> BlogPost)
197
+ const cleanName = String(nameArg || '').trim();
198
+ const modelName = cleanName.charAt(0).toUpperCase() + cleanName.slice(1);
199
+ const tableName = options.table ? String(options.table).trim() : pluralize(cleanName.toLowerCase());
200
+
201
+ const modelFilePath = path.join(modelsDir, `${modelName}.js`);
202
+ if (fs.existsSync(modelFilePath)) {
203
+ console.error(`āŒ Model file already exists: models/${modelName}.js`);
204
+ process.exitCode = 1;
205
+ return;
206
+ }
207
+
208
+ const fields = parseFields(options.fields);
209
+ const modelContent = generateModelContent(modelName, tableName, fields, Boolean(options.softDeletes));
210
+
211
+ fs.writeFileSync(modelFilePath, modelContent, 'utf-8');
212
+ console.log(`\nāœ… Created model: models/${modelName}.js (table: "${tableName}")`);
213
+
214
+ // Optional migration generation
215
+ if (options.migration) {
216
+ if (!fs.existsSync(migrationsDir)) {
217
+ fs.mkdirSync(migrationsDir, { recursive: true });
218
+ }
219
+
220
+ const now = new Date();
221
+ const timestamp = [
222
+ now.getFullYear(),
223
+ String(now.getMonth() + 1).padStart(2, '0'),
224
+ String(now.getDate()).padStart(2, '0'),
225
+ '_',
226
+ String(now.getHours()).padStart(2, '0'),
227
+ String(now.getMinutes()).padStart(2, '0'),
228
+ String(now.getSeconds()).padStart(2, '0'),
229
+ ].join('');
230
+
231
+ const migrationFileName = `${timestamp}_create_${tableName}_table.js`;
232
+ const migrationFilePath = path.join(migrationsDir, migrationFileName);
233
+ const migrationContent = generateMigrationContent(tableName, fields, Boolean(options.softDeletes));
234
+
235
+ fs.writeFileSync(migrationFilePath, migrationContent, 'utf-8');
236
+ console.log(`āœ… Created migration: migrations/${migrationFileName}`);
237
+ }
238
+
239
+ console.log('');
240
+ });
241
+ }
242
+
243
+ module.exports = {
244
+ registerCommand,
245
+ pluralize,
246
+ parseFields,
247
+ generateSchemaLines,
248
+ generateModelContent,
249
+ generateMigrationContent,
250
+ };
@@ -46,6 +46,8 @@ function registerCommand(program) {
46
46
  .description('Create a new Webspresso project')
47
47
  .option('-t, --template <template>', 'Template to use (minimal, full)', 'minimal')
48
48
  .option('--no-tailwind', 'Skip Tailwind CSS setup')
49
+ .option('--tailwind-v4', 'Use Tailwind CSS v4 (Oxide engine, zero-config)')
50
+ .option('--tailwind-version <version>', 'Tailwind version (3 or 4)', '3')
49
51
  .option('--no-agents', 'Skip AI Agent guidelines setup (.agents/, AGENTS.md, CLAUDE.md, .cursorrules)')
50
52
  .option('-i, --install', 'Auto install dependencies and build CSS')
51
53
  .option('-y, --yes', 'Non-interactive: no database, skip install unless -i/--install, skip dev server')
@@ -657,20 +659,43 @@ Visit http://localhost:3000
657
659
 
658
660
  // Add Tailwind if requested (default: true)
659
661
  if (useTailwind) {
660
- console.log('\nšŸŽØ Setting up Tailwind CSS...\n');
662
+ const isV4 = Boolean(options.tailwindV4 || options.tailwindVersion === '4');
663
+ console.log(`\nšŸŽØ Setting up Tailwind CSS ${isV4 ? 'v4 (Oxide engine, zero-config)' : 'v3'}...\n`);
661
664
 
662
665
  // Create src directory
663
666
  fs.mkdirSync(path.join(projectPath, 'src'), { recursive: true });
664
667
 
665
- // Create input.css
666
- const inputCss = `@tailwind base;
668
+ // Create public/css directory
669
+ fs.mkdirSync(path.join(projectPath, 'public', 'css'), { recursive: true });
670
+ fs.writeFileSync(path.join(projectPath, 'public', 'css', 'style.css'), '/* Run npm run build:css */\n');
671
+
672
+ // Update package.json
673
+ const updatedPackageJson = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf-8'));
674
+ updatedPackageJson.devDependencies = updatedPackageJson.devDependencies || {};
675
+ updatedPackageJson.scripts = updatedPackageJson.scripts || {};
676
+
677
+ if (isV4) {
678
+ const inputCss = `@import "tailwindcss";
679
+
680
+ @source "../pages/**/*.{njk,js}";
681
+ @source "../views/**/*.njk";
682
+ @source "../src/**/*.js";
683
+ `;
684
+ fs.writeFileSync(path.join(projectPath, 'src', 'input.css'), inputCss);
685
+
686
+ updatedPackageJson.devDependencies['@tailwindcss/cli'] = '^4.0.0';
687
+ updatedPackageJson.devDependencies['tailwindcss'] = '^4.0.0';
688
+
689
+ updatedPackageJson.scripts['build:css'] = '@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --minify';
690
+ updatedPackageJson.scripts['watch:css'] = '@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --watch';
691
+ } else {
692
+ const inputCss = `@tailwind base;
667
693
  @tailwind components;
668
694
  @tailwind utilities;
669
695
  `;
670
- fs.writeFileSync(path.join(projectPath, 'src', 'input.css'), inputCss);
671
-
672
- // Create tailwind.config.js
673
- const tailwindConfig = `/** @type {import('tailwindcss').Config} */
696
+ fs.writeFileSync(path.join(projectPath, 'src', 'input.css'), inputCss);
697
+
698
+ const tailwindConfig = `/** @type {import('tailwindcss').Config} */
674
699
  module.exports = {
675
700
  content: [
676
701
  './pages/**/*.{njk,js}',
@@ -683,33 +708,25 @@ module.exports = {
683
708
  plugins: [],
684
709
  }
685
710
  `;
686
- fs.writeFileSync(path.join(projectPath, 'tailwind.config.js'), tailwindConfig);
687
-
688
- // Create postcss.config.js
689
- const postcssConfig = `module.exports = {
711
+ fs.writeFileSync(path.join(projectPath, 'tailwind.config.js'), tailwindConfig);
712
+
713
+ const postcssConfig = `module.exports = {
690
714
  plugins: {
691
715
  tailwindcss: {},
692
716
  autoprefixer: {},
693
717
  },
694
718
  }
695
719
  `;
696
- fs.writeFileSync(path.join(projectPath, 'postcss.config.js'), postcssConfig);
697
-
698
- // Create public/css directory
699
- fs.mkdirSync(path.join(projectPath, 'public', 'css'), { recursive: true });
700
-
701
- // Create placeholder CSS (will be replaced by build)
702
- fs.writeFileSync(path.join(projectPath, 'public', 'css', 'style.css'), '/* Run npm run build:css */\n');
703
-
704
- // Update package.json
705
- const updatedPackageJson = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf-8'));
706
- updatedPackageJson.devDependencies = updatedPackageJson.devDependencies || {};
707
- updatedPackageJson.devDependencies['tailwindcss'] = '^3.4.1';
708
- updatedPackageJson.devDependencies['postcss'] = '^8.4.35';
709
- updatedPackageJson.devDependencies['autoprefixer'] = '^10.4.17';
720
+ fs.writeFileSync(path.join(projectPath, 'postcss.config.js'), postcssConfig);
721
+
722
+ updatedPackageJson.devDependencies['tailwindcss'] = '^3.4.1';
723
+ updatedPackageJson.devDependencies['postcss'] = '^8.4.35';
724
+ updatedPackageJson.devDependencies['autoprefixer'] = '^10.4.17';
725
+
726
+ updatedPackageJson.scripts['build:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --minify';
727
+ updatedPackageJson.scripts['watch:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --watch';
728
+ }
710
729
 
711
- updatedPackageJson.scripts['build:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --minify';
712
- updatedPackageJson.scripts['watch:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --watch';
713
730
  updatedPackageJson.scripts.dev = 'webspresso dev';
714
731
  updatedPackageJson.scripts.start = 'npm run build:css && NODE_ENV=production node server.js';
715
732
 
@@ -718,7 +735,7 @@ module.exports = {
718
735
  JSON.stringify(updatedPackageJson, null, 2) + '\n'
719
736
  );
720
737
 
721
- console.log('āœ… Tailwind CSS setup complete!');
738
+ console.log(`āœ… Tailwind CSS ${isV4 ? 'v4' : 'v3'} setup complete!`);
722
739
  }
723
740
 
724
741
  // Scaffold AI Agent guidelines unless --no-agents is specified
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - openapi / api:docs Command
5
+ * Generate and export OpenAPI 3 specification from application routes
6
+ * @module bin/commands/openapi
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { scanRoutes } = require('../../src/discovery/scan-routes');
12
+ const { compileRouteTable } = require('../../src/routing/route-table');
13
+ const { buildOpenApiDocument } = require('../../core/openapi/build-from-api-routes');
14
+
15
+ function registerCommand(program) {
16
+ program
17
+ .command('openapi')
18
+ .alias('api:docs')
19
+ .description('Generate and export OpenAPI 3 specification from application routes')
20
+ .option('-o, --out <file>', 'Output JSON file path (e.g. openapi.json or public/openapi.json)')
21
+ .option('-r, --root <dir>', 'Root directory of the project', process.cwd())
22
+ .option('--pages <dir>', 'Pages directory relative to root', 'pages')
23
+ .option('--title <title>', 'OpenAPI specification title override')
24
+ .option('--api-version <version>', 'OpenAPI specification version override')
25
+ .option('--description <desc>', 'OpenAPI specification description override')
26
+ .option('--server <url>', 'Default server URL in spec', 'http://localhost:3000')
27
+ .option('--include-orm', 'Include ORM model schemas in components', false)
28
+ .option('--indent <spaces>', 'JSON indentation spaces', '2')
29
+ .option('-p, --print', 'Print specification JSON to stdout', false)
30
+ .action((options) => {
31
+ const rootDir = path.resolve(options.root || process.cwd());
32
+ const pagesRelDir = options.pages || 'pages';
33
+ const pagesDir = path.resolve(rootDir, pagesRelDir);
34
+ const apiDir = path.resolve(pagesDir, 'api');
35
+
36
+ if (!fs.existsSync(pagesDir) && !fs.existsSync(apiDir)) {
37
+ console.warn(`[webspresso] Warning: Pages directory not found at: ${pagesDir}`);
38
+ }
39
+
40
+ // Discover routes
41
+ const descriptors = scanRoutes({
42
+ rootDir,
43
+ pages: fs.existsSync(pagesDir) ? { dir: pagesRelDir } : true,
44
+ api: fs.existsSync(apiDir) ? { dir: path.join(pagesRelDir, 'api') } : true,
45
+ });
46
+
47
+ const routeTable = compileRouteTable(descriptors, { isDev: true });
48
+ const routes = routeTable.list();
49
+
50
+ // Read package.json metadata if available
51
+ let pkg = {};
52
+ const pkgPath = path.join(rootDir, 'package.json');
53
+ if (fs.existsSync(pkgPath)) {
54
+ try {
55
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
56
+ } catch {
57
+ // ignore invalid json
58
+ }
59
+ }
60
+
61
+ const docTitle = options.title || pkg.name || 'Webspresso API';
62
+ const docVersion = options.apiVersion || pkg.version || '1.0.0';
63
+ const docDescription =
64
+ options.description ||
65
+ pkg.description ||
66
+ 'OpenAPI 3.0 specification generated from Webspresso file-based API routes.';
67
+
68
+ const doc = buildOpenApiDocument({
69
+ routes,
70
+ pagesDir,
71
+ includeOrmSchemas: Boolean(options.includeOrm),
72
+ info: {
73
+ title: docTitle,
74
+ version: docVersion,
75
+ description: docDescription,
76
+ },
77
+ servers: [{ url: String(options.server).replace(/\/$/, '') }],
78
+ });
79
+
80
+ const indent = parseInt(options.indent, 10) || 2;
81
+ const formattedJson = JSON.stringify(doc, null, indent);
82
+
83
+ if (options.out) {
84
+ const targetPath = path.resolve(rootDir, options.out);
85
+ const targetDir = path.dirname(targetPath);
86
+ if (!fs.existsSync(targetDir)) {
87
+ fs.mkdirSync(targetDir, { recursive: true });
88
+ }
89
+ fs.writeFileSync(targetPath, formattedJson, 'utf8');
90
+
91
+ const relPath = path.relative(rootDir, targetPath);
92
+ const pathCount = Object.keys(doc.paths || {}).length;
93
+ console.log(`\x1b[32māœ”\x1b[0m Generated OpenAPI 3 specification with ${pathCount} path(s) -> \x1b[1m${relPath}\x1b[0m`);
94
+ }
95
+
96
+ if (!options.out || options.print) {
97
+ console.log(formattedJson);
98
+ }
99
+ });
100
+ }
101
+
102
+ module.exports = { registerCommand };
@@ -19,14 +19,17 @@ const path = require('path');
19
19
  function registerCommand(program) {
20
20
  program
21
21
  .command('page [route]')
22
+ .alias('make:page')
23
+ .alias('page:make')
22
24
  .description('Add a new SSR page (Nunjucks template + companion load() loader)')
23
25
  .option('--no-loader', 'Skip creating companion .js loader file')
24
26
  .option('--locales', 'Create page-level locale files')
27
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
25
28
  .action(async (routeArg, options) => {
26
- const pagesDir = path.resolve(process.cwd(), 'pages');
29
+ const rootDir = path.resolve(options.root || process.cwd());
30
+ const pagesDir = path.join(rootDir, 'pages');
27
31
  if (!fs.existsSync(pagesDir)) {
28
- console.error('āŒ Not a Webspresso project! (pages/ directory not found)');
29
- process.exit(1);
32
+ fs.mkdirSync(pagesDir, { recursive: true });
30
33
  }
31
34
 
32
35
  let route = routeArg ? String(routeArg).trim() : '';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Prune old background queue jobs (CLI)
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ const { loadDbConfig, createDbInstance } = require('../utils/db');
8
+
9
+ function registerCommand(program) {
10
+ program
11
+ .command('queue:prune')
12
+ .description('Delete completed or failed queue jobs older than the given retention window')
13
+ .option('--days <n>', 'Delete jobs older than this many days', '7')
14
+ .option('--status <statuses>', 'Comma-separated statuses to delete (e.g. completed, failed)', 'completed')
15
+ .option('--table <name>', 'Queue table name', 'webspresso_jobs')
16
+ .option('-e, --env <environment>', 'Environment (development, production)', 'development')
17
+ .option('-c, --config <path>', 'Path to database config file')
18
+ .action(async (options) => {
19
+ const days = parseInt(options.days, 10);
20
+ if (Number.isNaN(days) || days < 0) {
21
+ console.error('āŒ --days must be a non-negative integer');
22
+ process.exit(1);
23
+ }
24
+
25
+ const statuses = (options.status || 'completed')
26
+ .split(',')
27
+ .map((s) => s.trim())
28
+ .filter(Boolean);
29
+
30
+ const { config, path: configPath } = loadDbConfig(options.config);
31
+ console.log(`\nšŸ“¦ Using config: ${configPath}`);
32
+ console.log(` Environment: ${options.env}\n`);
33
+
34
+ const knex = await createDbInstance(config, options.env);
35
+ const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
36
+
37
+ try {
38
+ const { DatabaseQueueAdapter } = require('../../core/queue/adapters/database');
39
+ const adapter = new DatabaseQueueAdapter({
40
+ knex,
41
+ tableName: options.table,
42
+ });
43
+
44
+ const deleted = await adapter.clean({
45
+ status: statuses,
46
+ olderThan,
47
+ });
48
+
49
+ console.log(`āœ… Deleted ${deleted} job(s) [status: ${statuses.join(', ')}] with timestamp before ${olderThan.toISOString()}.\n`);
50
+ } catch (err) {
51
+ console.error('āŒ queue:prune failed:', err.message);
52
+ process.exit(1);
53
+ } finally {
54
+ await knex.destroy();
55
+ }
56
+ });
57
+ }
58
+
59
+ module.exports = { registerCommand };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso CLI - routes Command
5
+ * Inspect and display compiled application routes
6
+ * @module bin/commands/routes
7
+ */
8
+
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const { scanRoutes } = require('../../src/discovery/scan-routes');
12
+ const { compileRouteTable } = require('../../src/routing/route-table');
13
+ const { formatRouteTable } = require('../../src/dev/banner');
14
+
15
+ function registerCommand(program) {
16
+ program
17
+ .command('routes')
18
+ .description('List and inspect all discovered application routes')
19
+ .option('-a, --api', 'Filter to only API endpoints')
20
+ .option('-p, --pages', 'Filter to only SSR page routes')
21
+ .option('-j, --json', 'Output routes as JSON')
22
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
23
+ .action((options) => {
24
+ const rootDir = path.resolve(options.root || process.cwd());
25
+
26
+ const descriptors = scanRoutes({
27
+ rootDir,
28
+ pages: fs.existsSync(path.join(rootDir, 'pages')) ? { dir: 'pages' } : true,
29
+ api: fs.existsSync(path.join(rootDir, 'pages', 'api')) ? { dir: 'pages/api' } : true,
30
+ });
31
+
32
+ const routeTable = compileRouteTable(descriptors, { isDev: true });
33
+ let routes = routeTable.list();
34
+
35
+ if (options.api) {
36
+ routes = routes.filter((r) => r.type === 'api' || (r.path && r.path.startsWith('/api')));
37
+ } else if (options.pages) {
38
+ routes = routes.filter((r) => r.type === 'page' && (!r.path || !r.path.startsWith('/api')));
39
+ }
40
+
41
+ if (options.json) {
42
+ console.log(JSON.stringify(routes, null, 2));
43
+ return;
44
+ }
45
+
46
+ console.log('');
47
+ console.log(formatRouteTable(routes, { limit: 200 }));
48
+ console.log('');
49
+ });
50
+ }
51
+
52
+ module.exports = { registerCommand };
@@ -199,6 +199,7 @@ function registerCommand(program) {
199
199
  .description('Scaffold a new service in services/<domain>/<action>.js')
200
200
  .option('-t, --transaction', 'Enable ACID transaction wrapper (transaction: true)')
201
201
  .option('-a, --auth <role>', 'Require authentication or specific role')
202
+ .option('--root <dir>', 'Root directory of the project', process.cwd())
202
203
  .action(async (nameArg, options) => {
203
204
  const fs = require('fs');
204
205
  let serviceName = nameArg ? String(nameArg).trim() : '';
@@ -223,7 +224,8 @@ function registerCommand(program) {
223
224
  const actionName = parts.pop();
224
225
  const domainPath = parts.join(path.sep);
225
226
 
226
- const servicesDir = path.resolve(process.cwd(), 'services');
227
+ const rootDir = path.resolve(options.root || process.cwd());
228
+ const servicesDir = path.resolve(rootDir, 'services');
227
229
  const targetDir = path.join(servicesDir, domainPath);
228
230
  fs.mkdirSync(targetDir, { recursive: true });
229
231
 
@@ -241,13 +243,14 @@ function registerCommand(program) {
241
243
 
242
244
  const content = `'use strict';
243
245
 
246
+ const { defineService } = require('webspresso');
244
247
  const { z } = require('zod');
245
248
 
246
249
  /**
247
250
  * Service: ${serviceName}
248
251
  * @type {import('webspresso').ServiceDefinition}
249
252
  */
250
- module.exports = {
253
+ module.exports = defineService({
251
254
  // Input validation schema (Zod)
252
255
  schema: z.object({
253
256
  // Define input properties here
@@ -262,7 +265,7 @@ module.exports = {
262
265
  data: input,
263
266
  };
264
267
  },
265
- };
268
+ });
266
269
  `;
267
270
 
268
271
  fs.writeFileSync(serviceFile, content, 'utf8');