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