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.
Files changed (44) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/LICENSE +24 -0
  3. package/README.md +282 -2
  4. package/bin/express-cli.js +575 -0
  5. package/lib/app.js +607 -0
  6. package/lib/prompts.js +338 -0
  7. package/lib/wizard.js +398 -0
  8. package/package.json +65 -4
  9. package/templates/app/Dockerfile.ejs +64 -0
  10. package/templates/app/app.js.ejs +163 -0
  11. package/templates/app/csrf.js.ejs +21 -0
  12. package/templates/app/dockerignore +15 -0
  13. package/templates/app/env.example.ejs +40 -0
  14. package/templates/app/eslint.config.js.ejs +28 -0
  15. package/templates/app/gitignore +22 -0
  16. package/templates/app/npmrc +8 -0
  17. package/templates/app/pnpm-workspace.yaml +24 -0
  18. package/templates/app/routes/index.js.ejs +22 -0
  19. package/templates/app/routes/uploads.js.ejs +94 -0
  20. package/templates/app/routes/users.js.ejs +42 -0
  21. package/templates/app/test/app.test.js.ejs +199 -0
  22. package/templates/app/www.js.ejs +46 -0
  23. package/templates/public/index.html +13 -0
  24. package/templates/public/stylesheets/style.css +8 -0
  25. package/templates/versions.json +49 -0
  26. package/templates/views/error.ejs +12 -0
  27. package/templates/views/error.hbs +3 -0
  28. package/templates/views/error.pug +6 -0
  29. package/templates/views/error.twig +7 -0
  30. package/templates/views/index.ejs +11 -0
  31. package/templates/views/index.hbs +2 -0
  32. package/templates/views/index.pug +5 -0
  33. package/templates/views/index.twig +6 -0
  34. package/templates/views/layout.hbs +10 -0
  35. package/templates/views/layout.pug +7 -0
  36. package/templates/views/layout.twig +10 -0
  37. package/templates/views/new-user.ejs +18 -0
  38. package/templates/views/new-user.hbs +9 -0
  39. package/templates/views/new-user.pug +13 -0
  40. package/templates/views/new-user.twig +13 -0
  41. package/templates/views/uploads.ejs +16 -0
  42. package/templates/views/uploads.hbs +7 -0
  43. package/templates/views/uploads.pug +10 -0
  44. 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
+ }