webspresso 0.0.74 → 0.0.76

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 (61) hide show
  1. package/README.md +66 -4
  2. package/bin/commands/admin-password.js +21 -57
  3. package/bin/commands/orm-map.js +139 -0
  4. package/bin/commands/skill.js +22 -8
  5. package/bin/utils/orm-map-html.js +689 -0
  6. package/bin/utils/orm-map-load.js +85 -0
  7. package/bin/utils/orm-map-snapshot.js +179 -0
  8. package/bin/utils/resolve-webspresso-orm.js +23 -0
  9. package/bin/webspresso.js +2 -0
  10. package/core/auth/manager.js +14 -1
  11. package/core/kernel/app.js +96 -0
  12. package/core/kernel/base-repository.js +143 -0
  13. package/core/kernel/events.js +101 -0
  14. package/core/kernel/flow.js +22 -0
  15. package/core/kernel/index.js +17 -0
  16. package/core/kernel/plugin.js +23 -0
  17. package/core/kernel/plugins/sample-seo.js +26 -0
  18. package/core/kernel/run-demo.js +58 -0
  19. package/core/kernel/view.js +167 -0
  20. package/core/openapi/build-from-api-routes.js +8 -2
  21. package/core/orm/model.js +3 -1
  22. package/core/url-path-normalize.js +30 -0
  23. package/index.d.ts +168 -1
  24. package/index.js +20 -2
  25. package/package.json +11 -1
  26. package/plugins/admin-panel/api.js +43 -15
  27. package/plugins/admin-panel/client/README.md +39 -0
  28. package/plugins/admin-panel/client/load-parts.js +74 -0
  29. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  30. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  31. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  32. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  33. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  34. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  35. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  36. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  37. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  38. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  39. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  40. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  41. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  42. package/plugins/admin-panel/components.js +4 -2640
  43. package/plugins/admin-panel/core/api-extensions.js +100 -10
  44. package/plugins/admin-panel/index.js +3 -0
  45. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  46. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  47. package/plugins/admin-panel/modules/dashboard.js +3 -2
  48. package/plugins/admin-panel/modules/user-management.js +90 -20
  49. package/plugins/index.js +4 -0
  50. package/plugins/rate-limit/index.js +178 -0
  51. package/plugins/redirect/index.js +204 -0
  52. package/plugins/rest-resources/index.js +2 -1
  53. package/plugins/swagger.js +2 -1
  54. package/plugins/upload/local-file-provider.js +6 -2
  55. package/src/file-router.js +191 -50
  56. package/src/njk-frontmatter.js +156 -0
  57. package/src/plugin-manager.js +4 -2
  58. package/src/server.js +26 -9
  59. package/templates/skills/webspresso-usage/REFERENCE-framework.md +277 -0
  60. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  61. package/templates/skills/webspresso-usage/SKILL.md +29 -278
package/README.md CHANGED
@@ -19,6 +19,7 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
19
19
  - **Session authentication** (optional): `createAuth` / `quickAuth` in **`webspresso/core/auth`** — pass the manager to **`createApp({ auth })`** for `express-session`, `req.user` / `req.auth`, remember-me tokens, and policy-style authorization. Full walkthrough: **[`doc/index.html#authentication`](doc/index.html#authentication)**.
20
20
  - **Optional client runtime** (Alpine.js + [swup](https://swup.js.org/)): **`createApp({ clientRuntime: { alpine, swup } })`** serves scripts under **`/__webspresso/client-runtime/`** and exposes **`clientRuntime`** in Nunjucks; layouts can include **`views/partials/webspresso-client-runtime.njk`**. Env overrides: **`WEBSPRESSO_ALPINE`**, **`WEBSPRESSO_SWUP`**. Demo: **`examples/alpine-swup-demo/`**. Details: **[`doc/index.html#client-runtime`](doc/index.html#client-runtime)**.
21
21
  - **TypeScript**: Published **`index.d.ts`** (via `package.json` `"types"`) for `createApp`, ORM, plugins, and router helpers — use from TS/JS with IDE autocomplete; runtime stays CommonJS
22
+ - **Application kernel (optional)**: In-process **`kernel`** API (`require('webspresso').kernel`) — event bus (`dispatch` / `publish`), **`kernel.createApp()`** (namespaced differently from SSR **`createApp`**), **`definePlugin`** / **`defineFlow`**, minimal **`{{ }}` view resolver**, and simulated **`BaseRepository`** with `orm.<resource>.*` events. Ships as **`core/kernel/`** on npm. Demo: **`node core/kernel/run-demo.js`**. Docs: **[`doc/index.html#application-kernel`](doc/index.html#application-kernel)**.
22
23
 
23
24
  ## Installation
24
25
 
@@ -40,6 +41,18 @@ Install **`@types/express`** in your app if you want full **`Express.Application
40
41
 
41
42
  Framework development (this repo): run **`npm run check:types`** to typecheck the declarations against a small smoke file (`tests/ts-smoke/`).
42
43
 
44
+ ## Application kernel (`kernel`)
45
+
46
+ Do **not** confuse **`kernel.createApp()`** with the package root **`createApp`** used for SSR—it returns a different object (event bus, optional flows, and a minimal view resolver). It does **not** modify Knex ORM behavior or Express routing.
47
+
48
+ ```javascript
49
+ const { kernel } = require('webspresso');
50
+ const app = kernel.createApp();
51
+ app.events.on('orm.post.afterCreate', async (ctx) => { /* ... */ });
52
+ ```
53
+
54
+ See **[`doc/index.html#application-kernel`](doc/index.html#application-kernel)** · source modules: [`core/kernel/`](core/kernel/). Cursor skill: **`REFERENCE-kernel.md`** (installed via `webspresso skill --preset webspresso`).
55
+
43
56
  ## Quick Start
44
57
 
45
58
  ### Using CLI (Recommended)
@@ -234,7 +247,7 @@ webspresso doctor --strict
234
247
 
235
248
  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.
236
249
 
237
- **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.
250
+ **Bundled preset:** `--preset webspresso` copies **`SKILL.md`** (short index), **`REFERENCE-framework.md`** (SSR `createApp`, routes, ORM, auth, plugins, CLI), and **`REFERENCE-kernel.md`** (`kernel` event bus / flows) into **`.cursor/skills/webspresso-usage/`** — no prompts.
238
251
 
239
252
  ```bash
240
253
  webspresso skill my-workflow
@@ -533,6 +546,14 @@ Error templates receive these variables:
533
546
  - `500.njk`: `{ error, status, isDev }`
534
547
  - `503.njk`: `{ url, method, isDev }`
535
548
 
549
+ **How errors reach this handler**
550
+
551
+ Unhandled errors from file-based routes (`pages/**/*.njk` `load()` / middleware / render, and `pages/api/**/*.js` handlers) are forwarded with `next(err)`, so they go through this 4-argument Express error middleware. That means `errorPages.serverError` and `errorPages.timeout` apply to those failures as well (not only to routes you add with `setupRoutes`).
552
+
553
+ - **`pages/_hooks.js` `onError(ctx, err)`** runs **before** the central handler (for both SSR and API file routes). Use it for logging or APM (`Sentry.captureException`, `newrelic.noticeError`, etc.). The error is also on **`ctx.error`**.
554
+
555
+ - **JSON vs HTML:** Requests whose path starts with **`/api`** always get a **JSON** error body from the default branch (and the default **string** `serverError` / `timeout` Nunjucks templates are skipped for those paths). Other clients use **`Accept`** as before: prefer HTML when the client accepts HTML.
556
+
536
557
  **Request Timeout:**
537
558
 
538
559
  Configure request timeout with `connect-timeout`:
@@ -682,6 +703,22 @@ Options:
682
703
  - `path` - Custom dashboard path (default: `/_webspresso`)
683
704
  - `enabled` - Force enable/disable (default: auto based on NODE_ENV)
684
705
 
706
+ **Redirect plugin:**
707
+ - Runs in `register()` **before** file-based routes, so configured paths override SSR pages.
708
+ - Rules: `from` (string or `RegExp`), `to` (path or URL), optional `status` (301/302/303/307/308), optional `methods` (`'*'` or a list; default plugin methods are `GET` and `HEAD` only).
709
+ - `preserveQuery` (default `true`) appends the request query when `to` has no `?`. External `to` values require `allowExternal: true`.
710
+
711
+ ```javascript
712
+ const { redirectPlugin } = require('webspresso/plugins');
713
+
714
+ redirectPlugin({
715
+ rules: [
716
+ { from: '/old-blog', to: '/blog', status: 301 },
717
+ { from: /^\/wiki\/(.*)$/, to: '/docs' },
718
+ ],
719
+ });
720
+ ```
721
+
685
722
  **Sitemap Plugin:**
686
723
  - Generates `/sitemap.xml` from routes automatically
687
724
  - **Dynamic Database Content**: Generate URLs from database records
@@ -768,7 +805,7 @@ Options:
768
805
  - `userManagement` - Site-user admin UI (`enabled`, `model` matching ORM user table, optional `fields` map). SPA routes: `/_admin/users`, `/_admin/users/new`, …; APIs: `/_admin/api/users*`. Admin staff still use **`admin_users`** / `/_admin` login; this is separate from **`req.user`** on the public site
769
806
  - `configure` - Callback `(registry) => void` for manual setup
770
807
 
771
- See **`doc/index.html#admin-user-management`** and **Session authentication** in **`.cursor/skills/webspresso-usage/SKILL.md`** for the split between **`adminUser`** and **`createApp({ auth })`**.
808
+ See **`doc/index.html#admin-user-management`** and **Session authentication** in **`.cursor/skills/webspresso-usage/REFERENCE-framework.md`** for the split between **`adminUser`** and **`createApp({ auth })`**.
772
809
 
773
810
  **Custom Admin Pages (registerModule):**
774
811
 
@@ -2128,10 +2165,34 @@ const { app } = createApp({
2128
2165
  ```
2129
2166
 
2130
2167
  - **ORM:** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
2131
- - **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'` or `customFields` type `file-upload`) POST to that URL with credentials.
2168
+ - **Admin forms:** columns with `zdb.file()` automatically render a drag-and-drop upload widget (or a manual URL text field when `uploadUrl` is not configured). Optional `ui: { label, hint, accept, maxBytes }` on the column customizes the widget. For existing `zdb.string()` columns you can use `admin.customFields: { columnName: { type: 'file-upload' } }` instead of changing the schema type.
2169
+ - **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'` or `customFields` type `file-upload`) POST to that URL with credentials; the saved record stores the returned **`url`** / **`publicUrl`** string.
2132
2170
  - **Response:** `{ url, publicUrl, key? }` — clients typically persist **`url`** / **`publicUrl`** in the model.
2133
2171
  - **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
2134
2172
 
2173
+ **Admin model example** (`zdb.file()` picks up the upload widget from schema; no extra `customFields` needed):
2174
+
2175
+ ```javascript
2176
+ const { defineModel, zdb } = require('webspresso');
2177
+
2178
+ const Post = defineModel({
2179
+ name: 'Post',
2180
+ table: 'posts',
2181
+ schema: zdb.schema({
2182
+ id: zdb.id(),
2183
+ title: zdb.string(),
2184
+ cover_image: zdb.file({
2185
+ maxLength: 2048,
2186
+ nullable: true,
2187
+ ui: { label: 'Cover', accept: 'image/*', hint: 'JPEG or PNG' },
2188
+ }),
2189
+ }),
2190
+ admin: { enabled: true, label: 'Posts' },
2191
+ });
2192
+ ```
2193
+
2194
+ For a plain string column, use `admin.customFields: { attachment: { type: 'file-upload' } }` instead of `zdb.file()`.
2195
+
2135
2196
  ### Health check plugin
2136
2197
 
2137
2198
  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.
@@ -2210,8 +2271,9 @@ npm run test:watch
2210
2271
  # Run tests with coverage
2211
2272
  npm run test:coverage
2212
2273
 
2213
- # Micro-benchmarks (Vitest bench; also runs in CI on the test matrix)
2274
+ # Micro-benchmarks (Vitest bench; CI: main push uploads benchmark-baseline artifact, PRs compare against it)
2214
2275
  npm run bench
2276
+ # Local baseline + compare: npm run bench:ci:local
2215
2277
  ```
2216
2278
 
2217
2279
  ## License
@@ -3,7 +3,8 @@
3
3
  * Reset admin user password via CLI
4
4
  */
5
5
 
6
- const readline = require('readline');
6
+ const inquirer = require('inquirer');
7
+ const { hash } = require('../../core/auth/hash');
7
8
  const { loadDbConfig, createDbInstance } = require('../utils/db');
8
9
 
9
10
  function registerCommand(program) {
@@ -32,17 +33,14 @@ function registerCommand(program) {
32
33
  // Get email (interactive if not provided)
33
34
  let email = options.email;
34
35
  if (!email) {
35
- const rl = readline.createInterface({
36
- input: process.stdin,
37
- output: process.stdout,
38
- });
39
-
40
- email = await new Promise((resolve) => {
41
- rl.question('Enter admin email: ', (answer) => {
42
- rl.close();
43
- resolve(answer.trim());
44
- });
45
- });
36
+ const answers = await inquirer.prompt([
37
+ {
38
+ type: 'input',
39
+ name: 'email',
40
+ message: 'Enter admin email:',
41
+ },
42
+ ]);
43
+ email = answers.email.trim();
46
44
  }
47
45
 
48
46
  if (!email) {
@@ -70,49 +68,15 @@ function registerCommand(program) {
70
68
  // Get new password (interactive if not provided)
71
69
  let password = options.password;
72
70
  if (!password) {
73
- const rl = readline.createInterface({
74
- input: process.stdin,
75
- output: process.stdout,
76
- });
77
-
78
- // Disable echo for password input
79
- if (process.stdin.isTTY) {
80
- process.stdout.write('Enter new password: ');
81
- password = await new Promise((resolve) => {
82
- let pwd = '';
83
- process.stdin.setRawMode(true);
84
- process.stdin.resume();
85
- process.stdin.on('data', (char) => {
86
- char = char.toString();
87
- if (char === '\n' || char === '\r') {
88
- process.stdin.setRawMode(false);
89
- process.stdin.pause();
90
- console.log(); // New line after password
91
- resolve(pwd);
92
- } else if (char === '\u0003') {
93
- // Ctrl+C
94
- process.exit();
95
- } else if (char === '\u007F') {
96
- // Backspace
97
- if (pwd.length > 0) {
98
- pwd = pwd.slice(0, -1);
99
- process.stdout.write('\b \b');
100
- }
101
- } else {
102
- pwd += char;
103
- process.stdout.write('*');
104
- }
105
- });
106
- });
107
- rl.close();
108
- } else {
109
- password = await new Promise((resolve) => {
110
- rl.question('Enter new password: ', (answer) => {
111
- rl.close();
112
- resolve(answer);
113
- });
114
- });
115
- }
71
+ const answers = await inquirer.prompt([
72
+ {
73
+ type: 'password',
74
+ name: 'password',
75
+ message: 'Enter new password:',
76
+ mask: '*',
77
+ },
78
+ ]);
79
+ password = answers.password;
116
80
  }
117
81
 
118
82
  if (!password || password.length < 6) {
@@ -121,8 +85,8 @@ function registerCommand(program) {
121
85
  process.exit(1);
122
86
  }
123
87
 
124
- // Hash the password
125
- const hashedPassword = await bcrypt.hash(password, 10);
88
+ // Hash the password (same rounds as admin panel setup)
89
+ const hashedPassword = await hash(password, 10);
126
90
 
127
91
  // Update the password
128
92
  await db('admin_users')
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ORM map — interactive single-page HTML of models and relationships
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { execFileSync } = require('child_process');
9
+ const { getWebspressoOrmForProject } = require('../utils/resolve-webspresso-orm');
10
+ const { loadProjectModels } = require('../utils/orm-map-load');
11
+ const { buildSnapshot, buildMermaidErDiagram } = require('../utils/orm-map-snapshot');
12
+ const { buildOrmMapHtml, readPackageName } = require('../utils/orm-map-html');
13
+
14
+ function ensureOpenInsideTrustedRoots(absFile, cwd) {
15
+ if (!fs.existsSync(absFile)) {
16
+ throw new Error(`File does not exist: ${absFile}`);
17
+ }
18
+ let realFile;
19
+ try {
20
+ realFile = fs.realpathSync(absFile);
21
+ } catch (e) {
22
+ throw new Error(`Cannot resolve file path: ${absFile}`, { cause: e });
23
+ }
24
+
25
+ /** @type {string[]} */
26
+ const roots = [];
27
+ for (const base of [os.tmpdir(), cwd]) {
28
+ const resolvedBase = path.resolve(base);
29
+ try {
30
+ roots.push(fs.realpathSync(resolvedBase));
31
+ } catch {
32
+ roots.push(resolvedBase);
33
+ }
34
+ }
35
+
36
+ const fileNorm = path.normalize(realFile + path.sep);
37
+ const trusted = roots.some((rootRaw) => {
38
+ const rr =
39
+ typeof rootRaw === 'string' && fs.existsSync(rootRaw)
40
+ ? fs.realpathSync(rootRaw)
41
+ : path.normalize(rootRaw);
42
+ const rp = rr.endsWith(path.sep) ? rr : rr + path.sep;
43
+ return realFile === rr || fileNorm.startsWith(path.normalize(rp));
44
+ });
45
+
46
+ if (!trusted) {
47
+ throw new Error('Refused to open file outside cwd or OS temp dir');
48
+ }
49
+ }
50
+
51
+ function openFile(filePath, cwd) {
52
+ const fp = path.resolve(filePath);
53
+ ensureOpenInsideTrustedRoots(fp, cwd);
54
+ if (process.platform === 'darwin') {
55
+ execFileSync('open', [fp], { stdio: 'ignore' });
56
+ } else if (process.platform === 'win32') {
57
+ execFileSync('cmd', ['/c', 'start', '', fp], { stdio: 'ignore' });
58
+ } else {
59
+ execFileSync('xdg-open', [fp], { stdio: 'ignore' });
60
+ }
61
+ }
62
+
63
+ function registerCommand(program) {
64
+ program
65
+ .command('orm:map')
66
+ .description(
67
+ 'Generate a self-contained HTML map of ORM models and relations (opens in browser by default)',
68
+ )
69
+ .option('-o, --output <file>', 'Write HTML to this path instead of a temp file')
70
+ .option('--no-open', 'Do not open the browser')
71
+ .option('-c, --config <path>', 'Database config file (webspresso.db.js / knexfile.js) for models path')
72
+ .option('-e, --env <environment>', 'Config environment', 'development')
73
+ .option('-m, --models <dir>', 'Models directory (overrides config default ./models)')
74
+ .action((options) => {
75
+ const cwd = process.cwd();
76
+ const { modelsDir, loaded, errors } = loadProjectModels(cwd, {
77
+ modelsOverride: options.models,
78
+ configPath: options.config,
79
+ env: options.env,
80
+ });
81
+
82
+ for (const err of errors) {
83
+ if (!err.file) {
84
+ console.error(`❌ ${err.message}`);
85
+ } else {
86
+ console.warn(`⚠️ ${err.file}: ${err.message}`);
87
+ }
88
+ }
89
+
90
+ if (loaded.length === 0 && errors.some((e) => e.file === '')) {
91
+ process.exit(1);
92
+ }
93
+
94
+ const orm = getWebspressoOrmForProject(cwd);
95
+ const registry = orm.getAllModels();
96
+ if (registry.size === 0) {
97
+ console.error(
98
+ `❌ No models registered. Checked: ${modelsDir}\n` +
99
+ ' Add defineModel() files or pass --models <dir>.',
100
+ );
101
+ process.exit(1);
102
+ }
103
+
104
+ const snapshot = buildSnapshot(registry);
105
+ const mermaid = buildMermaidErDiagram(snapshot);
106
+ const pkg = readPackageName(cwd);
107
+ const html = buildOrmMapHtml(snapshot, mermaid, {
108
+ title: 'ORM model map',
109
+ packageName: pkg,
110
+ });
111
+
112
+ let outPath;
113
+ if (options.output) {
114
+ outPath = path.resolve(cwd, options.output);
115
+ } else {
116
+ outPath = path.join(
117
+ os.tmpdir(),
118
+ `webspresso-orm-map-${Date.now()}.html`,
119
+ );
120
+ }
121
+
122
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
123
+ fs.writeFileSync(outPath, html, 'utf8');
124
+
125
+ console.log(`\n✅ ORM map (${registry.size} model(s))`);
126
+ console.log(` Models dir: ${modelsDir}`);
127
+ console.log(` Written: ${outPath}\n`);
128
+
129
+ if (options.open !== false) {
130
+ try {
131
+ openFile(outPath, cwd);
132
+ } catch (e) {
133
+ console.warn(`⚠️ Could not open browser: ${e.message}`);
134
+ }
135
+ }
136
+ });
137
+ }
138
+
139
+ module.exports = { registerCommand };
@@ -28,11 +28,11 @@ function defaultDescription(skillName) {
28
28
  return `Guides the agent through tasks for ${label}. Use when the user works on ${label} or asks about related workflows.`;
29
29
  }
30
30
 
31
- /** Bundled presets: CLI flag → template path under bin/ */
31
+ /** Bundled presets: CLI flag → template directory (all *.md copied) */
32
32
  const PRESETS = {
33
33
  webspresso: {
34
34
  skillName: 'webspresso-usage',
35
- templatePath: path.join(__dirname, '../../templates/skills/webspresso-usage/SKILL.md'),
35
+ templateDir: path.join(__dirname, '../../templates/skills/webspresso-usage'),
36
36
  },
37
37
  };
38
38
 
@@ -76,7 +76,10 @@ function registerCommand(program) {
76
76
  .option('-g, --global', 'Write to ~/.cursor/skills/ instead of ./.cursor/skills/')
77
77
  .option('-d, --description <text>', 'Skill description (for YAML frontmatter)')
78
78
  .option('-f, --force', 'Overwrite existing SKILL.md')
79
- .option('-p, --preset <name>', 'Install bundled skill: webspresso → full Webspresso agent reference (SKILL.md)')
79
+ .option(
80
+ '-p, --preset <name>',
81
+ 'Install bundled skill: webspresso → agent reference (SKILL.md + REFERENCE-*.md in .cursor/skills/webspresso-usage/)',
82
+ )
80
83
  .action(async (nameArg, options) => {
81
84
  const presetKey = options.preset ? String(options.preset).trim().toLowerCase() : '';
82
85
 
@@ -86,8 +89,16 @@ function registerCommand(program) {
86
89
  console.error(`❌ Unknown preset "${presetKey}". Available: ${Object.keys(PRESETS).join(', ')}`);
87
90
  process.exit(1);
88
91
  }
89
- if (!fs.existsSync(preset.templatePath)) {
90
- console.error(`❌ Preset template missing: ${preset.templatePath}`);
92
+ const templateDir = preset.templateDir;
93
+ if (!templateDir || !fs.existsSync(templateDir)) {
94
+ console.error(`❌ Preset template directory missing: ${templateDir}`);
95
+ process.exit(1);
96
+ }
97
+ const mdFiles = fs
98
+ .readdirSync(templateDir)
99
+ .filter((f) => f.endsWith('.md'));
100
+ if (mdFiles.length === 0) {
101
+ console.error(`❌ No .md files in preset: ${templateDir}`);
91
102
  process.exit(1);
92
103
  }
93
104
  const root = options.global ? os.homedir() : process.cwd();
@@ -101,10 +112,13 @@ function registerCommand(program) {
101
112
  }
102
113
 
103
114
  fs.mkdirSync(dir, { recursive: true });
104
- fs.copyFileSync(preset.templatePath, skillFile);
115
+ for (const file of mdFiles) {
116
+ fs.copyFileSync(path.join(templateDir, file), path.join(dir, file));
117
+ }
105
118
 
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');
119
+ console.log(`\n✅ Installed bundled skill "${presetKey}" →\n ${dir}/\n`);
120
+ console.log(` Files: ${mdFiles.join(', ')}\n`);
121
+ console.log(' Edit if needed, then restart Cursor or reload the window.\n');
108
122
  return;
109
123
  }
110
124