create-express-new 0.0.0-stage → 1.1.0
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/CHANGELOG.md +126 -0
- package/LICENSE +24 -0
- package/README.md +282 -2
- package/bin/express-cli.js +575 -0
- package/lib/app.js +607 -0
- package/lib/prompts.js +338 -0
- package/lib/wizard.js +398 -0
- package/package.json +65 -4
- package/templates/app/Dockerfile.ejs +64 -0
- package/templates/app/app.js.ejs +163 -0
- package/templates/app/csrf.js.ejs +21 -0
- package/templates/app/dockerignore +15 -0
- package/templates/app/env.example.ejs +40 -0
- package/templates/app/eslint.config.js.ejs +28 -0
- package/templates/app/gitignore +22 -0
- package/templates/app/npmrc +8 -0
- package/templates/app/pnpm-workspace.yaml +24 -0
- package/templates/app/routes/index.js.ejs +22 -0
- package/templates/app/routes/uploads.js.ejs +94 -0
- package/templates/app/routes/users.js.ejs +42 -0
- package/templates/app/test/app.test.js.ejs +199 -0
- package/templates/app/www.js.ejs +46 -0
- package/templates/public/index.html +13 -0
- package/templates/public/stylesheets/style.css +8 -0
- package/templates/versions.json +49 -0
- package/templates/views/error.ejs +12 -0
- package/templates/views/error.hbs +3 -0
- package/templates/views/error.pug +6 -0
- package/templates/views/error.twig +7 -0
- package/templates/views/index.ejs +11 -0
- package/templates/views/index.hbs +2 -0
- package/templates/views/index.pug +5 -0
- package/templates/views/index.twig +6 -0
- package/templates/views/layout.hbs +10 -0
- package/templates/views/layout.pug +7 -0
- package/templates/views/layout.twig +10 -0
- package/templates/views/new-user.ejs +18 -0
- package/templates/views/new-user.hbs +9 -0
- package/templates/views/new-user.pug +13 -0
- package/templates/views/new-user.twig +13 -0
- package/templates/views/uploads.ejs +16 -0
- package/templates/views/uploads.hbs +7 -0
- package/templates/views/uploads.pug +10 -0
- package/templates/views/uploads.twig +11 -0
package/lib/app.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// Plan the folders and files of a generated app without writing them, and
|
|
2
|
+
// check a destination directory against the plan, so existing files can be
|
|
3
|
+
// found, and asked about, before anything is written.
|
|
4
|
+
|
|
5
|
+
import ejs from 'ejs'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import util from 'node:util'
|
|
9
|
+
|
|
10
|
+
const MODE_0666 = 0o666
|
|
11
|
+
const MODE_0755 = 0o755
|
|
12
|
+
const TEMPLATE_DIR = path.join(import.meta.dirname, '..', 'templates')
|
|
13
|
+
|
|
14
|
+
// generated app dependency versions, checked by `npm run versions`
|
|
15
|
+
const VERSIONS = JSON.parse(fs.readFileSync(path.join(TEMPLATE_DIR, 'versions.json'), 'utf-8')).versions
|
|
16
|
+
|
|
17
|
+
// supported view engines, keyed by template file extension
|
|
18
|
+
export const VIEW_ENGINES = {
|
|
19
|
+
ejs: { pkg: 'ejs' },
|
|
20
|
+
hbs: { pkg: 'hbs' },
|
|
21
|
+
pug: { pkg: 'pug' },
|
|
22
|
+
twig: { pkg: 'twig' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// supported request loggers
|
|
26
|
+
export const LOGGERS = ['morgan', 'pino']
|
|
27
|
+
|
|
28
|
+
// supported package managers, with the versions a generated app accepts: the
|
|
29
|
+
// ones that block dependency install scripts by default and support the
|
|
30
|
+
// settings in the app's config file
|
|
31
|
+
export const PACKAGE_MANAGERS = {
|
|
32
|
+
pnpm: { version: '>=11', docs: 'https://pnpm.io/installation' },
|
|
33
|
+
npm: { version: '>=12', docs: 'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_PACKAGE_MANAGER = 'pnpm'
|
|
37
|
+
|
|
38
|
+
// views generated only with some options
|
|
39
|
+
const OPTIONAL_VIEWS = { 'new-user': 'csrf', uploads: 'uploads' }
|
|
40
|
+
|
|
41
|
+
// the hidden CSRF token field of each view engine's forms, in place of the
|
|
42
|
+
// `@csrf` lines in view templates
|
|
43
|
+
const CSRF_FIELD = {
|
|
44
|
+
ejs: '<input type="hidden" name="_csrf" value="<%= csrfToken %>">',
|
|
45
|
+
hbs: '<input type="hidden" name="_csrf" value="{{csrfToken}}">',
|
|
46
|
+
pug: "input(type='hidden', name='_csrf', value=csrfToken)",
|
|
47
|
+
twig: '<input type="hidden" name="_csrf" value="{{ csrfToken }}">'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// a line for the CSRF token in the <head> of each view engine's page layout,
|
|
51
|
+
// added after the title; ejs reads it from `locals`, as the error page can
|
|
52
|
+
// render before the token is set
|
|
53
|
+
const CSRF_META = {
|
|
54
|
+
ejs: '<meta name="csrf-token" content="<%= locals.csrfToken %>">',
|
|
55
|
+
hbs: '<meta name="csrf-token" content="{{csrfToken}}">',
|
|
56
|
+
pug: "meta(name='csrf-token', content=csrfToken)",
|
|
57
|
+
twig: '<meta name="csrf-token" content="{{ csrfToken }}">'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// tsconfig.json for TypeScript apps, which Node.js runs by stripping types
|
|
61
|
+
const TSCONFIG = {
|
|
62
|
+
compilerOptions: {
|
|
63
|
+
target: 'esnext',
|
|
64
|
+
module: 'nodenext',
|
|
65
|
+
strict: true,
|
|
66
|
+
noEmit: true,
|
|
67
|
+
allowImportingTsExtensions: true,
|
|
68
|
+
erasableSyntaxOnly: true,
|
|
69
|
+
verbatimModuleSyntax: true,
|
|
70
|
+
skipLibCheck: true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// files some other options generate, which an app with these options does
|
|
75
|
+
// not use: code in the other language, the other ESLint config, the pnpm
|
|
76
|
+
// settings and the templates of other view engines
|
|
77
|
+
const ALTERNATIVES = [
|
|
78
|
+
...['app', 'bin/www', 'csrf', 'routes/index', 'routes/uploads', 'routes/users', 'test/app.test'].flatMap((file) => [file + '.js', file + '.ts']),
|
|
79
|
+
'eslint.config.js',
|
|
80
|
+
'eslint.config.mjs',
|
|
81
|
+
'pnpm-workspace.yaml',
|
|
82
|
+
...fs.readdirSync(path.join(TEMPLATE_DIR, 'views')).map((file) => 'views/' + file)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Plan an app: its folders and files, in the order to create them, with
|
|
87
|
+
* paths relative to the app directory. Files are `{ path, contents, mode }`,
|
|
88
|
+
* marked `config` when the app runs without them, so an existing one can be
|
|
89
|
+
* kept, or `merge` when an existing one gets the missing lines instead.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} name
|
|
92
|
+
* @param {object} options
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
export function planApp (name, options) {
|
|
96
|
+
const entries = []
|
|
97
|
+
|
|
98
|
+
function dir (to) {
|
|
99
|
+
entries.push({ type: 'dir', path: to })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function file (to, contents, extra) {
|
|
103
|
+
entries.push({ type: 'file', path: to, contents, mode: MODE_0666, ...extra })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function copy (from, to, extra) {
|
|
107
|
+
file(to, fs.readFileSync(path.join(TEMPLATE_DIR, from), 'utf-8'), extra)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// an ignore file, ignoring uploaded files too
|
|
111
|
+
function ignore (from) {
|
|
112
|
+
const contents = fs.readFileSync(path.join(TEMPLATE_DIR, from), 'utf-8')
|
|
113
|
+
return options.uploads ? contents + '\n# Uploaded files\nuploads/\n' : contents
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Module format
|
|
117
|
+
const esm = !options.cjs
|
|
118
|
+
const dirname = esm ? 'import.meta.dirname' : '__dirname'
|
|
119
|
+
|
|
120
|
+
// App kind: a JSON API has no views or static files
|
|
121
|
+
const api = Boolean(options.api)
|
|
122
|
+
|
|
123
|
+
// Sessions, and CSRF protection using them, are for web apps with views
|
|
124
|
+
const session = Boolean(options.session) && !api
|
|
125
|
+
const csrf = Boolean(options.csrf) && session && Boolean(options.view)
|
|
126
|
+
|
|
127
|
+
// File uploads, with an upload form in apps with views
|
|
128
|
+
const uploads = Boolean(options.uploads)
|
|
129
|
+
|
|
130
|
+
// a rate limit, or a session cookie's Secure flag, need the client's real
|
|
131
|
+
// address and protocol behind a proxy
|
|
132
|
+
const trustProxy = Boolean(options.rateLimit) || session
|
|
133
|
+
|
|
134
|
+
// Language
|
|
135
|
+
const ts = Boolean(options.ts)
|
|
136
|
+
const ext = ts ? 'ts' : 'js'
|
|
137
|
+
const www = './bin/www.' + ext
|
|
138
|
+
|
|
139
|
+
// Package manager
|
|
140
|
+
const pm = options.pm ?? DEFAULT_PACKAGE_MANAGER
|
|
141
|
+
|
|
142
|
+
// load .env, when it exists, before starting the app
|
|
143
|
+
const env = '--env-file-if-exists=.env'
|
|
144
|
+
|
|
145
|
+
// Package
|
|
146
|
+
const pkg = {
|
|
147
|
+
name,
|
|
148
|
+
version: '0.0.0',
|
|
149
|
+
private: true,
|
|
150
|
+
type: esm ? 'module' : 'commonjs',
|
|
151
|
+
scripts: {
|
|
152
|
+
start: 'node ' + env + ' ' + www,
|
|
153
|
+
test: 'node --test',
|
|
154
|
+
// restart the app on change
|
|
155
|
+
dev: 'node --watch ' + env + ' ' + www
|
|
156
|
+
},
|
|
157
|
+
engines: {
|
|
158
|
+
// --env-file-if-exists needs Node.js 22.9, and TypeScript type
|
|
159
|
+
// stripping is enabled by default from Node.js 22.18
|
|
160
|
+
node: ts ? '>=22.18' : '>=22.9'
|
|
161
|
+
},
|
|
162
|
+
devEngines: {
|
|
163
|
+
// other package managers refuse to install the app or run its scripts
|
|
164
|
+
packageManager: { name: pm, version: PACKAGE_MANAGERS[pm].version, onFail: 'error' }
|
|
165
|
+
},
|
|
166
|
+
dependencies: {
|
|
167
|
+
express: VERSIONS.express
|
|
168
|
+
},
|
|
169
|
+
devDependencies: {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// JavaScript
|
|
173
|
+
const app = loadTemplate('app/app.js')
|
|
174
|
+
const server = loadTemplate('app/www.js')
|
|
175
|
+
const test = loadTemplate('app/test/app.test.js')
|
|
176
|
+
|
|
177
|
+
for (const template of [app, server, test]) {
|
|
178
|
+
template.locals.api = api
|
|
179
|
+
template.locals.esm = esm
|
|
180
|
+
template.locals.ext = ext
|
|
181
|
+
template.locals.ts = ts
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
app.locals.dirname = dirname
|
|
185
|
+
app.locals.httpErrors = Boolean(options.view) || api
|
|
186
|
+
app.locals.trustProxy = trustProxy
|
|
187
|
+
app.locals.session = session
|
|
188
|
+
app.locals.csrf = csrf
|
|
189
|
+
|
|
190
|
+
test.locals.rateLimit = Boolean(options.rateLimit)
|
|
191
|
+
test.locals.csrf = csrf
|
|
192
|
+
test.locals.uploads = uploads
|
|
193
|
+
test.locals.view = Boolean(options.view)
|
|
194
|
+
|
|
195
|
+
// App modules
|
|
196
|
+
app.locals.localModules = Object.create(null)
|
|
197
|
+
app.locals.modules = Object.create(null)
|
|
198
|
+
app.locals.named = new Set()
|
|
199
|
+
app.locals.mounts = []
|
|
200
|
+
app.locals.uses = []
|
|
201
|
+
|
|
202
|
+
// Security headers
|
|
203
|
+
if (options.helmet) {
|
|
204
|
+
app.locals.modules.helmet = 'helmet'
|
|
205
|
+
app.locals.uses.push('helmet()')
|
|
206
|
+
pkg.dependencies.helmet = VERSIONS.helmet
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Response compression
|
|
210
|
+
if (options.compression) {
|
|
211
|
+
app.locals.modules.compression = 'compression'
|
|
212
|
+
app.locals.uses.push('compression()')
|
|
213
|
+
pkg.dependencies.compression = VERSIONS.compression
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Cross-origin requests
|
|
217
|
+
if (options.cors) {
|
|
218
|
+
app.locals.modules.cors = 'cors'
|
|
219
|
+
app.locals.uses.push("cors({\n // any origin, or only the comma-separated origins in CORS_ORIGIN\n origin: process.env.CORS_ORIGIN?.split(',').map((origin) => origin.trim()) ?? '*'\n})")
|
|
220
|
+
pkg.dependencies.cors = VERSIONS.cors
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Request logger
|
|
224
|
+
if (options.logger === 'pino') {
|
|
225
|
+
app.locals.modules.pinoHttp = 'pino-http'
|
|
226
|
+
app.locals.named.add('pinoHttp')
|
|
227
|
+
app.locals.uses.push("pinoHttp({\n // skip request logs while testing\n autoLogging: { ignore: () => process.env.NODE_ENV === 'test' }\n})")
|
|
228
|
+
pkg.dependencies['pino-http'] = VERSIONS['pino-http']
|
|
229
|
+
|
|
230
|
+
// JSON logs, made readable in development
|
|
231
|
+
pkg.scripts.dev += ' | pino-pretty'
|
|
232
|
+
pkg.devDependencies['pino-pretty'] = VERSIONS['pino-pretty']
|
|
233
|
+
} else {
|
|
234
|
+
app.locals.modules.logger = 'morgan'
|
|
235
|
+
app.locals.uses.push("logger('dev', {\n // skip request logs while testing\n skip: () => process.env.NODE_ENV === 'test'\n})")
|
|
236
|
+
pkg.dependencies.morgan = VERSIONS.morgan
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Body parsers
|
|
240
|
+
app.locals.uses.push('express.json()')
|
|
241
|
+
|
|
242
|
+
if (!api) {
|
|
243
|
+
app.locals.uses.push('express.urlencoded({ extended: false })')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Cookie parser
|
|
247
|
+
if (options.cookies) {
|
|
248
|
+
app.locals.modules.cookieParser = 'cookie-parser'
|
|
249
|
+
app.locals.uses.push('cookieParser()')
|
|
250
|
+
pkg.dependencies['cookie-parser'] = VERSIONS['cookie-parser']
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// the app directory itself
|
|
254
|
+
dir('.')
|
|
255
|
+
|
|
256
|
+
if (!api) {
|
|
257
|
+
dir('public')
|
|
258
|
+
dir('public/stylesheets')
|
|
259
|
+
|
|
260
|
+
// Stylesheet
|
|
261
|
+
copy('public/stylesheets/style.css', 'public/stylesheets/style.css')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// copy route templates
|
|
265
|
+
dir('routes')
|
|
266
|
+
for (const route of ['index', 'users']) {
|
|
267
|
+
const router = loadTemplate('app/routes/' + route + '.js')
|
|
268
|
+
router.locals.api = api
|
|
269
|
+
router.locals.esm = esm
|
|
270
|
+
// an example form for CSRF protection to protect
|
|
271
|
+
router.locals.form = csrf
|
|
272
|
+
file('routes/' + route + '.' + ext, router.render())
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// upload route
|
|
276
|
+
if (uploads) {
|
|
277
|
+
const router = loadTemplate('app/routes/uploads.js')
|
|
278
|
+
router.locals.csrf = csrf
|
|
279
|
+
router.locals.dirname = dirname
|
|
280
|
+
router.locals.esm = esm
|
|
281
|
+
router.locals.ext = ext
|
|
282
|
+
router.locals.ts = ts
|
|
283
|
+
router.locals.view = Boolean(options.view)
|
|
284
|
+
file('routes/uploads.' + ext, router.render())
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// copy test templates
|
|
288
|
+
dir('test')
|
|
289
|
+
file('test/app.test.' + ext, test.render())
|
|
290
|
+
|
|
291
|
+
// Index router mount
|
|
292
|
+
app.locals.localModules.indexRouter = './routes/index.' + ext
|
|
293
|
+
app.locals.mounts.push({ path: '/', code: 'indexRouter' })
|
|
294
|
+
|
|
295
|
+
// User router mount
|
|
296
|
+
app.locals.localModules.usersRouter = './routes/users.' + ext
|
|
297
|
+
app.locals.mounts.push({ path: '/users', code: 'usersRouter' })
|
|
298
|
+
|
|
299
|
+
// Upload router mount
|
|
300
|
+
if (uploads) {
|
|
301
|
+
app.locals.localModules.uploadsRouter = './routes/uploads.' + ext
|
|
302
|
+
app.locals.mounts.push({ path: '/uploads', code: 'uploadsRouter' })
|
|
303
|
+
pkg.dependencies.multer = VERSIONS.multer
|
|
304
|
+
pkg.dependencies['http-errors'] = VERSIONS['http-errors']
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Template support
|
|
308
|
+
if (options.view) {
|
|
309
|
+
const view = VIEW_ENGINES[options.view]
|
|
310
|
+
|
|
311
|
+
// Copy view templates, with the CSRF token in the page <head> and forms
|
|
312
|
+
const optional = { csrf, uploads }
|
|
313
|
+
|
|
314
|
+
dir('views')
|
|
315
|
+
fs.readdirSync(path.join(TEMPLATE_DIR, 'views'))
|
|
316
|
+
.filter((template) => path.extname(template) === '.' + options.view)
|
|
317
|
+
.filter((template) => {
|
|
318
|
+
const option = OPTIONAL_VIEWS[path.basename(template, path.extname(template))]
|
|
319
|
+
return !option || optional[option]
|
|
320
|
+
})
|
|
321
|
+
.forEach((template) => {
|
|
322
|
+
let contents = fs.readFileSync(path.join(TEMPLATE_DIR, 'views', template), 'utf-8')
|
|
323
|
+
|
|
324
|
+
if (csrf) {
|
|
325
|
+
contents = contents
|
|
326
|
+
.replace(/^( *)(<title>.*|title= title)$/m, '$&\n$1' + CSRF_META[options.view])
|
|
327
|
+
.replace(/^( *)@csrf$/gm, '$1' + CSRF_FIELD[options.view])
|
|
328
|
+
} else {
|
|
329
|
+
contents = contents.replace(/^ *@csrf\n/gm, '')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
file('views/' + template, contents)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
app.locals.view = { engine: options.view }
|
|
336
|
+
pkg.dependencies[view.pkg] = VERSIONS[view.pkg]
|
|
337
|
+
} else {
|
|
338
|
+
app.locals.view = false
|
|
339
|
+
|
|
340
|
+
// Copy extra public files
|
|
341
|
+
if (!api) {
|
|
342
|
+
copy('public/index.html', 'public/index.html')
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// HTTP errors for the 404 and error handlers
|
|
347
|
+
if (app.locals.httpErrors) {
|
|
348
|
+
pkg.dependencies['http-errors'] = VERSIONS['http-errors']
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Static files
|
|
352
|
+
if (!api) {
|
|
353
|
+
app.locals.uses.push('express.static(path.join(' + dirname + ", 'public'))")
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Rate limit, after static files so only app routes count
|
|
357
|
+
if (options.rateLimit) {
|
|
358
|
+
app.locals.modules.rateLimit = 'express-rate-limit'
|
|
359
|
+
app.locals.named.add('rateLimit')
|
|
360
|
+
app.locals.uses.push("rateLimit({\n // requests each client may make per window, before 429 responses\n windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,\n limit: Number(process.env.RATE_LIMIT_MAX) || 100,\n standardHeaders: 'draft-8',\n legacyHeaders: false\n})")
|
|
361
|
+
pkg.dependencies['express-rate-limit'] = VERSIONS['express-rate-limit']
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Sessions
|
|
365
|
+
if (session) {
|
|
366
|
+
app.locals.modules.session = 'express-session'
|
|
367
|
+
app.locals.uses.push("session({\n // the default memory store loses sessions on restart and grows without\n // limit: add a store, such as connect-redis, for production\n secret: process.env.SESSION_SECRET || 'development only',\n resave: false,\n saveUninitialized: false,\n // Secure over HTTPS, including behind a proxy with TRUST_PROXY set\n cookie: { sameSite: 'lax', secure: 'auto' }\n})")
|
|
368
|
+
pkg.dependencies['express-session'] = VERSIONS['express-session']
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// CSRF protection
|
|
372
|
+
if (csrf) {
|
|
373
|
+
const module = loadTemplate('app/csrf.js')
|
|
374
|
+
module.locals.esm = esm
|
|
375
|
+
module.locals.uploads = uploads
|
|
376
|
+
file('csrf.' + ext, module.render())
|
|
377
|
+
|
|
378
|
+
app.locals.localModules.csrf = './csrf.' + ext
|
|
379
|
+
app.locals.uses.push('csrf.csrfSynchronisedProtection')
|
|
380
|
+
app.locals.uses.push('(req, res, next) => {\n // the token for forms, and the csrf-token meta tag in views\n res.locals.csrfToken = csrf.generateToken(req);\n next();\n}')
|
|
381
|
+
pkg.dependencies['csrf-sync'] = VERSIONS['csrf-sync']
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Environment variables
|
|
385
|
+
const envExample = loadTemplate('app/env.example')
|
|
386
|
+
envExample.locals.cors = Boolean(options.cors)
|
|
387
|
+
envExample.locals.trustProxy = trustProxy
|
|
388
|
+
envExample.locals.rateLimit = Boolean(options.rateLimit)
|
|
389
|
+
envExample.locals.session = session
|
|
390
|
+
envExample.locals.uploads = uploads
|
|
391
|
+
envExample.locals.start = scriptCommand(pm, 'start')
|
|
392
|
+
envExample.locals.dev = scriptCommand(pm, 'dev')
|
|
393
|
+
file('.env.example', envExample.render(), { config: true })
|
|
394
|
+
|
|
395
|
+
// Package manager settings, protecting installs from compromised packages
|
|
396
|
+
if (pm === 'pnpm') {
|
|
397
|
+
copy('app/pnpm-workspace.yaml', 'pnpm-workspace.yaml', { config: true })
|
|
398
|
+
} else {
|
|
399
|
+
copy('app/npmrc', '.npmrc', { config: true })
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Container image
|
|
403
|
+
if (options.docker) {
|
|
404
|
+
const dockerfile = loadTemplate('app/Dockerfile')
|
|
405
|
+
dockerfile.locals.name = name
|
|
406
|
+
dockerfile.locals.www = www
|
|
407
|
+
dockerfile.locals.uploads = uploads
|
|
408
|
+
dockerfile.locals.pm = pm
|
|
409
|
+
// the exact package manager version, for repeatable image builds
|
|
410
|
+
dockerfile.locals.pmVersion = VERSIONS[pm].replace(/^\^/, '')
|
|
411
|
+
file('Dockerfile', dockerfile.render(), { config: true })
|
|
412
|
+
file('.dockerignore', ignore('app/dockerignore'), { config: true })
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (options.git) {
|
|
416
|
+
file('.gitignore', ignore('app/gitignore'), { merge: true })
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// TypeScript type checking
|
|
420
|
+
if (ts) {
|
|
421
|
+
pkg.scripts.typecheck = 'tsc'
|
|
422
|
+
const types = ['@types/express', '@types/node']
|
|
423
|
+
|
|
424
|
+
if (options.logger !== 'pino') types.push('@types/morgan')
|
|
425
|
+
if (session) types.push('@types/express-session')
|
|
426
|
+
|
|
427
|
+
if (app.locals.httpErrors || uploads) types.push('@types/http-errors')
|
|
428
|
+
if (uploads) types.push('@types/multer')
|
|
429
|
+
if (options.compression) types.push('@types/compression')
|
|
430
|
+
if (options.cookies) types.push('@types/cookie-parser')
|
|
431
|
+
if (options.cors) types.push('@types/cors')
|
|
432
|
+
|
|
433
|
+
pkg.devDependencies.typescript = VERSIONS.typescript
|
|
434
|
+
for (const type of types) pkg.devDependencies[type] = VERSIONS[type]
|
|
435
|
+
|
|
436
|
+
file('tsconfig.json', JSON.stringify(TSCONFIG, null, 2) + '\n', { config: true })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Linting
|
|
440
|
+
if (options.lint) {
|
|
441
|
+
const config = loadTemplate('app/eslint.config.js')
|
|
442
|
+
config.locals.esm = esm
|
|
443
|
+
config.locals.ts = ts
|
|
444
|
+
|
|
445
|
+
// CommonJS apps need the .mjs extension for the ES module config
|
|
446
|
+
file(esm ? 'eslint.config.js' : 'eslint.config.mjs', config.render(), { config: true })
|
|
447
|
+
|
|
448
|
+
pkg.scripts.lint = 'eslint .'
|
|
449
|
+
for (const dep of ['eslint', '@eslint/js', 'globals']) pkg.devDependencies[dep] = VERSIONS[dep]
|
|
450
|
+
|
|
451
|
+
if (ts) {
|
|
452
|
+
// typescript-eslint does not support TypeScript 7 yet
|
|
453
|
+
pkg.devDependencies['typescript-eslint'] = VERSIONS['typescript-eslint']
|
|
454
|
+
pkg.devDependencies.typescript = VERSIONS['typescript@6']
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// sort dependencies like npm(1)
|
|
459
|
+
pkg.dependencies = sortedObject(pkg.dependencies)
|
|
460
|
+
pkg.devDependencies = sortedObject(pkg.devDependencies)
|
|
461
|
+
|
|
462
|
+
if (!Object.keys(pkg.devDependencies).length) {
|
|
463
|
+
delete pkg.devDependencies
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
file('app.' + ext, app.render())
|
|
467
|
+
file('package.json', JSON.stringify(pkg, null, 2) + '\n')
|
|
468
|
+
dir('bin')
|
|
469
|
+
file('bin/www.' + ext, server.render(), { mode: MODE_0755 })
|
|
470
|
+
|
|
471
|
+
return entries
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Check the destination directory against a plan. Returns `blockers`, the
|
|
476
|
+
* messages for paths in the way of a folder or file, `conflicts`, the
|
|
477
|
+
* planned files that exist with other contents, and `leftovers`, the paths
|
|
478
|
+
* of existing files that other options generate and this app does not use.
|
|
479
|
+
* Merged files are never conflicts. The file system decides whether names
|
|
480
|
+
* differ only in case.
|
|
481
|
+
*
|
|
482
|
+
* @param {string} dir
|
|
483
|
+
* @param {object[]} entries
|
|
484
|
+
*/
|
|
485
|
+
|
|
486
|
+
export function checkDestination (dir, entries) {
|
|
487
|
+
const blockers = []
|
|
488
|
+
const conflicts = []
|
|
489
|
+
const planned = new Set(entries.map((entry) => entry.path))
|
|
490
|
+
const leftovers = ALTERNATIVES.filter((file) => {
|
|
491
|
+
const stats = !planned.has(file) && stat(path.join(dir, file))
|
|
492
|
+
return Boolean(stats) && stats.isFile()
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
for (const entry of entries) {
|
|
496
|
+
const stats = stat(path.join(dir, entry.path))
|
|
497
|
+
const name = entry.path === '.' ? dir : entry.path
|
|
498
|
+
|
|
499
|
+
// a file in the way of a parent folder, reported for that folder
|
|
500
|
+
if (stats === false) {
|
|
501
|
+
if (entry.path === '.') blockers.push(name + ' is inside a file, not a folder')
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!stats) continue
|
|
506
|
+
|
|
507
|
+
if (entry.type === 'dir') {
|
|
508
|
+
if (!stats.isDirectory()) blockers.push(name + ' is a file, where a folder is needed')
|
|
509
|
+
} else if (stats.isDirectory()) {
|
|
510
|
+
blockers.push(name + ' is a folder, where a file is needed')
|
|
511
|
+
} else if (!entry.merge && fs.readFileSync(path.join(dir, entry.path), 'utf-8') !== entry.contents) {
|
|
512
|
+
conflicts.push(entry)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { blockers, conflicts, leftovers }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Create an app name from a directory path, fitting npm naming requirements.
|
|
521
|
+
*
|
|
522
|
+
* @param {String} pathName
|
|
523
|
+
*/
|
|
524
|
+
|
|
525
|
+
export function createAppName (pathName) {
|
|
526
|
+
return path.basename(pathName)
|
|
527
|
+
.replace(/[^A-Za-z0-9.-]+/g, '-')
|
|
528
|
+
.replace(/^[-_.]+|-+$/g, '')
|
|
529
|
+
.toLowerCase()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The command that runs a package.json script with a package manager.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} pm
|
|
536
|
+
* @param {string} script
|
|
537
|
+
*/
|
|
538
|
+
|
|
539
|
+
export function scriptCommand (pm, script) {
|
|
540
|
+
return pm === 'npm' && script !== 'start' && script !== 'test' ? 'npm run ' + script : pm + ' ' + script
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Add the lines of `generated` missing from `existing`, skipping comments
|
|
545
|
+
* and blank lines, under a comment saying where they came from.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} existing
|
|
548
|
+
* @param {string} generated
|
|
549
|
+
*/
|
|
550
|
+
|
|
551
|
+
export function mergeLines (existing, generated) {
|
|
552
|
+
const have = new Set(existing.split(/\r?\n/).map((line) => line.trim()))
|
|
553
|
+
const missing = generated.split('\n')
|
|
554
|
+
.map((line) => line.trim())
|
|
555
|
+
.filter((line) => line && !line.startsWith('#') && !have.has(line))
|
|
556
|
+
|
|
557
|
+
if (!missing.length) return existing
|
|
558
|
+
|
|
559
|
+
const eol = existing.includes('\r\n') ? '\r\n' : '\n'
|
|
560
|
+
const start = existing && !existing.endsWith('\n') ? eol : ''
|
|
561
|
+
|
|
562
|
+
return existing + start + (existing ? eol : '') + '# Added by create-express-new' + eol + missing.join(eol) + eol
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Load template file.
|
|
567
|
+
*/
|
|
568
|
+
|
|
569
|
+
function loadTemplate (name) {
|
|
570
|
+
const contents = fs.readFileSync(path.join(TEMPLATE_DIR, name + '.ejs'), 'utf-8')
|
|
571
|
+
const locals = Object.create(null)
|
|
572
|
+
|
|
573
|
+
function render () {
|
|
574
|
+
return ejs.render(contents, locals, {
|
|
575
|
+
escape: util.inspect
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
locals,
|
|
581
|
+
render
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Sort object keys like npm(1).
|
|
587
|
+
*
|
|
588
|
+
* @param {object} obj
|
|
589
|
+
*/
|
|
590
|
+
|
|
591
|
+
function sortedObject (obj) {
|
|
592
|
+
return Object.fromEntries(Object.keys(obj).sort().map((key) => [key, obj[key]]))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Stat a path: null when missing, false when a parent is a file.
|
|
597
|
+
*/
|
|
598
|
+
|
|
599
|
+
function stat (file) {
|
|
600
|
+
try {
|
|
601
|
+
return fs.statSync(file)
|
|
602
|
+
} catch (err) {
|
|
603
|
+
if (err.code === 'ENOENT') return null
|
|
604
|
+
if (err.code === 'ENOTDIR') return false
|
|
605
|
+
throw err
|
|
606
|
+
}
|
|
607
|
+
}
|