webspresso 0.0.73 → 0.0.75

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 (65) hide show
  1. package/README.md +44 -4
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/commands/upgrade.js +146 -0
  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 +4 -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/app.js +109 -0
  28. package/plugins/admin-panel/client/README.md +39 -0
  29. package/plugins/admin-panel/client/load-parts.js +74 -0
  30. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  32. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  33. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  34. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  35. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  36. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  37. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  38. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  39. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  40. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  41. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  42. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  43. package/plugins/admin-panel/components.js +4 -2640
  44. package/plugins/admin-panel/core/api-extensions.js +100 -10
  45. package/plugins/admin-panel/index.js +3 -0
  46. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  47. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  48. package/plugins/admin-panel/modules/dashboard.js +17 -13
  49. package/plugins/admin-panel/modules/user-management.js +118 -27
  50. package/plugins/data-exchange/export-xlsx.js +3 -0
  51. package/plugins/data-exchange/record-selection.js +21 -5
  52. package/plugins/index.js +4 -0
  53. package/plugins/rate-limit/index.js +178 -0
  54. package/plugins/redirect/index.js +204 -0
  55. package/plugins/rest-resources/index.js +2 -1
  56. package/plugins/site-analytics/admin-component.js +88 -78
  57. package/plugins/swagger.js +2 -1
  58. package/plugins/upload/local-file-provider.js +6 -2
  59. package/src/file-router.js +270 -53
  60. package/src/njk-frontmatter.js +156 -0
  61. package/src/plugin-manager.js +4 -2
  62. package/src/server.js +28 -9
  63. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  64. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  65. package/templates/skills/webspresso-usage/SKILL.md +29 -275
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
@@ -764,10 +801,12 @@ const { app } = createApp({
764
801
  Options:
765
802
  - `db` (required) - Database instance
766
803
  - `path` - Admin panel path (default: `/_admin`)
767
- - `auth` - Auth manager for user management
768
- - `userManagement` - User management config (`enabled`, `model`, `fields`)
804
+ - `auth` - Same **`AuthManager`** instance as **`createApp({ auth })`** when you use **`userManagement`** — enables **Active Sessions** / revoke APIs if **`rememberTokens`** (remember-me) is configured; optional for user CRUD-only
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
 
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 })`**.
809
+
771
810
  **Custom Admin Pages (registerModule):**
772
811
 
773
812
  Plugins can add custom admin pages using `registerModule` in `onRoutesReady`:
@@ -2208,8 +2247,9 @@ npm run test:watch
2208
2247
  # Run tests with coverage
2209
2248
  npm run test:coverage
2210
2249
 
2211
- # Micro-benchmarks (Vitest bench; also runs in CI on the test matrix)
2250
+ # Micro-benchmarks (Vitest bench; CI: main push uploads benchmark-baseline artifact, PRs compare against it)
2212
2251
  npm run bench
2252
+ # Local baseline + compare: npm run bench:ci:local
2213
2253
  ```
2214
2254
 
2215
2255
  ## License
@@ -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
 
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Upgrade Command — bump the webspresso dependency in the current project
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { execSync } = require('child_process');
8
+
9
+ const PKG_NAME = 'webspresso';
10
+
11
+ /**
12
+ * @param {string} cwd
13
+ * @returns {'npm'|'pnpm'|'yarn'}
14
+ */
15
+ function detectPackageManager(cwd) {
16
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
17
+ if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn';
18
+ return 'npm';
19
+ }
20
+
21
+ /**
22
+ * @param {object} pkg
23
+ * @returns {{ specifier: string, saveDev: boolean } | null}
24
+ */
25
+ function findWebspressoDep(pkg) {
26
+ if (!pkg || typeof pkg !== 'object') return null;
27
+ const prod = pkg.dependencies && pkg.dependencies[PKG_NAME];
28
+ const dev = pkg.devDependencies && pkg.devDependencies[PKG_NAME];
29
+ if (prod) return { specifier: String(prod), saveDev: false };
30
+ if (dev) return { specifier: String(dev), saveDev: true };
31
+ return null;
32
+ }
33
+
34
+ function isNonRegistrySpecifier(spec) {
35
+ return /^(file:|link:|workspace:|git\+|github:|http:|https:)/i.test(spec.trim());
36
+ }
37
+
38
+ /**
39
+ * @param {'npm'|'pnpm'|'yarn'} pm
40
+ * @param {string} tag
41
+ * @param {boolean} saveDev
42
+ * @returns {string[]}
43
+ */
44
+ function buildInstallArgs(pm, tag, saveDev) {
45
+ const spec = `${PKG_NAME}@${tag}`;
46
+ if (pm === 'npm') {
47
+ return saveDev
48
+ ? ['install', spec, '--save-dev']
49
+ : ['install', spec, '--save'];
50
+ }
51
+ if (pm === 'pnpm') {
52
+ return saveDev ? ['add', '-D', spec] : ['add', spec];
53
+ }
54
+ /* yarn */
55
+ return saveDev ? ['add', '-D', spec] : ['add', spec];
56
+ }
57
+
58
+ function registerCommand(program) {
59
+ program
60
+ .command('upgrade')
61
+ .description(
62
+ 'Upgrade the webspresso package in the current project (npm/pnpm/yarn)'
63
+ )
64
+ .option(
65
+ '-t, --tag <dist-tag>',
66
+ 'npm dist-tag (e.g. latest, next)',
67
+ 'latest'
68
+ )
69
+ .option('--pm <manager>', 'Force package manager: npm, pnpm, or yarn')
70
+ .option('--dry-run', 'Print the install command without running it')
71
+ .action((options) => {
72
+ const cwd = process.cwd();
73
+ const pkgPath = path.join(cwd, 'package.json');
74
+
75
+ if (!fs.existsSync(pkgPath)) {
76
+ console.error('❌ package.json not found. Run this from your project root.');
77
+ process.exit(1);
78
+ }
79
+
80
+ let pkg;
81
+ try {
82
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
83
+ } catch (e) {
84
+ console.error(`❌ Could not read package.json: ${e.message}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ const found = findWebspressoDep(pkg);
89
+ if (!found) {
90
+ console.error(
91
+ `❌ No "${PKG_NAME}" entry in dependencies or devDependencies.`
92
+ );
93
+ console.error(' Add it first, e.g. npm install webspresso');
94
+ process.exit(1);
95
+ }
96
+
97
+ if (isNonRegistrySpecifier(found.specifier)) {
98
+ console.error(
99
+ `❌ "${PKG_NAME}" is not a registry semver range (current: ${found.specifier}).`
100
+ );
101
+ console.error(
102
+ ' For file:/link:/workspace: installs, change package.json manually or point to a published version.'
103
+ );
104
+ process.exit(1);
105
+ }
106
+
107
+ let pm = options.pm;
108
+ if (pm) {
109
+ pm = String(pm).toLowerCase();
110
+ if (!['npm', 'pnpm', 'yarn'].includes(pm)) {
111
+ console.error('❌ --pm must be npm, pnpm, or yarn');
112
+ process.exit(1);
113
+ }
114
+ } else {
115
+ pm = detectPackageManager(cwd);
116
+ }
117
+
118
+ const args = buildInstallArgs(pm, options.tag, found.saveDev);
119
+ const cmd = `${pm} ${args.join(' ')}`;
120
+
121
+ console.log('\nWebspresso upgrade');
122
+ console.log('==================\n');
123
+ console.log(` Package manager: ${pm}`);
124
+ console.log(` Target: ${PKG_NAME}@${options.tag}`);
125
+ console.log(` Save as: ${found.saveDev ? 'devDependency' : 'dependency'}`);
126
+ console.log(` Current range: ${found.specifier}\n`);
127
+
128
+ if (options.dryRun) {
129
+ console.log(`Dry run — would run:\n ${cmd}\n`);
130
+ return;
131
+ }
132
+
133
+ try {
134
+ execSync(cmd, { stdio: 'inherit', cwd, shell: true });
135
+ console.log(
136
+ '\n✅ Upgrade finished. If you use native addons (better-sqlite3, bcrypt, sharp), run:\n' +
137
+ ' npm run rebuild:native\n' +
138
+ ' (or: npm rebuild better-sqlite3 bcrypt sharp)\n'
139
+ );
140
+ } catch {
141
+ process.exit(1);
142
+ }
143
+ });
144
+ }
145
+
146
+ module.exports = { registerCommand, detectPackageManager, findWebspressoDep };