webspresso 0.0.97 → 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.
- package/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/index.js +4 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +210 -62
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build Command
|
|
3
|
+
* Build production assets (Tailwind CSS and esbuild JS bundle with manifest.json)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
const { buildBundle } = require('../../src/bundle');
|
|
10
|
+
|
|
11
|
+
function registerCommand(program) {
|
|
12
|
+
program
|
|
13
|
+
.command('build')
|
|
14
|
+
.alias('build:assets')
|
|
15
|
+
.description('Build production assets (Tailwind CSS and esbuild JS with manifest.json)')
|
|
16
|
+
.option('--no-css', 'Skip CSS build')
|
|
17
|
+
.option('--no-js', 'Skip JS bundle build')
|
|
18
|
+
.option('--no-hash', 'Disable content hashing in asset filenames')
|
|
19
|
+
.action(async (options = {}) => {
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
22
|
+
if (!fs.existsSync(pkgPath)) {
|
|
23
|
+
console.error('❌ Not a Webspresso project! Run this command in your project directory.');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let packageJson = {};
|
|
28
|
+
try {
|
|
29
|
+
packageJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
|
|
32
|
+
console.log('\n📦 Building Webspresso production assets...\n');
|
|
33
|
+
|
|
34
|
+
let cssBuilt = false;
|
|
35
|
+
let jsBuilt = false;
|
|
36
|
+
|
|
37
|
+
// 1. Build CSS (Tailwind)
|
|
38
|
+
if (options.css !== false) {
|
|
39
|
+
const hasCssScript = Boolean(packageJson.scripts && packageJson.scripts['build:css']);
|
|
40
|
+
const hasInputCss = fs.existsSync(path.join(cwd, 'src', 'input.css'));
|
|
41
|
+
|
|
42
|
+
if (hasCssScript) {
|
|
43
|
+
try {
|
|
44
|
+
console.log('🎨 Compiling Tailwind CSS...');
|
|
45
|
+
execSync('npm run build:css', { stdio: 'inherit', cwd });
|
|
46
|
+
cssBuilt = true;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('❌ CSS build failed:', err.message);
|
|
49
|
+
}
|
|
50
|
+
} else if (hasInputCss) {
|
|
51
|
+
console.log('ℹ️ src/input.css found but no "build:css" script. Run "npx webspresso add tailwind" to configure.');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Build JS Bundle with manifest.json (esbuild)
|
|
56
|
+
if (options.js !== false) {
|
|
57
|
+
const hasClientEntry = fs.existsSync(path.join(cwd, 'src', 'client', 'main.js')) ||
|
|
58
|
+
fs.existsSync(path.join(cwd, 'src', 'client', 'main.ts')) ||
|
|
59
|
+
fs.existsSync(path.join(cwd, 'src', 'client', 'index.js'));
|
|
60
|
+
const hasEsbuild = Boolean(
|
|
61
|
+
(packageJson.devDependencies && packageJson.devDependencies.esbuild) ||
|
|
62
|
+
(packageJson.dependencies && packageJson.dependencies.esbuild)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (hasClientEntry || hasEsbuild) {
|
|
66
|
+
try {
|
|
67
|
+
console.log('⚡ Bundling client JavaScript with esbuild...');
|
|
68
|
+
const result = await buildBundle({
|
|
69
|
+
cwd,
|
|
70
|
+
hash: options.hash !== false,
|
|
71
|
+
minify: true,
|
|
72
|
+
sourcemap: true,
|
|
73
|
+
outdir: path.join(cwd, 'public', 'dist'),
|
|
74
|
+
});
|
|
75
|
+
jsBuilt = true;
|
|
76
|
+
console.log(`✅ Client assets bundled and manifest created at: ${path.relative(cwd, result.manifestPath)}`);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// Fallback to npm run build:js if custom script exists
|
|
79
|
+
if (packageJson.scripts && packageJson.scripts['build:js']) {
|
|
80
|
+
try {
|
|
81
|
+
execSync('npm run build:js', { stdio: 'inherit', cwd });
|
|
82
|
+
jsBuilt = true;
|
|
83
|
+
} catch (subErr) {
|
|
84
|
+
console.error('❌ JS build failed:', subErr.message);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
console.error('❌ JS bundle build failed:', err.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log('\n✨ Asset build complete!\n');
|
|
94
|
+
if (cssBuilt) console.log(' ✓ CSS: public/css/style.css');
|
|
95
|
+
if (jsBuilt) console.log(' ✓ JS: public/dist/ & manifest.json');
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { registerCommand };
|
package/bin/commands/dev.js
CHANGED
|
@@ -32,6 +32,7 @@ function registerCommand(program) {
|
|
|
32
32
|
.description('Start development server')
|
|
33
33
|
.option('-p, --port <port>', 'Port number', '3000')
|
|
34
34
|
.option('--no-css', 'Skip CSS watch (if Tailwind is set up)')
|
|
35
|
+
.option('--no-js', 'Skip client JS watch (if esbuild / watch:js is set up)')
|
|
35
36
|
.action((options) => {
|
|
36
37
|
if (!fs.existsSync('server.js')) {
|
|
37
38
|
console.error('❌ server.js not found! Make sure you are in a Webspresso project.');
|
|
@@ -41,56 +42,84 @@ function registerCommand(program) {
|
|
|
41
42
|
process.env.PORT = options.port;
|
|
42
43
|
process.env.NODE_ENV = 'development';
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
let packageJson = {};
|
|
46
|
+
if (fs.existsSync('package.json')) {
|
|
47
|
+
try {
|
|
48
|
+
packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
|
49
|
+
} catch (_) {}
|
|
50
|
+
}
|
|
51
|
+
const hasTailwindV3 = fs.existsSync('tailwind.config.js') && fs.existsSync('src/input.css');
|
|
52
|
+
const hasTailwindV4 = fs.existsSync('src/input.css') && Boolean(
|
|
53
|
+
(packageJson.devDependencies && (packageJson.devDependencies['@tailwindcss/cli'] || packageJson.devDependencies['tailwindcss'])) ||
|
|
54
|
+
(packageJson.scripts && packageJson.scripts['watch:css'])
|
|
55
|
+
);
|
|
56
|
+
const hasTailwind = hasTailwindV3 || hasTailwindV4;
|
|
45
57
|
const shouldWatchCss = hasTailwind && options.css !== false;
|
|
58
|
+
|
|
59
|
+
const hasJsWatch = Boolean(
|
|
60
|
+
(packageJson.scripts && packageJson.scripts['watch:js']) ||
|
|
61
|
+
fs.existsSync('src/client/main.js') ||
|
|
62
|
+
fs.existsSync('src/client/main.ts') ||
|
|
63
|
+
fs.existsSync('esbuild.config.js')
|
|
64
|
+
);
|
|
65
|
+
const shouldWatchJs = hasJsWatch && options.js !== false && Boolean(packageJson.scripts && packageJson.scripts['watch:js']);
|
|
46
66
|
|
|
47
67
|
// Build watch arguments
|
|
48
68
|
const watchArgs = buildWatchArgs();
|
|
49
69
|
const watchDirs = watchArgs.filter(a => a.startsWith('--watch-path')).map(a => a.split('=')[1]);
|
|
50
70
|
|
|
71
|
+
const watchingLabels = ['server.js'];
|
|
72
|
+
if (watchDirs.length) watchingLabels.push(...watchDirs);
|
|
73
|
+
if (shouldWatchCss) watchingLabels.push('CSS');
|
|
74
|
+
if (shouldWatchJs) watchingLabels.push('JS (esbuild)');
|
|
75
|
+
|
|
76
|
+
console.log(`\n🚀 Starting development server on port ${options.port}...`);
|
|
77
|
+
console.log(` Watching: ${watchingLabels.join(', ')}\n`);
|
|
78
|
+
|
|
79
|
+
const managedProcesses = [];
|
|
80
|
+
let isCleaningUp = false;
|
|
81
|
+
|
|
82
|
+
const cleanup = (code = 0) => {
|
|
83
|
+
if (isCleaningUp) return;
|
|
84
|
+
isCleaningUp = true;
|
|
85
|
+
for (const p of managedProcesses) {
|
|
86
|
+
try {
|
|
87
|
+
if (p && !p.killed) {
|
|
88
|
+
p.kill('SIGTERM');
|
|
89
|
+
}
|
|
90
|
+
} catch (_) {}
|
|
91
|
+
}
|
|
92
|
+
process.exit(code);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
process.once('SIGINT', () => cleanup(0));
|
|
96
|
+
process.once('SIGTERM', () => cleanup(0));
|
|
97
|
+
|
|
51
98
|
if (shouldWatchCss) {
|
|
52
|
-
console.log(`\n🚀 Starting development server on port ${options.port}...`);
|
|
53
|
-
console.log(` Watching: server.js${watchDirs.length ? ', ' + watchDirs.join(', ') : ''}, CSS\n`);
|
|
54
|
-
|
|
55
|
-
// Start CSS watch
|
|
56
99
|
const cssWatch = spawn('npm', ['run', 'watch:css'], {
|
|
57
|
-
stdio: 'inherit',
|
|
58
|
-
shell: true
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// Start server with watch paths
|
|
62
|
-
const server = spawn('node', watchArgs, {
|
|
63
100
|
stdio: 'inherit',
|
|
64
101
|
shell: true,
|
|
65
|
-
env: { ...process.env, PORT: options.port, NODE_ENV: 'development' }
|
|
66
102
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
process.on('SIGINT', cleanup);
|
|
76
|
-
process.on('SIGTERM', cleanup);
|
|
77
|
-
|
|
78
|
-
cssWatch.on('exit', cleanup);
|
|
79
|
-
server.on('exit', cleanup);
|
|
80
|
-
} else {
|
|
81
|
-
console.log(`\n🚀 Starting development server on port ${options.port}...`);
|
|
82
|
-
console.log(` Watching: server.js${watchDirs.length ? ', ' + watchDirs.join(', ') : ''}\n`);
|
|
83
|
-
|
|
84
|
-
const child = spawn('node', watchArgs, {
|
|
103
|
+
managedProcesses.push(cssWatch);
|
|
104
|
+
cssWatch.on('exit', () => cleanup(0));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (shouldWatchJs) {
|
|
108
|
+
const jsWatch = spawn('npm', ['run', 'watch:js'], {
|
|
85
109
|
stdio: 'inherit',
|
|
86
110
|
shell: true,
|
|
87
|
-
env: { ...process.env, PORT: options.port, NODE_ENV: 'development' }
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
child.on('exit', (code) => {
|
|
91
|
-
process.exit(code || 0);
|
|
92
111
|
});
|
|
112
|
+
managedProcesses.push(jsWatch);
|
|
113
|
+
jsWatch.on('exit', () => cleanup(0));
|
|
93
114
|
}
|
|
115
|
+
|
|
116
|
+
const server = spawn('node', watchArgs, {
|
|
117
|
+
stdio: 'inherit',
|
|
118
|
+
shell: true,
|
|
119
|
+
env: { ...process.env, PORT: options.port, NODE_ENV: 'development' },
|
|
120
|
+
});
|
|
121
|
+
managedProcesses.push(server);
|
|
122
|
+
server.on('exit', (code) => cleanup(code || 0));
|
|
94
123
|
});
|
|
95
124
|
}
|
|
96
125
|
|
package/bin/commands/doctor.js
CHANGED
|
@@ -215,6 +215,32 @@ function registerCommand(program) {
|
|
|
215
215
|
warnings += 1;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
console.log('\nTailwind CSS');
|
|
219
|
+
console.log('------------');
|
|
220
|
+
const inputCssPath = path.join(cwd, 'src', 'input.css');
|
|
221
|
+
if (fs.existsSync(inputCssPath)) {
|
|
222
|
+
let isV4 = false;
|
|
223
|
+
try {
|
|
224
|
+
const inputCssContent = fs.readFileSync(inputCssPath, 'utf8');
|
|
225
|
+
isV4 = inputCssContent.includes('@import "tailwindcss"') || inputCssContent.includes("@import 'tailwindcss'");
|
|
226
|
+
} catch (_) {}
|
|
227
|
+
|
|
228
|
+
if (isV4) {
|
|
229
|
+
line('✓', 'Tailwind CSS v4 detected (@import "tailwindcss" — Oxide engine, zero-config)');
|
|
230
|
+
} else {
|
|
231
|
+
line('✓', 'Tailwind CSS v3 detected (PostCSS / tailwind.config.js)');
|
|
232
|
+
}
|
|
233
|
+
const outputCss = path.join(cwd, 'public', 'css', 'style.css');
|
|
234
|
+
if (fs.existsSync(outputCss)) {
|
|
235
|
+
line('✓', 'public/css/style.css compiled output present');
|
|
236
|
+
} else {
|
|
237
|
+
line('⚠', 'public/css/style.css not found. Run "npm run build:css" to compile stylesheets.');
|
|
238
|
+
warnings += 1;
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
line('○', 'No src/input.css found (Tailwind CSS not configured)');
|
|
242
|
+
}
|
|
243
|
+
|
|
218
244
|
console.log('\nAI Agent Guidelines');
|
|
219
245
|
console.log('-------------------');
|
|
220
246
|
const agentsMdPath = path.join(cwd, 'AGENTS.md');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune old email log records (CLI)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { loadDbConfig, createDbInstance } = require('../utils/db');
|
|
8
|
+
|
|
9
|
+
function registerCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('email:prune')
|
|
12
|
+
.description('Delete email log rows older than the given retention window')
|
|
13
|
+
.option('--days <n>', 'Delete rows older than this many days', '90')
|
|
14
|
+
.option('--table <name>', 'Table name', 'email_logs')
|
|
15
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
16
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
const days = parseInt(options.days, 10);
|
|
19
|
+
if (Number.isNaN(days) || days < 1) {
|
|
20
|
+
console.error('❌ --days must be a positive integer');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
25
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
26
|
+
console.log(` Environment: ${options.env}\n`);
|
|
27
|
+
|
|
28
|
+
const knex = await createDbInstance(config, options.env);
|
|
29
|
+
const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const { purgeEmailLogs } = require('../../plugins/email/purge');
|
|
33
|
+
const deleted = await purgeEmailLogs(knex, {
|
|
34
|
+
tableName: options.table,
|
|
35
|
+
olderThan,
|
|
36
|
+
});
|
|
37
|
+
console.log(`✅ Deleted ${deleted} email log row(s) with created_at before ${olderThan.toISOString()}.\n`);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('❌ email:prune failed:', err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
} finally {
|
|
42
|
+
await knex.destroy();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { registerCommand };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso CLI - make:job Command
|
|
5
|
+
* Scaffolds a background queue job worker in jobs/<name>.js
|
|
6
|
+
* @module bin/commands/make-job
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
function generateJobContent(jobName, queueName = 'default', retries = 3) {
|
|
13
|
+
return `'use strict';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Background Job: ${jobName}
|
|
17
|
+
* Dispatched via: req.queue.dispatch('${jobName}', payload)
|
|
18
|
+
*/
|
|
19
|
+
module.exports = {
|
|
20
|
+
name: '${jobName}',
|
|
21
|
+
queue: '${queueName}',
|
|
22
|
+
retries: ${retries},
|
|
23
|
+
backoff: {
|
|
24
|
+
type: 'exponential',
|
|
25
|
+
delay: 1000,
|
|
26
|
+
},
|
|
27
|
+
handler: async (payload, ctx) => {
|
|
28
|
+
const { db, logger } = ctx;
|
|
29
|
+
if (logger) logger.info('[Job:${jobName}] Processing job:', payload);
|
|
30
|
+
|
|
31
|
+
// Business logic goes here:
|
|
32
|
+
// const repo = db ? db.getRepository('...') : null;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function registerCommand(program) {
|
|
39
|
+
program
|
|
40
|
+
.command('make:job <name>')
|
|
41
|
+
.alias('job:make')
|
|
42
|
+
.description('Scaffold a background queue job worker in jobs/<name>.js')
|
|
43
|
+
.option('-q, --queue <queue>', 'Target queue name', 'default')
|
|
44
|
+
.option('-r, --retries <number>', 'Number of retries upon failure', '3')
|
|
45
|
+
.option('--root <dir>', 'Root directory of the project', process.cwd())
|
|
46
|
+
.action((nameArg, options) => {
|
|
47
|
+
const rootDir = path.resolve(options.root || process.cwd());
|
|
48
|
+
const jobsDir = path.join(rootDir, 'jobs');
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(jobsDir)) {
|
|
51
|
+
fs.mkdirSync(jobsDir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const jobName = String(nameArg || '').trim();
|
|
55
|
+
if (!jobName) {
|
|
56
|
+
console.error('❌ Job name is required.');
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Support nested folders e.g. emails/welcome
|
|
62
|
+
const targetFilePath = path.join(jobsDir, `${jobName}.js`);
|
|
63
|
+
const targetDir = path.dirname(targetFilePath);
|
|
64
|
+
|
|
65
|
+
if (!fs.existsSync(targetDir)) {
|
|
66
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fs.existsSync(targetFilePath)) {
|
|
70
|
+
console.error(`❌ Job file already exists: jobs/${jobName}.js`);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const retries = parseInt(options.retries, 10) || 3;
|
|
76
|
+
const content = generateJobContent(jobName, options.queue || 'default', retries);
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(targetFilePath, content, 'utf-8');
|
|
79
|
+
console.log(`\n✅ Created background job: jobs/${jobName}.js (queue: "${options.queue || 'default'}", retries: ${retries})\n`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
registerCommand,
|
|
85
|
+
generateJobContent,
|
|
86
|
+
};
|
|
@@ -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
|
+
};
|