webspresso 0.0.67 → 0.0.69

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 CHANGED
@@ -129,6 +129,17 @@ If you select a database:
129
129
  - `models/` directory is created
130
130
  - `DATABASE_URL` is added to `.env.example` with a template
131
131
 
132
+ **Scaffold: `config/` and environment files**
133
+
134
+ New projects include:
135
+
136
+ - **`config/load-env.js`** — loads, in order, `.env`, `.env.local`, `.env.${NODE_ENV}`, and `.env.${NODE_ENV}.local` (each file overrides keys from earlier ones).
137
+ - **`config/env.schema.js`** — validates `process.env` with **Zod** before the app starts (`PORT`, `NODE_ENV`, i18n vars, `BASE_URL`, optional `DATABASE_URL`).
138
+ - **`config/app.js`** — returns `createApp()` options (paths; if `webspresso.db.js` exists, also **`createDatabase`** as `db`).
139
+ - **`server.js`** — calls `loadEnv()`, then `createApp(getCreateAppOptions())`, then `listen` using the parsed `PORT`.
140
+
141
+ Copy **`.env.example`** to **`.env`** (and use `.env.local` for machine-specific overrides). Patterns such as `.env.development.local` are gitignored via `.env.*.local`.
142
+
132
143
  **Seed Data Generation:**
133
144
  After selecting a database, you'll be asked if you want to generate seed data:
134
145
  - If yes, `@faker-js/faker` is added to dependencies
@@ -2041,6 +2052,45 @@ const { app } = createApp({
2041
2052
 
2042
2053
  List query parameters: `page`, `perPage`, `sort`, `order`, `include`, `trashed` (`only` / `include` when the model uses soft delete), plus **equality filters** on real columns (unknown keys are ignored).
2043
2054
 
2055
+ ### File upload plugin
2056
+
2057
+ Registers **POST** `multipart/form-data` (field name **`file`** by default) and stores the file via a pluggable provider. The framework ships with **`createLocalFileProvider({ destDir, publicBasePath })`** (writes to disk and returns a public URL path). Use **`mimeAllowlist`** / **`extensionAllowlist`** and **`maxBytes`** (default **10 MiB**) on the server; production apps should prefer an explicit MIME allowlist.
2058
+
2059
+ **Setup:**
2060
+
2061
+ ```javascript
2062
+ const { createApp, uploadPlugin, adminPanelPlugin } = require('webspresso');
2063
+
2064
+ const { app } = createApp({
2065
+ pagesDir: './pages',
2066
+ publicDir: './public',
2067
+ db,
2068
+ plugins: [
2069
+ uploadPlugin({
2070
+ path: '/api/upload',
2071
+ local: {
2072
+ destDir: './public/uploads',
2073
+ publicBasePath: '/uploads',
2074
+ },
2075
+ maxBytes: 10 * 1024 * 1024,
2076
+ mimeAllowlist: ['image/jpeg', 'image/png', 'application/pdf'],
2077
+ extensionAllowlist: ['jpg', 'jpeg', 'png', 'pdf'],
2078
+ middleware: [], // optional Express handlers (e.g. session auth)
2079
+ fieldName: 'file',
2080
+ }),
2081
+ adminPanelPlugin({
2082
+ db,
2083
+ // uploadUrl optional: defaults to app.get('webspresso.uploadPath') set by uploadPlugin
2084
+ }),
2085
+ ],
2086
+ });
2087
+ ```
2088
+
2089
+ - **ORM:** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
2090
+ - **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.
2091
+ - **Response:** `{ url, publicUrl, key? }` — clients typically persist **`url`** / **`publicUrl`** in the model.
2092
+ - **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
2093
+
2044
2094
  ### Health check plugin
2045
2095
 
2046
2096
  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.
@@ -2101,10 +2151,15 @@ In production, keep the plugin disabled or protect it with `authorize` / your ow
2101
2151
 
2102
2152
  ## Development
2103
2153
 
2154
+ Native addons (**better-sqlite3**, **bcrypt**, **sharp**) are compiled for your current Node ABI. After switching Node major versions (e.g. nvm, fnm, Volta), run **`npm run rebuild:native`** or a clean install: `rm -rf node_modules && npm ci`. **chokidar** is not ABI-tied like those drivers; if file watching misbehaves, reinstall dependencies. The repo includes [`.nvmrc`](.nvmrc) (Node 20 LTS) as a known-good default for this project.
2155
+
2104
2156
  ```bash
2105
2157
  # Install dependencies
2106
2158
  npm install
2107
2159
 
2160
+ # If you changed Node version and see MODULE_VERSION or .node load errors:
2161
+ npm run rebuild:native
2162
+
2108
2163
  # Run tests
2109
2164
  npm test
2110
2165
 
@@ -2113,6 +2168,9 @@ npm run test:watch
2113
2168
 
2114
2169
  # Run tests with coverage
2115
2170
  npm run test:coverage
2171
+
2172
+ # Micro-benchmarks (Vitest bench; also runs in CI on the test matrix)
2173
+ npm run bench
2116
2174
  ```
2117
2175
 
2118
2176
  ## License
@@ -107,6 +107,29 @@ function registerCommand(program) {
107
107
  warnings += 1;
108
108
  }
109
109
 
110
+ console.log('\nEnvironment files');
111
+ console.log('-----------------');
112
+
113
+ const envExamplePath = path.join(cwd, '.env.example');
114
+ const envPath = path.join(cwd, '.env');
115
+ if (fs.existsSync(envExamplePath)) {
116
+ line('✓', '.env.example present');
117
+ } else {
118
+ line('○', 'No .env.example (optional; new scaffolds include one)');
119
+ }
120
+ if (fs.existsSync(envPath)) {
121
+ line('✓', '.env present');
122
+ } else if (fs.existsSync(envExamplePath)) {
123
+ line('⚠', 'No .env — copy .env.example to .env if the app expects env vars');
124
+ warnings += 1;
125
+ } else {
126
+ line('○', 'No .env (fine if you do not use dotenv in this project)');
127
+ }
128
+ const loadEnvPath = path.join(cwd, 'config', 'load-env.js');
129
+ if (fs.existsSync(loadEnvPath)) {
130
+ line('✓', 'config/load-env.js present (dotenv chain scaffold)');
131
+ }
132
+
110
133
  console.log('\nDatabase config');
111
134
  console.log('---------------');
112
135
 
@@ -9,6 +9,30 @@ const path = require('path');
9
9
  const { runInstallation, startDevServer } = require('../utils/project');
10
10
  const { getSeedFileTemplate } = require('../utils/seed');
11
11
 
12
+ /** `.` / `./` mean scaffold into the current working directory */
13
+ function isCurrentDirAlias(name) {
14
+ if (name == null || typeof name !== 'string') return false;
15
+ const t = name.trim();
16
+ return t === '.' || t === './';
17
+ }
18
+
19
+ /** package.json `name` from a directory path (must match interactive validator rules) */
20
+ function derivePackageNameFromDir(dirPath) {
21
+ const base = path.basename(path.resolve(dirPath));
22
+ if (/^[a-z0-9-_]+$/i.test(base)) return base;
23
+ return 'webspresso-app';
24
+ }
25
+
26
+ function assertNotExistingWebspressoProject(projectPath) {
27
+ if (
28
+ fs.existsSync(path.join(projectPath, 'server.js')) ||
29
+ fs.existsSync(path.join(projectPath, 'pages'))
30
+ ) {
31
+ console.error('❌ Target directory already contains a Webspresso project (server.js or pages/).');
32
+ process.exit(1);
33
+ }
34
+ }
35
+
12
36
  function registerCommand(program) {
13
37
  program
14
38
  .command('new [project-name]')
@@ -16,9 +40,12 @@ function registerCommand(program) {
16
40
  .option('-t, --template <template>', 'Template to use (minimal, full)', 'minimal')
17
41
  .option('--no-tailwind', 'Skip Tailwind CSS setup')
18
42
  .option('-i, --install', 'Auto install dependencies and build CSS')
43
+ .option('-y, --yes', 'Non-interactive: no database, skip install unless -i/--install, skip dev server')
19
44
  .action(async (projectNameArg, options) => {
20
45
  const useTailwind = options.tailwind !== false;
21
46
  const autoInstall = options.install === true;
47
+ const skipPrompts = options.yes === true;
48
+ const stdinNotTty = !process.stdin.isTTY;
22
49
 
23
50
  let projectName;
24
51
  let projectPath;
@@ -43,27 +70,27 @@ function registerCommand(program) {
43
70
  useCurrentDir = true;
44
71
  projectPath = process.cwd();
45
72
 
46
- // Check for existing Webspresso files
47
- if (fs.existsSync(path.join(projectPath, 'server.js')) ||
48
- fs.existsSync(path.join(projectPath, 'pages'))) {
49
- console.error('❌ Current directory already contains a Webspresso project!');
50
- process.exit(1);
51
- }
73
+ assertNotExistingWebspressoProject(projectPath);
52
74
 
53
75
  // Warn if there are existing files
54
76
  if (hasExistingFiles) {
55
- const { continueAnyway } = await inquirer.prompt([
56
- {
57
- type: 'confirm',
58
- name: 'continueAnyway',
59
- message: '⚠️ Current directory is not empty. Continue anyway?',
60
- default: false
77
+ const autoProceed = skipPrompts || stdinNotTty;
78
+ if (autoProceed) {
79
+ console.log('ℹ️ Current directory is not empty; scaffolding alongside existing files.');
80
+ } else {
81
+ const { continueAnyway } = await inquirer.prompt([
82
+ {
83
+ type: 'confirm',
84
+ name: 'continueAnyway',
85
+ message: '⚠️ Current directory is not empty. Continue anyway?',
86
+ default: true
87
+ }
88
+ ]);
89
+
90
+ if (!continueAnyway) {
91
+ console.log('Cancelled.');
92
+ process.exit(0);
61
93
  }
62
- ]);
63
-
64
- if (!continueAnyway) {
65
- console.log('Cancelled.');
66
- process.exit(0);
67
94
  }
68
95
  }
69
96
 
@@ -102,53 +129,92 @@ function registerCommand(program) {
102
129
  projectName = dirName;
103
130
  projectPath = path.resolve(dirName);
104
131
  }
132
+ } else if (isCurrentDirAlias(projectNameArg)) {
133
+ useCurrentDir = true;
134
+ projectPath = process.cwd();
135
+ projectName = derivePackageNameFromDir(projectPath);
136
+
137
+ assertNotExistingWebspressoProject(projectPath);
138
+
139
+ const currentDirFiles = fs.readdirSync(projectPath);
140
+ const hasExistingFiles = currentDirFiles.some((f) => !f.startsWith('.'));
141
+ if (hasExistingFiles) {
142
+ const autoProceed = skipPrompts || stdinNotTty;
143
+ if (autoProceed) {
144
+ console.log('ℹ️ Current directory is not empty; scaffolding alongside existing files.');
145
+ } else {
146
+ const { continueAnyway } = await inquirer.prompt([
147
+ {
148
+ type: 'confirm',
149
+ name: 'continueAnyway',
150
+ message: '⚠️ Current directory is not empty. Continue anyway?',
151
+ default: true
152
+ }
153
+ ]);
154
+
155
+ if (!continueAnyway) {
156
+ console.log('Cancelled.');
157
+ process.exit(0);
158
+ }
159
+ }
160
+ }
105
161
  } else {
106
- projectName = projectNameArg;
107
- projectPath = path.resolve(projectNameArg);
162
+ const trimmed = projectNameArg.trim();
163
+ projectPath = path.resolve(trimmed);
164
+ projectName = derivePackageNameFromDir(projectPath);
108
165
 
109
166
  if (fs.existsSync(projectPath)) {
110
- console.error(`❌ Directory ${projectName} already exists!`);
167
+ console.error(`❌ Directory ${trimmed} already exists!`);
111
168
  process.exit(1);
112
169
  }
113
170
  }
114
171
 
115
172
  console.log(`\n🚀 Creating new Webspresso project: ${projectName}\n`);
116
173
 
117
- // Ask about database
118
- const { useDatabase, databaseType } = await inquirer.prompt([
119
- {
120
- type: 'confirm',
121
- name: 'useDatabase',
122
- message: 'Will you use a database?',
123
- default: false
124
- },
125
- {
126
- type: 'list',
127
- name: 'databaseType',
128
- message: 'Which database?',
129
- choices: [
130
- { name: 'SQLite (better-sqlite3)', value: 'better-sqlite3' },
131
- { name: 'PostgreSQL (pg)', value: 'pg' },
132
- { name: 'MySQL (mysql2)', value: 'mysql2' },
133
- { name: 'None', value: null }
134
- ],
135
- default: 'better-sqlite3',
136
- when: (answers) => answers.useDatabase
137
- }
138
- ]);
139
-
140
- // Ask about seed data if database is selected
174
+ let useDatabase = false;
175
+ let databaseType = null;
141
176
  let useSeed = false;
142
- if (useDatabase && databaseType) {
143
- const { generateSeed } = await inquirer.prompt([
177
+
178
+ if (skipPrompts) {
179
+ useDatabase = false;
180
+ databaseType = null;
181
+ useSeed = false;
182
+ } else {
183
+ const dbAnswers = await inquirer.prompt([
144
184
  {
145
185
  type: 'confirm',
146
- name: 'generateSeed',
147
- message: 'Generate seed data based on existing models?',
186
+ name: 'useDatabase',
187
+ message: 'Will you use a database?',
148
188
  default: false
189
+ },
190
+ {
191
+ type: 'list',
192
+ name: 'databaseType',
193
+ message: 'Which database?',
194
+ choices: [
195
+ { name: 'SQLite (better-sqlite3)', value: 'better-sqlite3' },
196
+ { name: 'PostgreSQL (pg)', value: 'pg' },
197
+ { name: 'MySQL (mysql2)', value: 'mysql2' },
198
+ { name: 'None', value: null }
199
+ ],
200
+ default: 'better-sqlite3',
201
+ when: (answers) => answers.useDatabase
149
202
  }
150
203
  ]);
151
- useSeed = generateSeed;
204
+ useDatabase = dbAnswers.useDatabase;
205
+ databaseType = dbAnswers.databaseType;
206
+
207
+ if (useDatabase && databaseType) {
208
+ const { generateSeed } = await inquirer.prompt([
209
+ {
210
+ type: 'confirm',
211
+ name: 'generateSeed',
212
+ message: 'Generate seed data based on existing models?',
213
+ default: false
214
+ }
215
+ ]);
216
+ useSeed = generateSeed;
217
+ }
152
218
  }
153
219
 
154
220
  // Create directory structure (skip root if using current dir)
@@ -159,6 +225,7 @@ function registerCommand(program) {
159
225
  fs.mkdirSync(path.join(projectPath, 'pages', 'locales'), { recursive: true });
160
226
  fs.mkdirSync(path.join(projectPath, 'views'), { recursive: true });
161
227
  fs.mkdirSync(path.join(projectPath, 'public'), { recursive: true });
228
+ fs.mkdirSync(path.join(projectPath, 'config'), { recursive: true });
162
229
 
163
230
  // Create models directory if database is selected
164
231
  if (useDatabase && databaseType) {
@@ -177,7 +244,8 @@ function registerCommand(program) {
177
244
  },
178
245
  dependencies: {
179
246
  webspresso: '*',
180
- dotenv: '^16.3.1'
247
+ dotenv: '^16.3.1',
248
+ zod: '^3.23.0'
181
249
  }
182
250
  };
183
251
 
@@ -203,28 +271,108 @@ function registerCommand(program) {
203
271
  JSON.stringify(packageJson, null, 2) + '\n'
204
272
  );
205
273
 
206
- // Create server.js
207
- const serverJs = `require('dotenv').config();
208
- const { createApp } = require('webspresso');
274
+ // config/load-env.js — dotenv chain (last file wins for each key)
275
+ const loadEnvJs = `const fs = require('fs');
209
276
  const path = require('path');
277
+ const dotenv = require('dotenv');
278
+
279
+ /**
280
+ * Load env files in order: .env, .env.local, .env.<NODE_ENV>, .env.<NODE_ENV>.local
281
+ * @param {string} [rootDir] Project root (default: parent of config/)
282
+ */
283
+ function loadEnv(rootDir) {
284
+ const root = rootDir || path.resolve(__dirname, '..');
285
+ const loadFile = (name) => {
286
+ const full = path.join(root, name);
287
+ if (fs.existsSync(full)) {
288
+ dotenv.config({ path: full, override: true });
289
+ }
290
+ };
291
+ loadFile('.env');
292
+ loadFile('.env.local');
293
+ const mode = process.env.NODE_ENV || 'development';
294
+ loadFile(\`.env.\${mode}\`);
295
+ loadFile(\`.env.\${mode}.local\`);
296
+ }
297
+
298
+ module.exports = { loadEnv };
299
+ `;
300
+
301
+ const envSchemaJs = `const { z } = require('zod');
210
302
 
211
- const { app } = createApp({
212
- pagesDir: path.join(__dirname, 'pages'),
213
- viewsDir: path.join(__dirname, 'views'),
214
- publicDir: path.join(__dirname, 'public')
303
+ const envSchema = z.object({
304
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
305
+ PORT: z.coerce.number().int().positive().default(3000),
306
+ DEFAULT_LOCALE: z.string().min(1).default('en'),
307
+ SUPPORTED_LOCALES: z.string().min(1).default('en,de'),
308
+ BASE_URL: z.string().url().default('http://localhost:3000'),
309
+ DATABASE_URL: z.string().optional(),
215
310
  });
216
311
 
217
- const PORT = process.env.PORT || 3000;
312
+ let _parsed = null;
313
+
314
+ function parseEnv() {
315
+ if (_parsed) return _parsed;
316
+ const result = envSchema.safeParse(process.env);
317
+ if (!result.success) {
318
+ console.error('Invalid environment variables:');
319
+ console.error(result.error.format());
320
+ process.exit(1);
321
+ }
322
+ _parsed = result.data;
323
+ return _parsed;
324
+ }
325
+
326
+ module.exports = { envSchema, parseEnv };
327
+ `;
328
+
329
+ const appConfigJs = `const path = require('path');
330
+ const fs = require('fs');
331
+ const { parseEnv } = require('./env.schema');
332
+
333
+ function getCreateAppOptions() {
334
+ parseEnv();
335
+ const rootDir = path.resolve(__dirname, '..');
336
+ const options = {
337
+ pagesDir: path.join(rootDir, 'pages'),
338
+ viewsDir: path.join(rootDir, 'views'),
339
+ publicDir: path.join(rootDir, 'public'),
340
+ };
341
+ const dbFile = path.join(rootDir, 'webspresso.db.js');
342
+ if (fs.existsSync(dbFile)) {
343
+ const { createDatabase } = require('webspresso');
344
+ const knexConfig = require(dbFile);
345
+ options.db = createDatabase(knexConfig);
346
+ }
347
+ return options;
348
+ }
349
+
350
+ module.exports = getCreateAppOptions;
351
+ `;
352
+
353
+ const serverJs = `const { loadEnv } = require('./config/load-env');
354
+ loadEnv();
355
+
356
+ const { createApp } = require('webspresso');
357
+ const getCreateAppOptions = require('./config/app');
358
+ const { parseEnv } = require('./config/env.schema');
359
+
360
+ const env = parseEnv();
361
+ const { app } = createApp(getCreateAppOptions());
218
362
 
219
- app.listen(PORT, () => {
220
- console.log(\`🚀 Server running at http://localhost:\${PORT}\`);
363
+ app.listen(env.PORT, () => {
364
+ console.log(\`🚀 Server running at http://localhost:\${env.PORT}\`);
221
365
  });
222
366
  `;
223
-
367
+
368
+ fs.writeFileSync(path.join(projectPath, 'config', 'load-env.js'), loadEnvJs);
369
+ fs.writeFileSync(path.join(projectPath, 'config', 'env.schema.js'), envSchemaJs);
370
+ fs.writeFileSync(path.join(projectPath, 'config', 'app.js'), appConfigJs);
224
371
  fs.writeFileSync(path.join(projectPath, 'server.js'), serverJs);
225
372
 
226
- // Create .env.example
227
- let envExample = `PORT=3000
373
+ // Create .env.example (see config/load-env.js for merge order)
374
+ let envExample = `# Copy to .env and adjust. Optional overrides: .env.local, .env.<NODE_ENV>, .env.<NODE_ENV>.local
375
+ PORT=3000
228
376
  NODE_ENV=development
229
377
  DEFAULT_LOCALE=en
230
378
  SUPPORTED_LOCALES=en,de
@@ -308,10 +456,11 @@ BASE_URL=http://localhost:3000
308
456
  fs.writeFileSync(path.join(projectPath, 'seeds', 'index.js'), seedIndex);
309
457
  }
310
458
 
311
- // Create .gitignore
459
+ // Create .gitignore (.env optional: teams often commit a non-secret .env for local defaults)
312
460
  const gitignore = `node_modules/
313
461
  .env
314
462
  .env.local
463
+ .env.*.local
315
464
  .DS_Store
316
465
  coverage/
317
466
  *.log
@@ -482,10 +631,18 @@ Webspresso project
482
631
 
483
632
  \`\`\`bash
484
633
  npm install
634
+ cp .env.example .env
485
635
  npm run dev
486
636
  \`\`\`
487
637
 
488
638
  Visit http://localhost:3000
639
+
640
+ ## Configuration
641
+
642
+ - **\`config/load-env.js\`** — loads \`.env\`, \`.env.local\`, then \`.env.$NODE_ENV\` and \`.env.$NODE_ENV.local\` (later files override keys).
643
+ - **\`config/env.schema.js\`** — [Zod](https://zod.dev) schema for \`process.env\`; fails fast on invalid values.
644
+ - **\`config/app.js\`** — builds options passed to \`createApp()\` (paths; adds \`db\` when \`webspresso.db.js\` exists).
645
+ - **\`server.js\`** — calls \`loadEnv()\`, then \`createApp(getCreateAppOptions())\`.
489
646
  `;
490
647
 
491
648
  fs.writeFileSync(path.join(projectPath, 'README.md'), readme);
@@ -561,28 +718,46 @@ module.exports = {
561
718
  if (autoInstall) {
562
719
  await runInstallation(projectPath, useTailwind);
563
720
 
564
- // Ask if user wants to start dev server
565
- const { shouldStartDev } = await inquirer.prompt([
566
- {
567
- type: 'confirm',
568
- name: 'shouldStartDev',
569
- message: 'Start development server?',
570
- default: true
571
- }
572
- ]);
573
-
574
- if (shouldStartDev) {
575
- startDevServer(projectPath, useTailwind);
576
- } else {
721
+ if (skipPrompts) {
577
722
  console.log('✅ Project ready!\n');
578
723
  console.log('Start developing:');
579
724
  if (!useCurrentDir) {
580
725
  console.log(` cd ${projectName}`);
581
726
  }
582
727
  console.log(' npm run dev\n');
728
+ } else {
729
+ const { shouldStartDev } = await inquirer.prompt([
730
+ {
731
+ type: 'confirm',
732
+ name: 'shouldStartDev',
733
+ message: 'Start development server?',
734
+ default: true
735
+ }
736
+ ]);
737
+
738
+ if (shouldStartDev) {
739
+ startDevServer(projectPath, useTailwind);
740
+ } else {
741
+ console.log('✅ Project ready!\n');
742
+ console.log('Start developing:');
743
+ if (!useCurrentDir) {
744
+ console.log(` cd ${projectName}`);
745
+ }
746
+ console.log(' npm run dev\n');
747
+ }
748
+ }
749
+ } else if (skipPrompts) {
750
+ console.log('\n✅ Project created successfully!\n');
751
+ console.log('Next steps:');
752
+ if (!useCurrentDir) {
753
+ console.log(` cd ${projectName}`);
754
+ }
755
+ console.log(' npm install');
756
+ if (useTailwind) {
757
+ console.log(' npm run build:css');
583
758
  }
759
+ console.log(' npm run dev\n');
584
760
  } else {
585
- // Ask if user wants to install dependencies
586
761
  const { shouldInstall } = await inquirer.prompt([
587
762
  {
588
763
  type: 'confirm',
@@ -595,7 +770,6 @@ module.exports = {
595
770
  if (shouldInstall) {
596
771
  await runInstallation(projectPath, useTailwind);
597
772
 
598
- // Ask if user wants to start dev server
599
773
  const { shouldStartDev } = await inquirer.prompt([
600
774
  {
601
775
  type: 'confirm',
@@ -112,6 +112,12 @@ function generateColumnLine(columnName, meta) {
112
112
  parts.push(`table.text('${columnName}')`);
113
113
  break;
114
114
 
115
+ case 'file': {
116
+ const fileLen = meta.maxLength || 2048;
117
+ parts.push(`table.string('${columnName}', ${fileLen})`);
118
+ break;
119
+ }
120
+
115
121
  case 'float':
116
122
  parts.push(`table.float('${columnName}')`);
117
123
  break;
@@ -420,6 +420,25 @@ function createSchemaHelpers(z) {
420
420
  }, z);
421
421
  },
422
422
 
423
+ /**
424
+ * File / URL column (stored as varchar; value is public URL or path from upload)
425
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
426
+ * @returns {SchemaBuilder}
427
+ */
428
+ file(options = {}) {
429
+ const { maxLength = 2048, nullable = false, ...rest } = options;
430
+ let schema = z.string().max(maxLength);
431
+ if (nullable) {
432
+ schema = schema.nullable().optional();
433
+ }
434
+ return createSchemaBuilder(schema, {
435
+ type: 'file',
436
+ maxLength,
437
+ nullable,
438
+ ...rest,
439
+ }, z);
440
+ },
441
+
423
442
  /**
424
443
  * Integer column
425
444
  * @param {Partial<import('./types').ColumnMeta>} [options={}]
package/core/orm/types.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // ============================================================================
10
10
 
11
11
  /**
12
- * @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
12
+ * @typedef {'id'|'string'|'text'|'file'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
13
13
  */
14
14
 
15
15
  /**
package/index.d.ts CHANGED
@@ -94,7 +94,10 @@ export function mountPages(
94
94
 
95
95
  export function filePathToRoute(filePath: string, pagesDir: string): string;
96
96
 
97
- export function extractMethodFromFilename(filename: string): string | null;
97
+ export function extractMethodFromFilename(filename: string): {
98
+ method: string;
99
+ baseName: string;
100
+ };
98
101
 
99
102
  export function scanDirectory(
100
103
  dir: string,
@@ -531,3 +534,33 @@ export interface RestResourcePluginOptions {
531
534
  export function restResourcePlugin(options?: RestResourcePluginOptions): WebspressoPlugin;
532
535
 
533
536
  export function ormCacheAdminPlugin(options: { db: DatabaseInstance }): WebspressoPlugin;
537
+
538
+ /** Multipart upload storage (e.g. local disk or S3). */
539
+ export interface UploadStorageProvider {
540
+ put(args: {
541
+ buffer?: Buffer;
542
+ stream?: NodeJS.ReadableStream;
543
+ originalName: string;
544
+ mimeType: string;
545
+ size: number;
546
+ req: Request;
547
+ }): Promise<{ publicUrl: string; key?: string }>;
548
+ }
549
+
550
+ export interface UploadPluginOptions {
551
+ path?: string;
552
+ provider?: UploadStorageProvider;
553
+ local?: { destDir?: string; publicBasePath?: string };
554
+ maxBytes?: number;
555
+ mimeAllowlist?: string[] | null;
556
+ extensionAllowlist?: string[] | null;
557
+ middleware?: RequestHandler | RequestHandler[];
558
+ fieldName?: string;
559
+ }
560
+
561
+ export function uploadPlugin(options?: UploadPluginOptions): WebspressoPlugin;
562
+
563
+ export function createLocalFileProvider(options?: {
564
+ destDir?: string;
565
+ publicBasePath?: string;
566
+ }): UploadStorageProvider;