webspresso 0.0.57 → 0.0.60
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 +144 -4
- 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/openapi/build-from-api-routes.js +172 -0
- package/core/openapi/orm-components.js +139 -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 +4 -1
- package/package.json +13 -7
- package/plugins/health-check.js +84 -0
- package/plugins/index.js +6 -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/plugins/schema-explorer.js +2 -133
- package/plugins/swagger.js +126 -0
- package/src/server.js +11 -0
- package/templates/skills/webspresso-usage/SKILL.md +241 -0
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Webspresso
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/webspresso)
|
|
4
|
+
[](https://npmx.dev/package/webspresso)
|
|
5
|
+
|
|
3
6
|
A minimal, file-based SSR framework for Node.js with Nunjucks templating.
|
|
4
7
|
|
|
5
8
|
## Features
|
|
@@ -12,7 +15,7 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
|
|
|
12
15
|
- **Lifecycle Hooks**: Global and route-level hooks for request processing
|
|
13
16
|
- **Template Helpers**: Laravel-inspired helper functions available in templates
|
|
14
17
|
- **Plugin System**: Extensible architecture with version control and inter-plugin communication
|
|
15
|
-
- **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics
|
|
18
|
+
- **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint
|
|
16
19
|
|
|
17
20
|
## Installation
|
|
18
21
|
|
|
@@ -143,7 +146,7 @@ The project includes:
|
|
|
143
146
|
- Tailwind CSS with build process
|
|
144
147
|
- Optimized layout template with navigation and footer
|
|
145
148
|
- Responsive starter page
|
|
146
|
-
- i18n setup (en/
|
|
149
|
+
- i18n setup (e.g. en/de)
|
|
147
150
|
- Development and production scripts
|
|
148
151
|
|
|
149
152
|
### `webspresso page`
|
|
@@ -191,6 +194,32 @@ webspresso start
|
|
|
191
194
|
webspresso start --port 3000
|
|
192
195
|
```
|
|
193
196
|
|
|
197
|
+
### `webspresso doctor`
|
|
198
|
+
|
|
199
|
+
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.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
webspresso doctor
|
|
203
|
+
webspresso doctor --db
|
|
204
|
+
webspresso doctor --strict
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### `webspresso skill`
|
|
208
|
+
|
|
209
|
+
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.
|
|
210
|
+
|
|
211
|
+
**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.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
webspresso skill my-workflow
|
|
215
|
+
webspresso skill review-pr --description "Runs PR review checklist. Use when reviewing pull requests."
|
|
216
|
+
webspresso skill deploy-check -g
|
|
217
|
+
|
|
218
|
+
# Install the bundled Webspresso agent reference (same content shipped in templates/skills/)
|
|
219
|
+
webspresso skill --preset webspresso
|
|
220
|
+
webspresso skill -p webspresso --global
|
|
221
|
+
```
|
|
222
|
+
|
|
194
223
|
### `webspresso add tailwind`
|
|
195
224
|
|
|
196
225
|
Add Tailwind CSS to your project with build process.
|
|
@@ -277,7 +306,7 @@ my-app/
|
|
|
277
306
|
├── pages/
|
|
278
307
|
│ ├── locales/ # Global i18n translations
|
|
279
308
|
│ │ ├── en.json
|
|
280
|
-
│ │ └──
|
|
309
|
+
│ │ └── de.json
|
|
281
310
|
│ ├── _hooks.js # Global lifecycle hooks
|
|
282
311
|
│ ├── index.njk # Home page (GET /)
|
|
283
312
|
│ ├── about/
|
|
@@ -482,7 +511,7 @@ const { app } = createApp({
|
|
|
482
511
|
hostname: 'https://example.com',
|
|
483
512
|
exclude: ['/admin/*', '/api/*'],
|
|
484
513
|
i18n: true,
|
|
485
|
-
locales: ['en', '
|
|
514
|
+
locales: ['en', 'de']
|
|
486
515
|
}),
|
|
487
516
|
analyticsPlugin({
|
|
488
517
|
google: {
|
|
@@ -748,6 +777,55 @@ const { app } = createApp({
|
|
|
748
777
|
|
|
749
778
|
Programmatic API (other plugins): `ctx.usePlugin('audit-log')` exposes `queryLogs`, `purgeAuditLogs`, and `getMigrationTemplate()`.
|
|
750
779
|
|
|
780
|
+
**reCAPTCHA plugin:**
|
|
781
|
+
- Google reCAPTCHA **v2** (checkbox) or **v3** (score): server verification via `https://www.google.com/recaptcha/api/siteverify` (no extra npm dependency; Node 18+ `fetch`)
|
|
782
|
+
- 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`)
|
|
783
|
+
- Secret key: `options.secretKey` or env `RECAPTCHA_SECRET_KEY` (never expose to templates)
|
|
784
|
+
|
|
785
|
+
```javascript
|
|
786
|
+
const {
|
|
787
|
+
recaptchaPlugin,
|
|
788
|
+
createRecaptchaMiddleware,
|
|
789
|
+
resolveRecaptchaMiddlewareParams,
|
|
790
|
+
} = require('webspresso/plugins/recaptcha');
|
|
791
|
+
|
|
792
|
+
const recaptchaConfig = {
|
|
793
|
+
siteKey: process.env.RECAPTCHA_SITE_KEY,
|
|
794
|
+
secretKey: process.env.RECAPTCHA_SECRET_KEY,
|
|
795
|
+
version: 'v2', // or 'v3'
|
|
796
|
+
minScore: 0.5,
|
|
797
|
+
expectedAction: 'contact', // v3 verification only
|
|
798
|
+
defaultV3Action: 'submit', // for recaptchaV3Token helper
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const { app } = createApp({
|
|
802
|
+
pagesDir: './pages',
|
|
803
|
+
plugins: [recaptchaPlugin(recaptchaConfig)],
|
|
804
|
+
middlewares: {
|
|
805
|
+
recaptcha: createRecaptchaMiddleware({
|
|
806
|
+
...resolveRecaptchaMiddlewareParams(recaptchaConfig),
|
|
807
|
+
bodyField: 'g-recaptcha-response',
|
|
808
|
+
}),
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
**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.
|
|
814
|
+
|
|
815
|
+
```javascript
|
|
816
|
+
// pages/api/contact.post.js
|
|
817
|
+
module.exports = async function post(req, res) {
|
|
818
|
+
// recaptcha middleware already returned 400 if token invalid
|
|
819
|
+
return res.json({ ok: true, hostname: req.recaptcha.hostname });
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
module.exports.middleware = ['recaptcha'];
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
Optional: after `createApp`, use `pluginManager.getPluginAPI('recaptcha').createMiddleware({ bodyField: '...' })` to override plugin defaults and attach to the `app.use` chain (after body parsers).
|
|
826
|
+
|
|
827
|
+
Low-level usage (without middleware): import `verifyRecaptcha` / `getRemoteIp` from `webspresso/plugins/recaptcha/verify`.
|
|
828
|
+
|
|
751
829
|
**SEO Checker Plugin:**
|
|
752
830
|
- Client-side SEO analysis tool (inspired by django-check-seo)
|
|
753
831
|
- Integrated with dev toolbar
|
|
@@ -1349,6 +1427,10 @@ await UserRepo.query().onlyTrashed().list(); // Only deleted
|
|
|
1349
1427
|
await UserRepo.query().forTenant(tenantId).list();
|
|
1350
1428
|
```
|
|
1351
1429
|
|
|
1430
|
+
`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.
|
|
1431
|
+
|
|
1432
|
+
`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.
|
|
1433
|
+
|
|
1352
1434
|
### Transactions
|
|
1353
1435
|
|
|
1354
1436
|
```javascript
|
|
@@ -1697,6 +1779,64 @@ const user = plugin.api.getModel('User'); // Single model
|
|
|
1697
1779
|
const names = plugin.api.getModelNames(); // Model names
|
|
1698
1780
|
```
|
|
1699
1781
|
|
|
1782
|
+
### Health check plugin
|
|
1783
|
+
|
|
1784
|
+
Exposes a lightweight **GET** endpoint for load balancers and orchestrators (Kubernetes, Docker healthcheck, etc.). **Enabled by default** in all environments; set `enabled: false` to turn it off.
|
|
1785
|
+
|
|
1786
|
+
**Setup:**
|
|
1787
|
+
|
|
1788
|
+
```javascript
|
|
1789
|
+
const { createApp, healthCheckPlugin } = require('webspresso');
|
|
1790
|
+
|
|
1791
|
+
const app = createApp({
|
|
1792
|
+
plugins: [
|
|
1793
|
+
healthCheckPlugin({
|
|
1794
|
+
path: '/health', // default
|
|
1795
|
+
verbose: true, // timestamp, uptime, NODE_ENV, framework name/version
|
|
1796
|
+
authorize: (req) => true, // optional — restrict who can read the endpoint
|
|
1797
|
+
checks: async ({ db }) => {
|
|
1798
|
+
if (db) await db.knex.raw('select 1');
|
|
1799
|
+
return { database: 'ok' };
|
|
1800
|
+
},
|
|
1801
|
+
}),
|
|
1802
|
+
],
|
|
1803
|
+
});
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1806
|
+
- **`checks`**: If this function throws, the handler responds with **503** and `{ status: 'unhealthy', error, ... }`. Return a plain object to merge into `checks` on success (e.g. dependency status).
|
|
1807
|
+
- Use a **custom `path`** if your app already serves `GET /health` from `pages/`.
|
|
1808
|
+
|
|
1809
|
+
### Swagger / OpenAPI plugin
|
|
1810
|
+
|
|
1811
|
+
Serves **OpenAPI 3** for file-based `pages/api` routes and optional [Zod](https://zod.dev) `schema` exports, plus a **Swagger UI** page. Defaults to **development only** (same idea as the schema explorer).
|
|
1812
|
+
|
|
1813
|
+
**Setup:**
|
|
1814
|
+
|
|
1815
|
+
```javascript
|
|
1816
|
+
const { createApp, swaggerPlugin } = require('webspresso');
|
|
1817
|
+
|
|
1818
|
+
const app = createApp({
|
|
1819
|
+
plugins: [
|
|
1820
|
+
swaggerPlugin({
|
|
1821
|
+
path: '/_swagger', // UI: GET /_swagger, spec: GET /_swagger/openapi.json
|
|
1822
|
+
enabled: true, // default: true in development, false in production
|
|
1823
|
+
title: 'My API', // optional OpenAPI info.title
|
|
1824
|
+
serverUrl: 'https://api.example.com', // optional servers[0].url (else BASE_URL or localhost)
|
|
1825
|
+
includeOrmSchemas: false, // merge ORM model schemas into components.schemas
|
|
1826
|
+
ormExclude: ['Secret'], // when includeOrmSchemas is true
|
|
1827
|
+
authorize: (req) => true, // optional gate for both UI and JSON
|
|
1828
|
+
}),
|
|
1829
|
+
],
|
|
1830
|
+
});
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
**Endpoints:**
|
|
1834
|
+
|
|
1835
|
+
- `GET /_swagger/openapi.json` — Full OpenAPI document (`paths` from API routes; request/response shapes from exported `schema({ z })` when present).
|
|
1836
|
+
- `GET /_swagger` — Swagger UI (loads the JSON above; requires network access for CDN assets).
|
|
1837
|
+
|
|
1838
|
+
In production, keep the plugin disabled or protect it with `authorize` / your own middleware.
|
|
1839
|
+
|
|
1700
1840
|
## Development
|
|
1701
1841
|
|
|
1702
1842
|
```bash
|
|
@@ -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();
|