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 +58 -0
- package/bin/commands/doctor.js +23 -0
- package/bin/commands/new.js +253 -79
- package/core/orm/migrations/scaffold.js +6 -0
- package/core/orm/schema-helpers.js +19 -0
- package/core/orm/types.js +1 -1
- package/index.d.ts +34 -1
- package/index.js +15 -1
- package/package.json +6 -1
- package/plugins/admin-panel/api.js +5 -5
- package/plugins/admin-panel/components.js +184 -0
- package/plugins/admin-panel/core/registry.js +1 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +135 -66
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/admin-panel/index.js +8 -0
- package/plugins/index.js +3 -0
- package/plugins/upload/index.js +188 -0
- package/plugins/upload/local-file-provider.js +122 -0
- package/src/file-router.js +95 -34
- package/templates/skills/webspresso-usage/SKILL.md +62 -22
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
|
package/bin/commands/doctor.js
CHANGED
|
@@ -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
|
|
package/bin/commands/new.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
107
|
-
projectPath = path.resolve(
|
|
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 ${
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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: '
|
|
147
|
-
message: '
|
|
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
|
-
|
|
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
|
-
//
|
|
207
|
-
const
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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):
|
|
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;
|