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
package/lib/prompts.js ADDED
@@ -0,0 +1,338 @@
1
+ // Minimal interactive terminal prompts, built on node:readline with no
2
+ // dependencies. Each prompt takes `{ input, output }` streams, so they can be
3
+ // driven by a TTY or, in tests, by plain streams.
4
+ //
5
+ // Prompts given `back: true` resolve to BACK when the user presses Esc (or
6
+ // the left arrow, outside text prompts), after erasing themselves. After an
7
+ // answer, `io.printed` holds the text the answer left on screen, so callers
8
+ // can erase it again with `erase(io, rows(text, columns))`. Prompts redraw
9
+ // when the terminal is resized.
10
+
11
+ import readline from 'node:readline'
12
+
13
+ const CSI = '\x1b['
14
+ const WINDOWS = process.platform === 'win32'
15
+
16
+ const SYMBOLS = {
17
+ active: WINDOWS ? '>' : '❯',
18
+ done: WINDOWS ? '√' : '✔',
19
+ question: '?'
20
+ }
21
+
22
+ export const BACK = Symbol('back')
23
+
24
+ export const style = {
25
+ bold: (str) => CSI + '1m' + str + CSI + '22m',
26
+ cyan: (str) => CSI + '36m' + str + CSI + '39m',
27
+ dim: (str) => CSI + '2m' + str + CSI + '22m',
28
+ green: (str) => CSI + '32m' + str + CSI + '39m',
29
+ red: (str) => CSI + '31m' + str + CSI + '39m'
30
+ }
31
+
32
+ /**
33
+ * Error thrown when the user cancels a prompt with Ctrl+C.
34
+ */
35
+
36
+ export class CancelError extends Error {
37
+ constructor () {
38
+ super('cancelled')
39
+ this.name = 'CancelError'
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Ask a yes or no question; y and n answer at once, enter takes the default.
45
+ */
46
+
47
+ export function confirm (io, { message, initial = true, back = false }) {
48
+ let value = initial
49
+
50
+ return prompt(io, {
51
+ back,
52
+ arrows: true,
53
+ render (done, width) {
54
+ if (done) return answered(message, value ? 'yes' : 'no')
55
+ return hinted(question(message), initial ? '(Y/n)' : '(y/N)', width)
56
+ },
57
+ keypress (str, key) {
58
+ if (key.name === 'y') return { value: (value = true) }
59
+ if (key.name === 'n') return { value: (value = false) }
60
+ if (key.name === 'return') return { value }
61
+ }
62
+ })
63
+ }
64
+
65
+ /**
66
+ * Erase the given number of terminal rows above the cursor.
67
+ */
68
+
69
+ export function erase ({ output }, count) {
70
+ if (count) output.write(CSI + count + 'A\r' + CSI + '0J')
71
+ }
72
+
73
+ /**
74
+ * Choose any number of choices; space toggles, enter submits. `initial` is
75
+ * an array of the values selected to begin with.
76
+ */
77
+
78
+ export function multiselect (io, { message, choices, initial = [], back = false }) {
79
+ const selected = new Set(choices.map((c, i) => initial.includes(c.value) ? i : -1).filter((i) => i !== -1))
80
+ let index = 0
81
+
82
+ return prompt(io, {
83
+ back,
84
+ arrows: true,
85
+ render (done, width) {
86
+ if (done) {
87
+ const labels = choices.filter((c, i) => selected.has(i)).map((c) => c.label)
88
+ return answered(message, labels.length ? labels.join(', ') : 'none')
89
+ }
90
+
91
+ const help = back ? '(space to select, enter to confirm, esc to go back)' : '(space to select, enter to confirm)'
92
+
93
+ const hints = fitsHints(choices, '[ ] ', width)
94
+
95
+ return [
96
+ hinted(question(message), help, width),
97
+ ...choices.map((choice, i) => option(choice, i === index, selected.has(i) ? '[x] ' : '[ ] ', hints))
98
+ ].join('\n')
99
+ },
100
+ keypress (str, key) {
101
+ index = move(key, index, choices.length)
102
+
103
+ if (key.name === 'space') {
104
+ if (selected.has(index)) selected.delete(index)
105
+ else selected.add(index)
106
+ }
107
+
108
+ if (key.name === 'return') {
109
+ return { value: choices.filter((c, i) => selected.has(i)).map((c) => c.value) }
110
+ }
111
+ }
112
+ })
113
+ }
114
+
115
+ /**
116
+ * Count the terminal rows a string occupies, including wrapped lines.
117
+ */
118
+
119
+ export function rows (str, columns = Infinity) {
120
+ return str.split('\n').reduce((count, line) => {
121
+ return count + Math.max(1, Math.ceil(visible(line).length / columns))
122
+ }, 0)
123
+ }
124
+
125
+ /**
126
+ * Choose one of the choices with the arrow keys, starting on the choice
127
+ * whose value is `initial`.
128
+ */
129
+
130
+ export function select (io, { message, choices, initial, back = false }) {
131
+ let index = Math.max(0, choices.findIndex((c) => c.value === initial))
132
+
133
+ return prompt(io, {
134
+ back,
135
+ arrows: true,
136
+ render (done, width) {
137
+ if (done) return answered(message, choices[index].label)
138
+
139
+ const help = back ? '(use arrow keys, esc to go back)' : '(use arrow keys)'
140
+
141
+ const hints = fitsHints(choices, '', width)
142
+
143
+ return [
144
+ hinted(question(message), help, width),
145
+ ...choices.map((choice, i) => option(choice, i === index, '', hints))
146
+ ].join('\n')
147
+ },
148
+ keypress (str, key) {
149
+ index = move(key, index, choices.length)
150
+ if (key.name === 'return') return { value: choices[index].value }
151
+ }
152
+ })
153
+ }
154
+
155
+ /**
156
+ * Ask for text, starting with `value`; enter with nothing typed takes the
157
+ * `initial` value. A dim `hint` follows the message when it fits.
158
+ */
159
+
160
+ export function text (io, { message, hint = '', initial = '', value: start = '', validate = () => null, back = false }) {
161
+ let value = start
162
+ let error = null
163
+
164
+ return prompt(io, {
165
+ back,
166
+ render (done, width) {
167
+ if (done) return answered(message, value || initial)
168
+
169
+ const input = value || style.dim(initial)
170
+ const cursor = CSI + '7m ' + CSI + '27m'
171
+ const end = ' ' + style.dim(SYMBOLS.active) + ' ' + input + cursor
172
+ const line = (hint ? hinted(question(message), hint, width - visible(end).length) : question(message)) + end
173
+
174
+ return error ? line + '\n' + style.red(' ' + error) : line
175
+ },
176
+ keypress (str, key) {
177
+ error = null
178
+
179
+ if (key.name === 'return') {
180
+ error = validate(value || initial)
181
+ if (!error) return { value: value || initial }
182
+ } else if (key.name === 'backspace') {
183
+ value = value.slice(0, -1)
184
+ } else if (str && !key.ctrl && !key.meta && str >= ' ') {
185
+ value += str
186
+ }
187
+ }
188
+ })
189
+ }
190
+
191
+ /**
192
+ * Line for an answered prompt.
193
+ */
194
+
195
+ function answered (message, value) {
196
+ return style.green(SYMBOLS.done) + ' ' + style.bold(message) + ' ' + style.dim('·') + ' ' + style.cyan(value)
197
+ }
198
+
199
+ /**
200
+ * Line for an unanswered prompt.
201
+ */
202
+
203
+ function question (message) {
204
+ return style.cyan(SYMBOLS.question) + ' ' + style.bold(message)
205
+ }
206
+
207
+ /**
208
+ * Check if every choice fits the terminal width with its hint, so hints are
209
+ * shown for all choices or none.
210
+ */
211
+
212
+ function fitsHints (choices, prefix, width) {
213
+ return choices.every((choice) => {
214
+ const line = ' ' + prefix + choice.label + (choice.hint ? ' ' + choice.hint : '')
215
+ return line.length < width
216
+ })
217
+ }
218
+
219
+ /**
220
+ * A line followed by a dim hint, leaving the hint out when the line would
221
+ * not fit the terminal width.
222
+ */
223
+
224
+ export function hinted (main, hint, width) {
225
+ const line = main + ' ' + style.dim(hint)
226
+ return visible(line).length < width ? line : main
227
+ }
228
+
229
+ /**
230
+ * Move a list cursor with the arrow keys, wrapping around.
231
+ */
232
+
233
+ function move (key, index, length) {
234
+ if (key.name === 'up') return (index - 1 + length) % length
235
+ if (key.name === 'down') return (index + 1) % length
236
+ return index
237
+ }
238
+
239
+ /**
240
+ * Line for a choice in a list.
241
+ */
242
+
243
+ function option (choice, active, prefix, hints) {
244
+ const main = active
245
+ ? style.cyan(SYMBOLS.active + ' ' + prefix + choice.label)
246
+ : ' ' + prefix + choice.label
247
+
248
+ return hints && choice.hint ? main + ' ' + style.dim(choice.hint) : main
249
+ }
250
+
251
+ /**
252
+ * Run a prompt: render it, redraw it on every keypress, and resolve once
253
+ * `keypress` returns `{ value }`, or to BACK when going back.
254
+ */
255
+
256
+ function prompt (io, { render, keypress, back, arrows = false }) {
257
+ const { input, output } = io
258
+ const width = () => output.columns || Infinity
259
+
260
+ return new Promise((resolve, reject) => {
261
+ let last = ''
262
+ let lines = 0
263
+
264
+ function draw (done) {
265
+ const str = render(done, width())
266
+
267
+ // move back to the start of the previous render and clear it
268
+ erase(io, lines)
269
+
270
+ output.write(str + '\n')
271
+ last = str
272
+ lines = rows(str, width())
273
+ }
274
+
275
+ // terminals reflow wrapped lines on resize, so recount the previous
276
+ // render at the new width before redrawing
277
+ function onResize () {
278
+ lines = rows(last, width())
279
+ draw(false)
280
+ }
281
+
282
+ function cleanup () {
283
+ output.off('resize', onResize)
284
+ input.off('keypress', onKeypress)
285
+ if (input.isTTY) input.setRawMode(false)
286
+ input.pause()
287
+ output.write(CSI + '?25h')
288
+ }
289
+
290
+ function onKeypress (str, key = {}) {
291
+ if (key.ctrl && key.name === 'c') {
292
+ cleanup()
293
+ reject(new CancelError())
294
+ return
295
+ }
296
+
297
+ // go back, erasing this prompt
298
+ if (back && (key.name === 'escape' || (arrows && key.name === 'left'))) {
299
+ erase(io, lines)
300
+ cleanup()
301
+ io.printed = ''
302
+ resolve(BACK)
303
+ return
304
+ }
305
+
306
+ const result = keypress(str, key)
307
+
308
+ if (result && 'value' in result) {
309
+ draw(true)
310
+ cleanup()
311
+ io.printed = last
312
+ resolve(result.value)
313
+ } else {
314
+ draw(false)
315
+ }
316
+ }
317
+
318
+ // wait 100ms, not the default 500ms, to tell Esc from an escape sequence
319
+ readline.emitKeypressEvents(input, { escapeCodeTimeout: 100 })
320
+ if (input.isTTY) input.setRawMode(true)
321
+
322
+ // hide the cursor while prompting
323
+ output.write(CSI + '?25l')
324
+ input.on('keypress', onKeypress)
325
+ output.on('resize', onResize)
326
+ input.resume()
327
+ draw(false)
328
+ })
329
+ }
330
+
331
+ /**
332
+ * A string without ANSI escape codes, as it appears on screen.
333
+ */
334
+
335
+ function visible (str) {
336
+ // eslint-disable-next-line no-control-regex
337
+ return str.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
338
+ }