webspresso 0.0.96 → 0.0.98

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 (121) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
  7. package/.agents/skills/webspresso-framework/SKILL.md +56 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
  10. package/.agents/skills/webspresso-services/SKILL.md +101 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/admin-password.js +2 -1
  14. package/bin/commands/admin-setup.js +1 -1
  15. package/bin/commands/agents-init.js +51 -11
  16. package/bin/commands/analytics-prune.js +57 -0
  17. package/bin/commands/api.js +207 -15
  18. package/bin/commands/audit-prune.js +1 -1
  19. package/bin/commands/build.js +99 -0
  20. package/bin/commands/dev.js +64 -35
  21. package/bin/commands/doctor.js +26 -0
  22. package/bin/commands/email-prune.js +47 -0
  23. package/bin/commands/favicon-generate.js +1 -1
  24. package/bin/commands/make-job.js +86 -0
  25. package/bin/commands/make-model.js +250 -0
  26. package/bin/commands/new.js +45 -28
  27. package/bin/commands/openapi.js +102 -0
  28. package/bin/commands/orm-map.js +5 -4
  29. package/bin/commands/page.js +6 -3
  30. package/bin/commands/polar-migrate.js +1 -1
  31. package/bin/commands/queue-prune.js +59 -0
  32. package/bin/commands/routes.js +52 -0
  33. package/bin/commands/seed.js +1 -1
  34. package/bin/commands/service.js +6 -3
  35. package/bin/commands/typegen.js +106 -0
  36. package/bin/commands/upload-prune.js +79 -0
  37. package/bin/webspresso.js +22 -0
  38. package/core/applySchema.js +13 -6
  39. package/core/auth/hash.js +31 -17
  40. package/core/compression/middleware.js +23 -17
  41. package/core/openapi/build-from-api-routes.js +160 -29
  42. package/core/orm/cache/fingerprint.js +15 -1
  43. package/core/orm/cache/memory-provider.js +27 -2
  44. package/core/orm/index.js +12 -7
  45. package/core/orm/model.js +49 -7
  46. package/core/queue/adapters/database.js +94 -0
  47. package/core/queue/adapters/memory.js +62 -0
  48. package/core/queue/adapters/redis.js +80 -0
  49. package/core/queue/manager.js +123 -0
  50. package/core/ssr/stream.js +11 -0
  51. package/index.d.ts +660 -10
  52. package/index.js +44 -96
  53. package/package.json +32 -12
  54. package/plugins/admin-panel/api.js +279 -17
  55. package/plugins/admin-panel/app.js +124 -31
  56. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  57. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  58. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  59. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  60. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  61. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  62. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  63. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  64. package/plugins/admin-panel/core/admin-module.js +13 -7
  65. package/plugins/admin-panel/core/api-extensions.js +34 -2
  66. package/plugins/admin-panel/core/registry.js +96 -16
  67. package/plugins/admin-panel/index.js +96 -0
  68. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  69. package/plugins/admin-panel/modules/admin-users.js +16 -1
  70. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  71. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  72. package/plugins/admin-panel/modules/dashboard.js +283 -0
  73. package/plugins/admin-panel/modules/menu.js +56 -18
  74. package/plugins/admin-panel/modules/user-management.js +24 -6
  75. package/plugins/audit-log/admin-component.js +11 -0
  76. package/plugins/audit-log/api-handlers.js +34 -0
  77. package/plugins/audit-log/index.js +129 -3
  78. package/plugins/audit-log/purge.js +60 -10
  79. package/plugins/basic-auth/index.js +3 -1
  80. package/plugins/content/client/inline-edit.js +12 -12
  81. package/plugins/cors.js +6 -7
  82. package/plugins/dashboard/app.js +152 -27
  83. package/plugins/dashboard/index.js +143 -39
  84. package/plugins/dashboard/styles.js +78 -0
  85. package/plugins/data-exchange/export-xlsx.js +9 -2
  86. package/plugins/data-exchange/import.js +9 -2
  87. package/plugins/data-exchange/index.js +6 -3
  88. package/plugins/email/index.js +132 -2
  89. package/plugins/email/purge.js +60 -10
  90. package/plugins/file-manager/core.js +1 -1
  91. package/plugins/file-manager/security.js +33 -23
  92. package/plugins/index.js +49 -87
  93. package/plugins/mcp/transports/sse.js +4 -2
  94. package/plugins/orm-cache-admin/index.js +4 -2
  95. package/plugins/queue/index.js +11 -0
  96. package/plugins/schema-explorer/admin-component.js +714 -0
  97. package/plugins/schema-explorer/relations-graph.js +205 -0
  98. package/plugins/schema-explorer.js +248 -69
  99. package/plugins/site-analytics/admin-component.js +30 -8
  100. package/plugins/site-analytics/api-handlers.js +42 -0
  101. package/plugins/site-analytics/index.js +147 -2
  102. package/plugins/site-analytics/purge.js +168 -0
  103. package/plugins/swagger.js +261 -13
  104. package/plugins/upload/index.js +26 -0
  105. package/plugins/upload/orphan-pruner.js +292 -0
  106. package/src/api/api-loader.js +3 -1
  107. package/src/bundle/index.js +517 -0
  108. package/src/bundle/manifest.js +165 -0
  109. package/src/dev/banner.js +296 -0
  110. package/src/dev/colors.js +70 -0
  111. package/src/dev/index.js +62 -0
  112. package/src/dev/profiler.js +354 -0
  113. package/src/dev/typegen.js +497 -0
  114. package/src/dev-reload/index.js +611 -0
  115. package/src/file-router.js +213 -63
  116. package/src/helpers.js +958 -98
  117. package/src/njk-frontmatter.js +12 -0
  118. package/src/pages/page-loader.js +61 -11
  119. package/src/server.js +629 -63
  120. package/src/services/discovery.js +6 -1
  121. package/src/services/index.js +6 -13
@@ -10,14 +10,26 @@ const { execSync } = require('child_process');
10
10
  function registerCommand(program) {
11
11
  program
12
12
  .command('add tailwind')
13
+ .alias('add:tailwind')
13
14
  .description('Add Tailwind CSS to the project with build process')
14
- .action(async () => {
15
+ .option('--v4', 'Use Tailwind CSS v4 (Oxide engine, LightningCSS, zero postcss config)')
16
+ .option('--v3', 'Use Tailwind CSS v3 (legacy PostCSS engine)')
17
+ .option('-v, --tailwind-version <version>', 'Tailwind version (3 or 4)', '3')
18
+ .option('--upgrade', 'Upgrade existing Tailwind v3 project to Tailwind v4')
19
+ .action(async (...actionArgs) => {
20
+ const cmd = actionArgs[actionArgs.length - 1];
21
+ const options = (cmd && typeof cmd.opts === 'function')
22
+ ? cmd.opts()
23
+ : (actionArgs.find(a => a && typeof a === 'object') || {});
24
+
15
25
  if (!fs.existsSync('package.json')) {
16
26
  console.error('❌ Not a Webspresso project! Run this command in your project directory.');
17
27
  process.exit(1);
18
28
  }
19
29
 
20
- console.log('\n🎨 Adding Tailwind CSS to your project...\n');
30
+ const isV4 = Boolean(options.v4 || options.upgrade || options.tailwindVersion === '4');
31
+
32
+ console.log(`\n🎨 Adding Tailwind CSS ${isV4 ? 'v4 (Oxide engine, zero-config)' : 'v3'} to your project...\n`);
21
33
 
22
34
  // Read package.json
23
35
  const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
@@ -27,17 +39,26 @@ function registerCommand(program) {
27
39
  packageJson.devDependencies = {};
28
40
  }
29
41
 
30
- packageJson.devDependencies['tailwindcss'] = '^3.4.1';
31
- packageJson.devDependencies['postcss'] = '^8.4.35';
32
- packageJson.devDependencies['autoprefixer'] = '^10.4.17';
33
-
34
- // Add build scripts
35
42
  if (!packageJson.scripts) {
36
43
  packageJson.scripts = {};
37
44
  }
38
-
39
- packageJson.scripts['build:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --minify';
40
- packageJson.scripts['watch:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --watch';
45
+
46
+ if (isV4) {
47
+ packageJson.devDependencies['@tailwindcss/cli'] = '^4.0.0';
48
+ packageJson.devDependencies['tailwindcss'] = '^4.0.0';
49
+ delete packageJson.devDependencies['postcss'];
50
+ delete packageJson.devDependencies['autoprefixer'];
51
+
52
+ packageJson.scripts['build:css'] = '@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --minify';
53
+ packageJson.scripts['watch:css'] = '@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --watch';
54
+ } else {
55
+ packageJson.devDependencies['tailwindcss'] = '^3.4.1';
56
+ packageJson.devDependencies['postcss'] = '^8.4.35';
57
+ packageJson.devDependencies['autoprefixer'] = '^10.4.17';
58
+
59
+ packageJson.scripts['build:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --minify';
60
+ packageJson.scripts['watch:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --watch';
61
+ }
41
62
 
42
63
  // Update dev script to include CSS watch
43
64
  if (packageJson.scripts.dev) {
@@ -50,24 +71,48 @@ function registerCommand(program) {
50
71
  }
51
72
 
52
73
  fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n');
53
- console.log('✅ Updated package.json');
74
+ console.log(`✅ Updated package.json (${isV4 ? 'Tailwind v4' : 'Tailwind v3'})`);
54
75
 
55
76
  // Create src directory if it doesn't exist
56
77
  if (!fs.existsSync('src')) {
57
78
  fs.mkdirSync('src', { recursive: true });
58
79
  }
59
80
 
60
- // Create input.css
61
- const inputCss = `@tailwind base;
81
+ if (isV4) {
82
+ // Create v4 input.css
83
+ const inputCss = `@import "tailwindcss";
84
+
85
+ @source "../pages/**/*.{njk,js}";
86
+ @source "../views/**/*.njk";
87
+ @source "../src/**/*.js";
88
+ `;
89
+ fs.writeFileSync('src/input.css', inputCss);
90
+ console.log('✅ Created src/input.css (@import "tailwindcss"; + @source directives)');
91
+
92
+ // If upgrading from v3, clean up obsolete configs
93
+ if (fs.existsSync('postcss.config.js')) {
94
+ try {
95
+ fs.unlinkSync('postcss.config.js');
96
+ console.log('🧹 Removed obsolete postcss.config.js (not needed in v4)');
97
+ } catch (_) {}
98
+ }
99
+ if (fs.existsSync('tailwind.config.js')) {
100
+ try {
101
+ fs.renameSync('tailwind.config.js', 'tailwind.config.js.backup');
102
+ console.log('ℹ️ Renamed tailwind.config.js to tailwind.config.js.backup (v4 is CSS-first)');
103
+ } catch (_) {}
104
+ }
105
+ } else {
106
+ // Create v3 input.css
107
+ const inputCss = `@tailwind base;
62
108
  @tailwind components;
63
109
  @tailwind utilities;
64
110
  `;
65
-
66
- fs.writeFileSync('src/input.css', inputCss);
67
- console.log('✅ Created src/input.css');
68
-
69
- // Create tailwind.config.js
70
- const tailwindConfig = `/** @type {import('tailwindcss').Config} */
111
+ fs.writeFileSync('src/input.css', inputCss);
112
+ console.log('✅ Created src/input.css');
113
+
114
+ // Create tailwind.config.js
115
+ const tailwindConfig = `/** @type {import('tailwindcss').Config} */
71
116
  module.exports = {
72
117
  content: [
73
118
  './pages/**/*.{njk,js}',
@@ -80,21 +125,20 @@ module.exports = {
80
125
  plugins: [],
81
126
  }
82
127
  `;
83
-
84
- fs.writeFileSync('tailwind.config.js', tailwindConfig);
85
- console.log('✅ Created tailwind.config.js');
86
-
87
- // Create postcss.config.js
88
- const postcssConfig = `module.exports = {
128
+ fs.writeFileSync('tailwind.config.js', tailwindConfig);
129
+ console.log('✅ Created tailwind.config.js');
130
+
131
+ // Create postcss.config.js
132
+ const postcssConfig = `module.exports = {
89
133
  plugins: {
90
134
  tailwindcss: {},
91
135
  autoprefixer: {},
92
136
  },
93
137
  }
94
138
  `;
95
-
96
- fs.writeFileSync('postcss.config.js', postcssConfig);
97
- console.log('✅ Created postcss.config.js');
139
+ fs.writeFileSync('postcss.config.js', postcssConfig);
140
+ console.log('✅ Created postcss.config.js');
141
+ }
98
142
 
99
143
  // Check if layout.njk exists and update it (before creating CSS)
100
144
  const layoutPath = 'views/layout.njk';
@@ -128,9 +172,10 @@ module.exports = {
128
172
  fs.writeFileSync('public/css/style.css', '/* Run npm run build:css */\n');
129
173
  console.log('✅ Created public/css/style.css');
130
174
 
131
- // Try to build CSS if tailwindcss is already installed
175
+ // Try to build CSS if tailwindcss / @tailwindcss/cli is already installed
132
176
  const tailwindBin = path.join(process.cwd(), 'node_modules', '.bin', 'tailwindcss');
133
- if (fs.existsSync(tailwindBin)) {
177
+ const tailwindCliBin = path.join(process.cwd(), 'node_modules', '.bin', '@tailwindcss/cli');
178
+ if (fs.existsSync(tailwindBin) || fs.existsSync(tailwindCliBin)) {
134
179
  try {
135
180
  console.log('\n🎨 Building Tailwind CSS from your templates...');
136
181
  execSync('npm run build:css', { stdio: 'inherit', cwd: process.cwd() });
@@ -139,7 +184,7 @@ module.exports = {
139
184
  console.log('\n⚠️ CSS build failed. Run "npm run build:css" manually.\n');
140
185
  }
141
186
  } else {
142
- console.log('\n✅ Tailwind CSS added successfully!\n');
187
+ console.log(`\n✅ Tailwind CSS ${isV4 ? 'v4' : 'v3'} added successfully!\n`);
143
188
  console.log('Next steps:');
144
189
  console.log(' npm install');
145
190
  console.log(' npm run build:css');
@@ -92,7 +92,8 @@ function registerCommand(program) {
92
92
 
93
93
  // Hash the password (same rounds as admin panel setup)
94
94
  const { hash } = require('../../core/auth/hash');
95
- const hashedPassword = await hash(password, 10);
95
+ const rounds = process.env.NODE_ENV === 'test' ? 4 : 10;
96
+ const hashedPassword = await hash(password, rounds);
96
97
 
97
98
  // Update the password
98
99
  await db('admin_users')
@@ -6,7 +6,6 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const { loadDbConfig } = require('../utils/db');
9
- const { generateAdminUsersMigration } = require('../../plugins/admin-panel/migration-template');
10
9
 
11
10
  function registerCommand(program) {
12
11
  program
@@ -53,6 +52,7 @@ function registerCommand(program) {
53
52
  const filepath = path.join(migrationDir, filename);
54
53
 
55
54
  // Generate migration content
55
+ const { generateAdminUsersMigration } = require('../../plugins/admin-panel/migration-template');
56
56
  const content = generateAdminUsersMigration();
57
57
 
58
58
  // Write migration file
@@ -39,6 +39,40 @@ function copyFileSafe(src, dest, force = false) {
39
39
  return destExists ? 'overwritten' : 'created';
40
40
  }
41
41
 
42
+ /**
43
+ * Recursively copies a directory safely.
44
+ * @param {string} srcDir
45
+ * @param {string} destDir
46
+ * @param {boolean} force
47
+ * @param {string[]} installed
48
+ * @param {string[]} skipped
49
+ * @param {string} baseRel
50
+ */
51
+ function copyDirSafe(srcDir, destDir, force = false, installed = [], skipped = [], baseRel = '') {
52
+ if (!fs.existsSync(srcDir)) return;
53
+ if (!fs.existsSync(destDir)) {
54
+ fs.mkdirSync(destDir, { recursive: true });
55
+ }
56
+
57
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
58
+ for (const entry of entries) {
59
+ const srcPath = path.join(srcDir, entry.name);
60
+ const destPath = path.join(destDir, entry.name);
61
+ const relPath = path.join(baseRel, entry.name);
62
+
63
+ if (entry.isDirectory()) {
64
+ copyDirSafe(srcPath, destPath, force, installed, skipped, relPath);
65
+ } else {
66
+ const status = copyFileSafe(srcPath, destPath, force);
67
+ if (status === 'created' || status === 'overwritten') {
68
+ installed.push(relPath);
69
+ } else {
70
+ skipped.push(relPath);
71
+ }
72
+ }
73
+ }
74
+ }
75
+
42
76
  /**
43
77
  * Scaffolds AI Agent rules and guidebook files into target project directory.
44
78
  * @param {string} targetDir
@@ -98,23 +132,29 @@ function scaffoldAgentsFiles(targetDir, options = {}) {
98
132
  }
99
133
  }
100
134
 
101
- // 4. .agents/*.md Guidebook directory
135
+ // 4. .agents/*.md Guidebook and skills/ directory
102
136
  if (includeGuides && fs.existsSync(SOURCE_AGENTS_DIR)) {
103
137
  const destAgentsDir = path.join(targetDir, '.agents');
104
138
  if (!fs.existsSync(destAgentsDir)) {
105
139
  fs.mkdirSync(destAgentsDir, { recursive: true });
106
140
  }
107
141
 
108
- const files = fs.readdirSync(SOURCE_AGENTS_DIR).filter((f) => f.endsWith('.md'));
109
- for (const file of files) {
110
- const srcFile = path.join(SOURCE_AGENTS_DIR, file);
111
- const destFile = path.join(destAgentsDir, file);
112
- const relPath = path.join('.agents', file);
113
- const status = copyFileSafe(srcFile, destFile, force);
114
- if (status === 'created' || status === 'overwritten') {
115
- result.installed.push(relPath);
116
- } else {
117
- result.skipped.push(relPath);
142
+ const entries = fs.readdirSync(SOURCE_AGENTS_DIR, { withFileTypes: true });
143
+ for (const entry of entries) {
144
+ if (entry.name === 'internal') continue; // exclude internal reviews from user projects
145
+ const srcPath = path.join(SOURCE_AGENTS_DIR, entry.name);
146
+ const destPath = path.join(destAgentsDir, entry.name);
147
+ const relPath = path.join('.agents', entry.name);
148
+
149
+ if (entry.isDirectory()) {
150
+ copyDirSafe(srcPath, destPath, force, result.installed, result.skipped, relPath);
151
+ } else if (entry.name.endsWith('.md')) {
152
+ const status = copyFileSafe(srcPath, destPath, force);
153
+ if (status === 'created' || status === 'overwritten') {
154
+ result.installed.push(relPath);
155
+ } else {
156
+ result.skipped.push(relPath);
157
+ }
118
158
  }
119
159
  }
120
160
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Prune old site analytics records (CLI)
3
+ */
4
+
5
+ const { loadDbConfig, createDbInstance } = require('../utils/db');
6
+
7
+ function registerCommand(program) {
8
+ program
9
+ .command('analytics:prune')
10
+ .description('Delete analytics page views and client errors older than the given retention window')
11
+ .option('--days <n>', 'Delete page views older than this many days', '90')
12
+ .option('--error-days <n>', 'Delete client errors older than this many days', '30')
13
+ .option('--table <name>', 'Page views table name', 'analytics_page_views')
14
+ .option('--errors-table <name>', 'Client errors table name', 'analytics_client_errors')
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 errorDays = parseInt(options.errorDays, 10);
25
+ if (Number.isNaN(errorDays) || errorDays < 1) {
26
+ console.error('❌ --error-days must be a positive integer');
27
+ process.exit(1);
28
+ }
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
+
36
+ try {
37
+ const { purgeAnalyticsData } = require('../../plugins/site-analytics/purge');
38
+ const result = await purgeAnalyticsData(knex, {
39
+ days,
40
+ errorDays,
41
+ tableName: options.table,
42
+ errorsTableName: options.errorsTable,
43
+ });
44
+
45
+ console.log(`✅ Analytics purge completed:`);
46
+ console.log(` - Page views deleted: ${result.viewsDeleted} (cutoff: ${result.viewsCutoff})`);
47
+ console.log(` - Client errors deleted: ${result.errorsDeleted} (cutoff: ${result.errorsCutoff})\n`);
48
+ } catch (err) {
49
+ console.error('❌ analytics:prune failed:', err.message);
50
+ process.exit(1);
51
+ } finally {
52
+ await knex.destroy();
53
+ }
54
+ });
55
+ }
56
+
57
+ module.exports = { registerCommand };
@@ -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
+ };
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  const { loadDbConfig, createDbInstance } = require('../utils/db');
6
- const { purgeAuditLogs } = require('../../plugins/audit-log/purge');
7
6
 
8
7
  function registerCommand(program) {
9
8
  program
@@ -28,6 +27,7 @@ function registerCommand(program) {
28
27
  const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
29
28
 
30
29
  try {
30
+ const { purgeAuditLogs } = require('../../plugins/audit-log/purge');
31
31
  const deleted = await purgeAuditLogs(knex, {
32
32
  tableName: options.table,
33
33
  olderThan,