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
@@ -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 };
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  function getSharp() {
9
- return process.env.NODE_ENV === 'test'
9
+ return (process.env.NODE_ENV === 'test' || process.env.VITEST)
10
10
  ? require('../../tests/mocks/sharp')
11
11
  : require('sharp');
12
12
  }
@@ -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
+ };
@@ -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
+ };