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
@@ -0,0 +1,575 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import readline from 'node:readline'
7
+ import util from 'node:util'
8
+ import { DEFAULT_PACKAGE_MANAGER, LOGGERS, PACKAGE_MANAGERS, VIEW_ENGINES, checkDestination, createAppName, mergeLines, planApp, scriptCommand } from '../lib/app.js'
9
+ import { CancelError } from '../lib/prompts.js'
10
+ import { wizard } from '../lib/wizard.js'
11
+
12
+ const MODE_0755 = 0o755
13
+ const VERSION = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf-8')).version
14
+
15
+ // command line options
16
+ const OPTIONS = {
17
+ api: { type: 'boolean' },
18
+ cjs: { type: 'boolean' },
19
+ compression: { type: 'boolean' },
20
+ cookies: { type: 'boolean' },
21
+ cors: { type: 'boolean' },
22
+ csrf: { type: 'boolean' },
23
+ docker: { type: 'boolean' },
24
+ force: { type: 'boolean', short: 'f' },
25
+ git: { type: 'boolean' },
26
+ helmet: { type: 'boolean' },
27
+ help: { type: 'boolean', short: 'h' },
28
+ install: { type: 'boolean' },
29
+ 'keep-config': { type: 'boolean' },
30
+ lint: { type: 'boolean' },
31
+ logger: { type: 'string' },
32
+ 'no-git': { type: 'boolean' },
33
+ 'no-view': { type: 'boolean' },
34
+ pm: { type: 'string' },
35
+ 'rate-limit': { type: 'boolean' },
36
+ session: { type: 'boolean' },
37
+ ts: { type: 'boolean' },
38
+ uploads: { type: 'boolean' },
39
+ version: { type: 'boolean' },
40
+ view: { type: 'string', short: 'v' }
41
+ }
42
+
43
+ // run the wizard when started in a terminal without arguments
44
+ const args = process.argv.slice(2)
45
+
46
+ if (args.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
47
+ wizard({ input: process.stdin, output: process.stdout }).then((options) => {
48
+ main(options, exit)
49
+ }, (err) => {
50
+ if (!(err instanceof CancelError)) throw err
51
+ console.error('aborting')
52
+ exit(1)
53
+ })
54
+ } else {
55
+ main(parseOptions(args), exit)
56
+ }
57
+
58
+ /**
59
+ * Prompt for confirmation on STDOUT/STDIN
60
+ */
61
+
62
+ function confirm (msg, fn) {
63
+ const rl = readline.createInterface({
64
+ input: process.stdin,
65
+ output: process.stdout
66
+ })
67
+
68
+ let answered = false
69
+
70
+ rl.question(msg, (input) => {
71
+ answered = true
72
+ rl.close()
73
+ fn(/^(y|yes|ok|true)$/i.test(input.trim()))
74
+ })
75
+
76
+ // treat closing STDIN without an answer as "no"
77
+ rl.on('close', () => {
78
+ if (!answered) {
79
+ console.log()
80
+ fn(false)
81
+ }
82
+ })
83
+ }
84
+
85
+ /**
86
+ * Create application at the given directory, from its plan. Existing files
87
+ * with other contents are overwritten, except config files with
88
+ * `--keep-config`, and an existing .gitignore gets the missing lines.
89
+ * Leftover files from other options are left in place, with a warning.
90
+ *
91
+ * @param {string} dir
92
+ * @param {object[]} entries
93
+ * @param {string[]} leftovers
94
+ * @param {object} options
95
+ * @param {function} done
96
+ */
97
+
98
+ function createApplication (dir, entries, leftovers, options, done) {
99
+ console.log()
100
+
101
+ for (const entry of entries) {
102
+ const file = path.join(dir, entry.path)
103
+
104
+ if (entry.type === 'dir') {
105
+ if (entry.path !== '.' || dir !== '.') mkdir(file)
106
+ continue
107
+ }
108
+
109
+ const existing = read(file)
110
+
111
+ if (existing === null) {
112
+ write(file, entry.contents, entry.mode)
113
+ } else if (existing === entry.contents) {
114
+ log('identical', file)
115
+ executable(file, entry.mode)
116
+ } else if (entry.merge) {
117
+ const merged = mergeLines(existing, entry.contents)
118
+ if (merged === existing) log('keep', file)
119
+ else write(file, merged, entry.mode, 'update')
120
+ } else if (entry.config && options.keepConfig) {
121
+ log('keep', file)
122
+ } else {
123
+ write(file, entry.contents, entry.mode, 'overwrite')
124
+ executable(file, entry.mode)
125
+ }
126
+ }
127
+
128
+ if (leftovers.length) {
129
+ warning('these existing files are not used by the new app, so they were left as they are:\n' +
130
+ leftovers.map((file) => ' ' + path.join(dir, file)).join('\n') + '\n' +
131
+ 'remove them if they are from an earlier app with other options')
132
+ }
133
+
134
+ if (options.install) {
135
+ installDependencies(dir, options.pm, done)
136
+ } else {
137
+ printNextSteps(dir, options.pm, true)
138
+ done(0)
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Install the generated app's dependencies with its package manager, then
144
+ * display the next steps.
145
+ *
146
+ * @param {string} dir
147
+ * @param {string} pm the app's package manager
148
+ * @param {function} done
149
+ */
150
+
151
+ function installDependencies (dir, pm, done) {
152
+ console.log(' installing dependencies...')
153
+ console.log()
154
+
155
+ const child = spawn(pm, ['install'], {
156
+ cwd: dir,
157
+ shell: process.platform === 'win32',
158
+ stdio: 'inherit'
159
+ })
160
+
161
+ // a package manager that cannot be started fails with an error, then closes
162
+ let failed = false
163
+
164
+ child.on('error', (err) => {
165
+ failed = true
166
+ error(err.code === 'ENOENT'
167
+ ? pm + ' is not installed, see ' + PACKAGE_MANAGERS[pm].docs
168
+ : pm + ' install failed: ' + err.message)
169
+ printNextSteps(dir, pm, true)
170
+ done(1)
171
+ })
172
+
173
+ child.on('close', (status) => {
174
+ if (failed) return
175
+ if (status !== 0) error(pm + ' install failed')
176
+ printNextSteps(dir, pm, status !== 0)
177
+ done(status === 0 ? 0 : 1)
178
+ })
179
+ }
180
+
181
+ /**
182
+ * Display the commands to run the generated app.
183
+ *
184
+ * @param {string} dir
185
+ * @param {string} pm the app's package manager
186
+ * @param {boolean} install include the install step
187
+ */
188
+
189
+ function printNextSteps (dir, pm, install) {
190
+ const prompt = launchedFromCmd() ? '>' : '$'
191
+
192
+ if (dir !== '.') {
193
+ console.log()
194
+ console.log(' change directory:')
195
+ console.log(' %s cd %s', prompt, dir)
196
+ }
197
+
198
+ if (install) {
199
+ console.log()
200
+ console.log(' install dependencies:')
201
+ console.log(' %s %s install', prompt, pm)
202
+ }
203
+
204
+ console.log()
205
+ console.log(' run the app:')
206
+ console.log(' %s %s', prompt, scriptCommand(pm, 'start'))
207
+ console.log()
208
+ console.log(' run the app in development, restarting on change:')
209
+ console.log(' %s %s', prompt, scriptCommand(pm, 'dev'))
210
+ console.log()
211
+ }
212
+
213
+ /**
214
+ * Check if the given directory `dir` is empty.
215
+ *
216
+ * @param {String} dir
217
+ * @param {Function} fn
218
+ */
219
+
220
+ function emptyDirectory (dir, fn) {
221
+ fs.readdir(dir, (err, files) => {
222
+ if (err && err.code !== 'ENOENT') throw err
223
+ fn(!files || !files.length)
224
+ })
225
+ }
226
+
227
+ /**
228
+ * Display an error.
229
+ *
230
+ * @param {String} message
231
+ */
232
+
233
+ function error (message) {
234
+ console.error()
235
+ message.split('\n').forEach((line) => {
236
+ console.error(' error: %s', line)
237
+ })
238
+ console.error()
239
+ }
240
+
241
+ /**
242
+ * Set the exit code, letting the process exit once all output is flushed.
243
+ */
244
+
245
+ function exit (code) {
246
+ process.exitCode = code
247
+ }
248
+
249
+ /**
250
+ * Determine if launched from cmd.exe
251
+ */
252
+
253
+ function launchedFromCmd () {
254
+ return process.platform === 'win32' &&
255
+ process.env._ === undefined
256
+ }
257
+
258
+ /**
259
+ * Main program.
260
+ */
261
+
262
+ function main (options, done) {
263
+ // top-level argument direction
264
+ if (options['!'].length > 0) {
265
+ usage()
266
+ error('unknown option `' + options['!'][0] + "'")
267
+ done(1)
268
+ } else if (options.help) {
269
+ usage()
270
+ done(0)
271
+ } else if (options.version) {
272
+ version()
273
+ done(0)
274
+ } else if (options.view === '') {
275
+ usage()
276
+ error('option `-v, --view <engine>\' argument missing')
277
+ done(1)
278
+ } else if (options.logger === '') {
279
+ usage()
280
+ error('option `--logger <name>\' argument missing')
281
+ done(1)
282
+ } else if (options.pm === '') {
283
+ usage()
284
+ error('option `--pm <name>\' argument missing')
285
+ done(1)
286
+ } else {
287
+ // Path
288
+ const destinationPath = options._[0] || '.'
289
+
290
+ // App name
291
+ const appName = createAppName(path.resolve(destinationPath)) || 'hello-world'
292
+
293
+ // A JSON API has no view engine
294
+ if (options.api) {
295
+ if (typeof options.view === 'string') {
296
+ usage()
297
+ error('option `--api\' cannot be used with `--view\'')
298
+ return done(1)
299
+ }
300
+
301
+ options.view = false
302
+ }
303
+
304
+ // Default view engine
305
+ if (options.view === true) {
306
+ options.view = 'pug'
307
+ }
308
+
309
+ // Renamed engines
310
+ if (options.view === 'jade') {
311
+ warning("jade has been renamed to pug, using `--view=pug'")
312
+ options.view = 'pug'
313
+ }
314
+
315
+ if (options.ts && options.cjs) {
316
+ usage()
317
+ error('option `--ts\' cannot be used with `--cjs\'')
318
+ return done(1)
319
+ }
320
+
321
+ // Unsupported engines
322
+ if (options.view && !VIEW_ENGINES[options.view]) {
323
+ usage()
324
+ error('unsupported view engine `' + options.view + "'")
325
+ return done(1)
326
+ }
327
+
328
+ // Unsupported loggers
329
+ if (!LOGGERS.includes(options.logger)) {
330
+ usage()
331
+ error('unsupported logger `' + options.logger + "'")
332
+ return done(1)
333
+ }
334
+
335
+ // Unsupported package managers
336
+ if (!Object.hasOwn(PACKAGE_MANAGERS, options.pm)) {
337
+ usage()
338
+ error('unsupported package manager `' + options.pm + "'")
339
+ return done(1)
340
+ }
341
+
342
+ // Sessions are for web apps, and CSRF protection needs them and views
343
+ if (options.session && options.api) {
344
+ usage()
345
+ error('option `--session\' cannot be used with `--api\'')
346
+ return done(1)
347
+ }
348
+
349
+ if (options.csrf && !options.session) {
350
+ usage()
351
+ error('option `--csrf\' requires `--session\'')
352
+ return done(1)
353
+ }
354
+
355
+ if (options.csrf && !options.view) {
356
+ usage()
357
+ error('option `--csrf\' needs a view engine, so cannot be used with `--no-view\'')
358
+ return done(1)
359
+ }
360
+
361
+ // Check the destination before writing anything
362
+ const entries = planApp(appName, options)
363
+ const { blockers, conflicts, leftovers } = checkDestination(destinationPath, entries)
364
+
365
+ if (blockers.length) {
366
+ error('cannot create the app:\n' + blockers.join('\n'))
367
+ return done(1)
368
+ }
369
+
370
+ const overwrites = conflicts.filter((entry) => !(entry.config && options.keepConfig))
371
+
372
+ // Generate application
373
+ emptyDirectory(destinationPath, (empty) => {
374
+ if (empty || options.force) {
375
+ createApplication(destinationPath, entries, leftovers, options, done)
376
+ } else {
377
+ if (overwrites.length) {
378
+ console.log()
379
+ console.log(' these existing files will be overwritten:')
380
+ for (const entry of overwrites) console.log(' ' + path.join(destinationPath, entry.path))
381
+ console.log()
382
+ }
383
+
384
+ confirm('destination is not empty, continue? [y/N] ', (ok) => {
385
+ if (ok) {
386
+ process.stdin.destroy()
387
+ createApplication(destinationPath, entries, leftovers, options, done)
388
+ } else {
389
+ console.error('aborting')
390
+ done(1)
391
+ }
392
+ })
393
+ }
394
+ })
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Create a directory, with its parents.
400
+ *
401
+ * @param {string} dir
402
+ */
403
+
404
+ function mkdir (dir) {
405
+ log('create', dir + path.sep)
406
+ fs.mkdirSync(dir, { recursive: true, mode: MODE_0755 })
407
+ }
408
+
409
+ /**
410
+ * Parse command line arguments.
411
+ *
412
+ * The view option defaults to `true` when not given and is `''` when given
413
+ * without an argument; `_` holds positionals and `!` holds unknown options.
414
+ *
415
+ * @param {string[]} argv
416
+ */
417
+
418
+ function parseOptions (argv) {
419
+ const { values, positionals, tokens } = util.parseArgs({
420
+ args: argv,
421
+ options: OPTIONS,
422
+ allowPositionals: true,
423
+ strict: false,
424
+ tokens: true
425
+ })
426
+
427
+ const options = { view: true, ...values, _: positionals, '!': [] }
428
+
429
+ for (const token of tokens) {
430
+ if (token.kind !== 'option') continue
431
+
432
+ if (!Object.hasOwn(OPTIONS, token.name)) {
433
+ options['!'].push(token.rawName)
434
+ } else if (OPTIONS[token.name].type === 'string' &&
435
+ (token.value === undefined || (!token.inlineValue && token.value.startsWith('-')))) {
436
+ // argument missing, or the next option was taken as the argument
437
+ options[token.name] = ''
438
+ }
439
+ }
440
+
441
+ if (options['no-view']) {
442
+ options.view = false
443
+ }
444
+
445
+ // a .gitignore is added unless --no-git is given
446
+ options.git = !options['no-git']
447
+ options.keepConfig = Boolean(options['keep-config'])
448
+ options.rateLimit = Boolean(options['rate-limit'])
449
+ options.logger ??= 'morgan'
450
+ options.pm ??= DEFAULT_PACKAGE_MANAGER
451
+
452
+ return options
453
+ }
454
+
455
+ /**
456
+ * Display the usage.
457
+ */
458
+
459
+ function usage () {
460
+ console.log('')
461
+ console.log(' Usage: pnpm create express-new@latest [dir] [options]')
462
+ console.log(' npm create express-new@latest [dir] -- [options]')
463
+ console.log(' npx create-express-new [options] [dir]')
464
+ console.log(' express [options] [dir] (when installed globally)')
465
+ console.log('')
466
+ console.log(' Run without arguments in a terminal to choose the options interactively.')
467
+ console.log('')
468
+ console.log(' Options:')
469
+ console.log('')
470
+ console.log(' -v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)')
471
+ console.log(' --no-view use static html instead of view engine')
472
+ console.log(' --api generate a JSON API, without views or static files')
473
+ console.log(' --cjs generate CommonJS modules instead of ES modules')
474
+ console.log(' --ts generate TypeScript, run directly by Node.js')
475
+ console.log(' --helmet add helmet middleware for security headers')
476
+ console.log(' --compression add compression middleware for gzip/brotli responses')
477
+ console.log(' --cookies add cookie-parser middleware')
478
+ console.log(' --cors add cors middleware for cross-origin requests')
479
+ console.log(' --rate-limit add express-rate-limit to limit requests per client')
480
+ console.log(' --session add express-session for sessions (not with --api)')
481
+ console.log(' --csrf add CSRF protection for forms (needs --session)')
482
+ console.log(' --uploads add multer and an upload route at /uploads')
483
+ console.log(' --logger <name> request logger (morgan|pino) (defaults to morgan)')
484
+ console.log(' --docker add a Dockerfile for a production image')
485
+ console.log(' --lint add ESLint and a lint script')
486
+ console.log(' --pm <name> package manager the app requires (pnpm|npm) (defaults to pnpm)')
487
+ console.log(' --install install the dependencies after creating the app')
488
+ console.log(' --no-git skip the .gitignore')
489
+ console.log(' -f, --force force on non-empty directory')
490
+ console.log(' --keep-config keep existing config files (.env.example, Dockerfile,')
491
+ console.log(' .dockerignore, tsconfig.json, eslint.config.*,')
492
+ console.log(' pnpm-workspace.yaml, .npmrc)')
493
+ console.log(' --version output the version number')
494
+ console.log(' -h, --help output usage information')
495
+ }
496
+
497
+ /**
498
+ * Display the version.
499
+ */
500
+
501
+ function version () {
502
+ console.log(VERSION)
503
+ }
504
+
505
+ /**
506
+ * Display a warning.
507
+ *
508
+ * @param {String} message
509
+ */
510
+
511
+ function warning (message) {
512
+ console.error()
513
+ message.split('\n').forEach((line) => {
514
+ console.error(' warning: %s', line)
515
+ })
516
+ console.error()
517
+ }
518
+
519
+ /**
520
+ * Make an existing file executable when its planned mode is, as writing it
521
+ * only sets the mode of new files. Adds execute where read is allowed.
522
+ *
523
+ * @param {string} file
524
+ * @param {number} mode
525
+ */
526
+
527
+ function executable (file, mode) {
528
+ if (!(mode & 0o111)) return
529
+
530
+ const current = fs.statSync(file).mode & 0o777
531
+ const wanted = current | ((current & 0o444) >> 2)
532
+
533
+ if (wanted !== current) fs.chmodSync(file, wanted)
534
+ }
535
+
536
+ /**
537
+ * Log what happened to a file.
538
+ *
539
+ * @param {string} action
540
+ * @param {string} file
541
+ */
542
+
543
+ function log (action, file) {
544
+ const color = action === 'overwrite' ? '33' : action === 'create' || action === 'update' ? '36' : '2'
545
+ console.log(' \x1b[' + color + 'm' + action + '\x1b[0m : ' + file)
546
+ }
547
+
548
+ /**
549
+ * Read a file, or null when it does not exist.
550
+ *
551
+ * @param {string} file
552
+ */
553
+
554
+ function read (file) {
555
+ try {
556
+ return fs.readFileSync(file, 'utf-8')
557
+ } catch (err) {
558
+ if (err.code === 'ENOENT') return null
559
+ throw err
560
+ }
561
+ }
562
+
563
+ /**
564
+ * echo str > file, logging it as `action`.
565
+ *
566
+ * @param {String} file
567
+ * @param {String} str
568
+ * @param {number} mode
569
+ * @param {string} action
570
+ */
571
+
572
+ function write (file, str, mode, action = 'create') {
573
+ fs.writeFileSync(file, str, { mode })
574
+ log(action, file)
575
+ }