webspresso 0.0.57 → 0.0.58
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/README.md +82 -3
- package/bin/commands/doctor.js +182 -0
- package/bin/commands/new.js +8 -8
- package/bin/commands/page.js +5 -5
- package/bin/commands/skill.js +165 -0
- package/bin/utils/db.js +19 -0
- package/bin/webspresso.js +4 -0
- package/core/orm/index.js +1 -1
- package/core/orm/json-fields.js +70 -0
- package/core/orm/query-builder.js +83 -87
- package/core/orm/repository.js +1 -62
- package/index.js +2 -1
- package/package.json +3 -2
- package/plugins/index.js +2 -0
- package/plugins/recaptcha/helpers.js +91 -0
- package/plugins/recaptcha/index.js +131 -0
- package/plugins/recaptcha/middleware.js +61 -0
- package/plugins/recaptcha/verify.js +150 -0
- package/src/server.js +11 -0
- package/templates/skills/webspresso-usage/SKILL.md +241 -0
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ The project includes:
|
|
|
143
143
|
- Tailwind CSS with build process
|
|
144
144
|
- Optimized layout template with navigation and footer
|
|
145
145
|
- Responsive starter page
|
|
146
|
-
- i18n setup (en/
|
|
146
|
+
- i18n setup (e.g. en/de)
|
|
147
147
|
- Development and production scripts
|
|
148
148
|
|
|
149
149
|
### `webspresso page`
|
|
@@ -191,6 +191,32 @@ webspresso start
|
|
|
191
191
|
webspresso start --port 3000
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
### `webspresso doctor`
|
|
195
|
+
|
|
196
|
+
Check Node.js version, `package.json` / `engines.node`, typical project files (`server.js`, `pages/`), and whether `webspresso.db.js` or `knexfile.js` exists. Use `--db` to run a quick connection test when a config is present. Warnings alone exit with code `0`; pass `--strict` to fail (exit `1`) on any warning—useful in CI.
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
webspresso doctor
|
|
200
|
+
webspresso doctor --db
|
|
201
|
+
webspresso doctor --strict
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `webspresso skill`
|
|
205
|
+
|
|
206
|
+
Scaffold a **Cursor Agent Skill**: creates `.cursor/skills/<name>/SKILL.md` with valid YAML frontmatter (`name`, `description`) and a short markdown template for AI tooling. Use `--global` to write under `~/.cursor/skills/` instead of the current project.
|
|
207
|
+
|
|
208
|
+
**Bundled preset:** `--preset webspresso` copies the full **Webspresso usage** reference skill (framework routing, ORM, plugins, CLI, pitfalls) into `.cursor/skills/webspresso-usage/SKILL.md` — no prompts.
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
webspresso skill my-workflow
|
|
212
|
+
webspresso skill review-pr --description "Runs PR review checklist. Use when reviewing pull requests."
|
|
213
|
+
webspresso skill deploy-check -g
|
|
214
|
+
|
|
215
|
+
# Install the bundled Webspresso agent reference (same content shipped in templates/skills/)
|
|
216
|
+
webspresso skill --preset webspresso
|
|
217
|
+
webspresso skill -p webspresso --global
|
|
218
|
+
```
|
|
219
|
+
|
|
194
220
|
### `webspresso add tailwind`
|
|
195
221
|
|
|
196
222
|
Add Tailwind CSS to your project with build process.
|
|
@@ -277,7 +303,7 @@ my-app/
|
|
|
277
303
|
├── pages/
|
|
278
304
|
│ ├── locales/ # Global i18n translations
|
|
279
305
|
│ │ ├── en.json
|
|
280
|
-
│ │ └──
|
|
306
|
+
│ │ └── de.json
|
|
281
307
|
│ ├── _hooks.js # Global lifecycle hooks
|
|
282
308
|
│ ├── index.njk # Home page (GET /)
|
|
283
309
|
│ ├── about/
|
|
@@ -482,7 +508,7 @@ const { app } = createApp({
|
|
|
482
508
|
hostname: 'https://example.com',
|
|
483
509
|
exclude: ['/admin/*', '/api/*'],
|
|
484
510
|
i18n: true,
|
|
485
|
-
locales: ['en', '
|
|
511
|
+
locales: ['en', 'de']
|
|
486
512
|
}),
|
|
487
513
|
analyticsPlugin({
|
|
488
514
|
google: {
|
|
@@ -748,6 +774,55 @@ const { app } = createApp({
|
|
|
748
774
|
|
|
749
775
|
Programmatic API (other plugins): `ctx.usePlugin('audit-log')` exposes `queryLogs`, `purgeAuditLogs`, and `getMigrationTemplate()`.
|
|
750
776
|
|
|
777
|
+
**reCAPTCHA plugin:**
|
|
778
|
+
- Google reCAPTCHA **v2** (checkbox) or **v3** (score): server verification via `https://www.google.com/recaptcha/api/siteverify` (no extra npm dependency; Node 18+ `fetch`)
|
|
779
|
+
- Registers CSP entries for Google scripts/iframes; Nunjucks helpers: `recaptchaScript`, `recaptchaWidget` (v2), `recaptchaV3Token` (hidden input + execute for v3 — use with `version: 'v3'` and `recaptchaScript` loads `api.js?render=siteKey`)
|
|
780
|
+
- Secret key: `options.secretKey` or env `RECAPTCHA_SECRET_KEY` (never expose to templates)
|
|
781
|
+
|
|
782
|
+
```javascript
|
|
783
|
+
const {
|
|
784
|
+
recaptchaPlugin,
|
|
785
|
+
createRecaptchaMiddleware,
|
|
786
|
+
resolveRecaptchaMiddlewareParams,
|
|
787
|
+
} = require('webspresso/plugins/recaptcha');
|
|
788
|
+
|
|
789
|
+
const recaptchaConfig = {
|
|
790
|
+
siteKey: process.env.RECAPTCHA_SITE_KEY,
|
|
791
|
+
secretKey: process.env.RECAPTCHA_SECRET_KEY,
|
|
792
|
+
version: 'v2', // or 'v3'
|
|
793
|
+
minScore: 0.5,
|
|
794
|
+
expectedAction: 'contact', // v3 verification only
|
|
795
|
+
defaultV3Action: 'submit', // for recaptchaV3Token helper
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
const { app } = createApp({
|
|
799
|
+
pagesDir: './pages',
|
|
800
|
+
plugins: [recaptchaPlugin(recaptchaConfig)],
|
|
801
|
+
middlewares: {
|
|
802
|
+
recaptcha: createRecaptchaMiddleware({
|
|
803
|
+
...resolveRecaptchaMiddlewareParams(recaptchaConfig),
|
|
804
|
+
bodyField: 'g-recaptcha-response',
|
|
805
|
+
}),
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
**File-based API** (`pages/api/...post.js`): use the named middleware instead of duplicating verification in the handler; on success, `req.recaptcha` contains a short summary of Google’s response.
|
|
811
|
+
|
|
812
|
+
```javascript
|
|
813
|
+
// pages/api/contact.post.js
|
|
814
|
+
module.exports = async function post(req, res) {
|
|
815
|
+
// recaptcha middleware already returned 400 if token invalid
|
|
816
|
+
return res.json({ ok: true, hostname: req.recaptcha.hostname });
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
module.exports.middleware = ['recaptcha'];
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
Optional: after `createApp`, use `pluginManager.getPluginAPI('recaptcha').createMiddleware({ bodyField: '...' })` to override plugin defaults and attach to the `app.use` chain (after body parsers).
|
|
823
|
+
|
|
824
|
+
Low-level usage (without middleware): import `verifyRecaptcha` / `getRemoteIp` from `webspresso/plugins/recaptcha/verify`.
|
|
825
|
+
|
|
751
826
|
**SEO Checker Plugin:**
|
|
752
827
|
- Client-side SEO analysis tool (inspired by django-check-seo)
|
|
753
828
|
- Integrated with dev toolbar
|
|
@@ -1349,6 +1424,10 @@ await UserRepo.query().onlyTrashed().list(); // Only deleted
|
|
|
1349
1424
|
await UserRepo.query().forTenant(tenantId).list();
|
|
1350
1425
|
```
|
|
1351
1426
|
|
|
1427
|
+
`list()`, `first()`, and `paginate()` emit the same `beforeFind` / `afterFind` lifecycle hooks as `findAll` / `findOne` (one `afterFind` per row). `count()` ignores any `.limit()` / `.offset()` on the builder so it returns the full matching row count.
|
|
1428
|
+
|
|
1429
|
+
`query().delete()` runs a SQL `DELETE` for matching rows. On models with soft deletes, use `UserRepo.delete(id)` (or equivalent) to set `deleted_at`; the query builder does not convert deletes to soft deletes.
|
|
1430
|
+
|
|
1352
1431
|
### Transactions
|
|
1353
1432
|
|
|
1354
1433
|
```javascript
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor Command — environment and project sanity checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} enginesNode - e.g. ">=18.0.0", "^20.1.0"
|
|
11
|
+
* @returns {boolean|null} null if could not interpret
|
|
12
|
+
*/
|
|
13
|
+
function nodeEngineOk(enginesNode) {
|
|
14
|
+
const v = process.version;
|
|
15
|
+
const major = parseInt(v.replace(/^v/, '').split('.')[0], 10);
|
|
16
|
+
if (Number.isNaN(major)) return null;
|
|
17
|
+
|
|
18
|
+
const s = String(enginesNode).trim();
|
|
19
|
+
const ge = s.match(/^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
20
|
+
if (ge) {
|
|
21
|
+
const reqMajor = parseInt(ge[1], 10);
|
|
22
|
+
return major >= reqMajor;
|
|
23
|
+
}
|
|
24
|
+
const gt = s.match(/^>\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
25
|
+
if (gt) {
|
|
26
|
+
const reqMajor = parseInt(gt[1], 10);
|
|
27
|
+
return major > reqMajor;
|
|
28
|
+
}
|
|
29
|
+
const caret = s.match(/^\^\s*(\d+)/);
|
|
30
|
+
if (caret) {
|
|
31
|
+
const reqMajor = parseInt(caret[1], 10);
|
|
32
|
+
return major === reqMajor;
|
|
33
|
+
}
|
|
34
|
+
const exact = s.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
|
|
35
|
+
if (exact) {
|
|
36
|
+
const reqMajor = parseInt(exact[1], 10);
|
|
37
|
+
return major === reqMajor;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function registerCommand(program) {
|
|
43
|
+
program
|
|
44
|
+
.command('doctor')
|
|
45
|
+
.description('Check Node version, project layout, and optional database connectivity')
|
|
46
|
+
.option('--db', 'Run a database connection test when webspresso.db.js or knexfile.js exists')
|
|
47
|
+
.option('--strict', 'Exit with code 1 if any warning is reported')
|
|
48
|
+
.option('-e, --env <environment>', 'Environment for DB config (with --db)', 'development')
|
|
49
|
+
.action(async (options) => {
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
let errors = 0;
|
|
52
|
+
let warnings = 0;
|
|
53
|
+
|
|
54
|
+
const line = (icon, msg) => console.log(` ${icon} ${msg}`);
|
|
55
|
+
|
|
56
|
+
console.log('\nWebspresso Doctor');
|
|
57
|
+
console.log('=================\n');
|
|
58
|
+
|
|
59
|
+
console.log('Environment');
|
|
60
|
+
console.log('-----------');
|
|
61
|
+
line('✓', `Node.js ${process.version} (${process.platform}, ${process.arch})`);
|
|
62
|
+
|
|
63
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
64
|
+
let pkg = null;
|
|
65
|
+
if (!fs.existsSync(pkgPath)) {
|
|
66
|
+
line('✗', 'package.json not found (run this from a project directory)');
|
|
67
|
+
errors += 1;
|
|
68
|
+
} else {
|
|
69
|
+
try {
|
|
70
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
71
|
+
line('✓', `package.json readable (${pkg.name || 'unnamed'})`);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
line('✗', `package.json invalid JSON: ${e.message}`);
|
|
74
|
+
errors += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (pkg && pkg.engines && pkg.engines.node) {
|
|
79
|
+
const ok = nodeEngineOk(pkg.engines.node);
|
|
80
|
+
if (ok === null) {
|
|
81
|
+
line('⚠', `engines.node "${pkg.engines.node}" — could not verify; check manually`);
|
|
82
|
+
warnings += 1;
|
|
83
|
+
} else if (ok) {
|
|
84
|
+
line('✓', `engines.node "${pkg.engines.node}" satisfied`);
|
|
85
|
+
} else {
|
|
86
|
+
line('⚠', `engines.node "${pkg.engines.node}" may not match current Node (adjust nvm / install)`);
|
|
87
|
+
warnings += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log('\nProject layout');
|
|
92
|
+
console.log('--------------');
|
|
93
|
+
|
|
94
|
+
const serverPath = path.join(cwd, 'server.js');
|
|
95
|
+
if (fs.existsSync(serverPath)) {
|
|
96
|
+
line('✓', 'server.js present');
|
|
97
|
+
} else {
|
|
98
|
+
line('⚠', 'server.js not found (expected for standard Webspresso apps)');
|
|
99
|
+
warnings += 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const pagesPath = path.join(cwd, 'pages');
|
|
103
|
+
if (fs.existsSync(pagesPath) && fs.statSync(pagesPath).isDirectory()) {
|
|
104
|
+
line('✓', 'pages/ directory present');
|
|
105
|
+
} else {
|
|
106
|
+
line('⚠', 'pages/ not found (file-based routes may be missing)');
|
|
107
|
+
warnings += 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log('\nDatabase config');
|
|
111
|
+
console.log('---------------');
|
|
112
|
+
|
|
113
|
+
let resolved = null;
|
|
114
|
+
let configLoadError = false;
|
|
115
|
+
try {
|
|
116
|
+
resolved = resolveDbConfigIfExists();
|
|
117
|
+
} catch (e) {
|
|
118
|
+
configLoadError = true;
|
|
119
|
+
line('✗', `Database config could not be loaded: ${e.message}`);
|
|
120
|
+
errors += 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (resolved) {
|
|
124
|
+
line('✓', `Found ${path.relative(cwd, resolved.path)}`);
|
|
125
|
+
} else if (!configLoadError) {
|
|
126
|
+
line('○', 'No webspresso.db.js or knexfile.js (ORM/CLI DB commands need one)');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (options.db) {
|
|
130
|
+
console.log('\nDatabase connection (--db)');
|
|
131
|
+
console.log('--------------------------');
|
|
132
|
+
if (!resolved) {
|
|
133
|
+
line('○', 'Skipped — no database config file');
|
|
134
|
+
} else {
|
|
135
|
+
let knexMod;
|
|
136
|
+
try {
|
|
137
|
+
knexMod = require('knex');
|
|
138
|
+
} catch {
|
|
139
|
+
line('✗', 'knex is not installed. Run: npm install knex');
|
|
140
|
+
errors += 1;
|
|
141
|
+
}
|
|
142
|
+
if (knexMod) {
|
|
143
|
+
let knex;
|
|
144
|
+
try {
|
|
145
|
+
knex = await createDbInstance(resolved.config, options.env);
|
|
146
|
+
await knex.raw('select 1');
|
|
147
|
+
line('✓', `Connection OK (env: ${options.env})`);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
line('✗', `Connection failed: ${e.message}`);
|
|
150
|
+
errors += 1;
|
|
151
|
+
} finally {
|
|
152
|
+
if (knex && typeof knex.destroy === 'function') {
|
|
153
|
+
try {
|
|
154
|
+
await knex.destroy();
|
|
155
|
+
} catch {
|
|
156
|
+
/* ignore */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log('');
|
|
165
|
+
|
|
166
|
+
const strict = options.strict === true;
|
|
167
|
+
const failWarnings = strict && warnings > 0;
|
|
168
|
+
const exitCode = errors > 0 || failWarnings ? 1 : 0;
|
|
169
|
+
|
|
170
|
+
if (errors > 0) {
|
|
171
|
+
console.log(`Summary: ${errors} error(s)${warnings ? `, ${warnings} warning(s)` : ''}\n`);
|
|
172
|
+
} else if (warnings > 0) {
|
|
173
|
+
console.log(`Summary: ${warnings} warning(s)${strict ? ' (strict: treating as failure)' : ''}\n`);
|
|
174
|
+
} else {
|
|
175
|
+
console.log('Summary: all checks passed.\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
process.exit(exitCode);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { registerCommand };
|
package/bin/commands/new.js
CHANGED
|
@@ -227,7 +227,7 @@ app.listen(PORT, () => {
|
|
|
227
227
|
let envExample = `PORT=3000
|
|
228
228
|
NODE_ENV=development
|
|
229
229
|
DEFAULT_LOCALE=en
|
|
230
|
-
SUPPORTED_LOCALES=en,
|
|
230
|
+
SUPPORTED_LOCALES=en,de
|
|
231
231
|
BASE_URL=http://localhost:3000
|
|
232
232
|
`;
|
|
233
233
|
|
|
@@ -454,23 +454,23 @@ coverage/
|
|
|
454
454
|
JSON.stringify(enJson, null, 2) + '\n'
|
|
455
455
|
);
|
|
456
456
|
|
|
457
|
-
const
|
|
457
|
+
const deJson = {
|
|
458
458
|
site: {
|
|
459
459
|
name: 'Webspresso'
|
|
460
460
|
},
|
|
461
461
|
nav: {
|
|
462
|
-
home: '
|
|
462
|
+
home: 'Startseite'
|
|
463
463
|
},
|
|
464
464
|
footer: {
|
|
465
|
-
copyright: '© 2025 Webspresso.
|
|
465
|
+
copyright: '© 2025 Webspresso. All rights reserved.'
|
|
466
466
|
},
|
|
467
|
-
welcome: '
|
|
468
|
-
description: '
|
|
467
|
+
welcome: 'Willkommen bei Webspresso',
|
|
468
|
+
description: 'Start building your SSR app!'
|
|
469
469
|
};
|
|
470
470
|
|
|
471
471
|
fs.writeFileSync(
|
|
472
|
-
path.join(projectPath, 'pages', 'locales', '
|
|
473
|
-
JSON.stringify(
|
|
472
|
+
path.join(projectPath, 'pages', 'locales', 'de.json'),
|
|
473
|
+
JSON.stringify(deJson, null, 2) + '\n'
|
|
474
474
|
);
|
|
475
475
|
|
|
476
476
|
// Create README
|
package/bin/commands/page.js
CHANGED
|
@@ -110,18 +110,18 @@ function registerCommand(program) {
|
|
|
110
110
|
JSON.stringify(enContent, null, 2) + '\n'
|
|
111
111
|
);
|
|
112
112
|
|
|
113
|
-
const
|
|
113
|
+
const deContent = {
|
|
114
114
|
title: route,
|
|
115
|
-
description: '
|
|
115
|
+
description: 'Seitenbeschreibung',
|
|
116
116
|
meta: {
|
|
117
117
|
title: `${route} - Webspresso`,
|
|
118
|
-
description: '
|
|
118
|
+
description: 'Seitenbeschreibung'
|
|
119
119
|
}
|
|
120
120
|
};
|
|
121
121
|
|
|
122
122
|
fs.writeFileSync(
|
|
123
|
-
path.join(localesDir, '
|
|
124
|
-
JSON.stringify(
|
|
123
|
+
path.join(localesDir, 'de.json'),
|
|
124
|
+
JSON.stringify(deContent, null, 2) + '\n'
|
|
125
125
|
);
|
|
126
126
|
|
|
127
127
|
console.log(`✅ Created locale files in ${localesDir}`);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Command — scaffold Agent Skill (SKILL.md) for Cursor / AI tools
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const inquirer = require('inquirer');
|
|
9
|
+
|
|
10
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
|
|
11
|
+
|
|
12
|
+
function validateSkillName(name) {
|
|
13
|
+
const t = String(name).trim().toLowerCase();
|
|
14
|
+
if (!t) return 'Skill name is required';
|
|
15
|
+
if (t.length > 64) return 'Skill name must be at most 64 characters';
|
|
16
|
+
if (!NAME_PATTERN.test(t)) {
|
|
17
|
+
return 'Use lowercase letters, numbers, and hyphens only (e.g. my-skill-name)';
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function skillDir(base, skillName) {
|
|
23
|
+
return path.join(base, '.cursor', 'skills', skillName);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function defaultDescription(skillName) {
|
|
27
|
+
const label = skillName.replace(/-/g, ' ');
|
|
28
|
+
return `Guides the agent through tasks for ${label}. Use when the user works on ${label} or asks about related workflows.`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Bundled presets: CLI flag → template path under bin/ */
|
|
32
|
+
const PRESETS = {
|
|
33
|
+
webspresso: {
|
|
34
|
+
skillName: 'webspresso-usage',
|
|
35
|
+
templatePath: path.join(__dirname, '../../templates/skills/webspresso-usage/SKILL.md'),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function buildSkillMarkdown(skillName, description) {
|
|
40
|
+
const title = skillName
|
|
41
|
+
.split('-')
|
|
42
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
43
|
+
.join(' ');
|
|
44
|
+
const descYaml = JSON.stringify(description.trim());
|
|
45
|
+
|
|
46
|
+
return `---
|
|
47
|
+
name: ${skillName}
|
|
48
|
+
description: ${descYaml}
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
# ${title}
|
|
52
|
+
|
|
53
|
+
## Instructions
|
|
54
|
+
|
|
55
|
+
- Describe step-by-step what the agent should do.
|
|
56
|
+
- Add constraints, file paths, and conventions for this project.
|
|
57
|
+
|
|
58
|
+
## When to use
|
|
59
|
+
|
|
60
|
+
- List concrete user phrases or tasks that should trigger this skill.
|
|
61
|
+
|
|
62
|
+
## Examples
|
|
63
|
+
|
|
64
|
+
\`\`\`
|
|
65
|
+
Example prompt or command
|
|
66
|
+
\`\`\`
|
|
67
|
+
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function registerCommand(program) {
|
|
73
|
+
program
|
|
74
|
+
.command('skill [name]')
|
|
75
|
+
.description('Create an Agent Skill folder with SKILL.md (Cursor / AI tools)')
|
|
76
|
+
.option('-g, --global', 'Write to ~/.cursor/skills/ instead of ./.cursor/skills/')
|
|
77
|
+
.option('-d, --description <text>', 'Skill description (for YAML frontmatter)')
|
|
78
|
+
.option('-f, --force', 'Overwrite existing SKILL.md')
|
|
79
|
+
.option('-p, --preset <name>', 'Install bundled skill: webspresso → full Webspresso agent reference (SKILL.md)')
|
|
80
|
+
.action(async (nameArg, options) => {
|
|
81
|
+
const presetKey = options.preset ? String(options.preset).trim().toLowerCase() : '';
|
|
82
|
+
|
|
83
|
+
if (presetKey) {
|
|
84
|
+
const preset = PRESETS[presetKey];
|
|
85
|
+
if (!preset) {
|
|
86
|
+
console.error(`❌ Unknown preset "${presetKey}". Available: ${Object.keys(PRESETS).join(', ')}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
if (!fs.existsSync(preset.templatePath)) {
|
|
90
|
+
console.error(`❌ Preset template missing: ${preset.templatePath}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const root = options.global ? os.homedir() : process.cwd();
|
|
94
|
+
const dir = skillDir(root, preset.skillName);
|
|
95
|
+
const skillFile = path.join(dir, 'SKILL.md');
|
|
96
|
+
|
|
97
|
+
if (!options.force && fs.existsSync(skillFile)) {
|
|
98
|
+
console.error(`❌ Already exists: ${skillFile}`);
|
|
99
|
+
console.error(' Use --force to overwrite.');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
fs.copyFileSync(preset.templatePath, skillFile);
|
|
105
|
+
|
|
106
|
+
console.log(`\n✅ Installed bundled skill "${presetKey}" →\n ${skillFile}\n`);
|
|
107
|
+
console.log(' Edit SKILL.md if needed, then restart Cursor or reload the window.\n');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let skillName = nameArg ? String(nameArg).trim().toLowerCase() : '';
|
|
112
|
+
|
|
113
|
+
if (!skillName) {
|
|
114
|
+
const { name } = await inquirer.prompt([
|
|
115
|
+
{
|
|
116
|
+
type: 'input',
|
|
117
|
+
name: 'name',
|
|
118
|
+
message: 'Skill name (kebab-case, e.g. review-api):',
|
|
119
|
+
validate: validateSkillName,
|
|
120
|
+
},
|
|
121
|
+
]);
|
|
122
|
+
skillName = name.trim().toLowerCase();
|
|
123
|
+
} else {
|
|
124
|
+
const v = validateSkillName(skillName);
|
|
125
|
+
if (v !== true) {
|
|
126
|
+
console.error(`❌ ${v}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
skillName = skillName.trim().toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let description = options.description;
|
|
133
|
+
if (!description) {
|
|
134
|
+
const { desc } = await inquirer.prompt([
|
|
135
|
+
{
|
|
136
|
+
type: 'input',
|
|
137
|
+
name: 'desc',
|
|
138
|
+
message: 'Short description (what/when the agent should use this skill):',
|
|
139
|
+
default: defaultDescription(skillName),
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
description = desc || defaultDescription(skillName);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const root = options.global ? os.homedir() : process.cwd();
|
|
146
|
+
const dir = skillDir(root, skillName);
|
|
147
|
+
const skillFile = path.join(dir, 'SKILL.md');
|
|
148
|
+
|
|
149
|
+
if (!options.force && fs.existsSync(skillFile)) {
|
|
150
|
+
console.error(`❌ Already exists: ${skillFile}`);
|
|
151
|
+
console.error(' Use --force to overwrite.');
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
const body = buildSkillMarkdown(skillName, description);
|
|
158
|
+
fs.writeFileSync(skillFile, body, 'utf8');
|
|
159
|
+
|
|
160
|
+
console.log(`\n✅ Agent skill created:\n ${skillFile}\n`);
|
|
161
|
+
console.log(' Edit SKILL.md, then restart Cursor or reload the window if needed.\n');
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { registerCommand };
|
package/bin/utils/db.js
CHANGED
|
@@ -26,6 +26,24 @@ function loadDbConfig(configPath) {
|
|
|
26
26
|
process.exit(1);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Resolve database config if present (no exit when missing; for doctor / tooling)
|
|
31
|
+
* @param {string} [configPath] - Custom config path
|
|
32
|
+
* @returns {{ config: Object, path: string } | null}
|
|
33
|
+
*/
|
|
34
|
+
function resolveDbConfigIfExists(configPath) {
|
|
35
|
+
const defaultPaths = ['webspresso.db.js', 'knexfile.js'];
|
|
36
|
+
const paths = configPath ? [configPath, ...defaultPaths] : defaultPaths;
|
|
37
|
+
|
|
38
|
+
for (const p of paths) {
|
|
39
|
+
const fullPath = path.resolve(process.cwd(), p);
|
|
40
|
+
if (fs.existsSync(fullPath)) {
|
|
41
|
+
return { config: require(fullPath), path: fullPath };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
/**
|
|
30
48
|
* Create database instance from config
|
|
31
49
|
* @param {Object} config - Database config
|
|
@@ -50,5 +68,6 @@ async function createDbInstance(config, env) {
|
|
|
50
68
|
|
|
51
69
|
module.exports = {
|
|
52
70
|
loadDbConfig,
|
|
71
|
+
resolveDbConfigIfExists,
|
|
53
72
|
createDbInstance
|
|
54
73
|
};
|
package/bin/webspresso.js
CHANGED
|
@@ -28,6 +28,8 @@ const { registerCommand: registerAdminSetup } = require('./commands/admin-setup'
|
|
|
28
28
|
const { registerCommand: registerAdminPassword } = require('./commands/admin-password');
|
|
29
29
|
const { registerCommand: registerFaviconGenerate } = require('./commands/favicon-generate');
|
|
30
30
|
const { registerCommand: registerAuditPrune } = require('./commands/audit-prune');
|
|
31
|
+
const { registerCommand: registerDoctor } = require('./commands/doctor');
|
|
32
|
+
const { registerCommand: registerSkill } = require('./commands/skill');
|
|
31
33
|
|
|
32
34
|
registerNew(program);
|
|
33
35
|
registerPage(program);
|
|
@@ -44,6 +46,8 @@ registerAdminSetup(program);
|
|
|
44
46
|
registerAdminPassword(program);
|
|
45
47
|
registerFaviconGenerate(program);
|
|
46
48
|
registerAuditPrune(program);
|
|
49
|
+
registerDoctor(program);
|
|
50
|
+
registerSkill(program);
|
|
47
51
|
|
|
48
52
|
// Parse arguments
|
|
49
53
|
program.parse();
|
package/core/orm/index.js
CHANGED
|
@@ -186,7 +186,7 @@ function createDatabase(config) {
|
|
|
186
186
|
* Get query builder for a model
|
|
187
187
|
* @param {string} modelName - Model name
|
|
188
188
|
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
189
|
-
* @returns {import('
|
|
189
|
+
* @returns {import('./query-builder').QueryBuilder}
|
|
190
190
|
*/
|
|
191
191
|
function query(modelName, scopeContext) {
|
|
192
192
|
const model = getModelInstance(modelName);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso ORM - JSON column helpers (shared by repository and query builder)
|
|
3
|
+
* @module core/orm/json-fields
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get JSON column names from model
|
|
8
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
9
|
+
* @returns {Set<string>} Set of JSON column names
|
|
10
|
+
*/
|
|
11
|
+
function getJsonColumns(model) {
|
|
12
|
+
const jsonCols = new Set();
|
|
13
|
+
if (model.columns) {
|
|
14
|
+
for (const [name, meta] of model.columns) {
|
|
15
|
+
if (meta.type === 'json') {
|
|
16
|
+
jsonCols.add(name);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return jsonCols;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Serialize JSON fields for database storage
|
|
25
|
+
* @param {Object} data - Data to serialize
|
|
26
|
+
* @param {Set<string>} jsonColumns - JSON column names
|
|
27
|
+
* @returns {Object} Serialized data
|
|
28
|
+
*/
|
|
29
|
+
function serializeJsonFields(data, jsonColumns) {
|
|
30
|
+
if (jsonColumns.size === 0) return data;
|
|
31
|
+
|
|
32
|
+
const serialized = { ...data };
|
|
33
|
+
for (const col of jsonColumns) {
|
|
34
|
+
if (col in serialized && serialized[col] !== null && serialized[col] !== undefined) {
|
|
35
|
+
if (typeof serialized[col] !== 'string') {
|
|
36
|
+
serialized[col] = JSON.stringify(serialized[col]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return serialized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Deserialize JSON fields from database
|
|
45
|
+
* @param {Object} record - Record from database
|
|
46
|
+
* @param {Set<string>} jsonColumns - JSON column names
|
|
47
|
+
* @returns {Object} Deserialized record
|
|
48
|
+
*/
|
|
49
|
+
function deserializeJsonFields(record, jsonColumns) {
|
|
50
|
+
if (!record || jsonColumns.size === 0) return record;
|
|
51
|
+
|
|
52
|
+
for (const col of jsonColumns) {
|
|
53
|
+
if (col in record && record[col] !== null && record[col] !== undefined) {
|
|
54
|
+
if (typeof record[col] === 'string') {
|
|
55
|
+
try {
|
|
56
|
+
record[col] = JSON.parse(record[col]);
|
|
57
|
+
} catch {
|
|
58
|
+
// If parsing fails, keep the original string value
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
getJsonColumns,
|
|
68
|
+
serializeJsonFields,
|
|
69
|
+
deserializeJsonFields,
|
|
70
|
+
};
|