create-bakery 2.1.0 → 2.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-bakery",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "Scaffold a Bakery app: bun create bakery <directory>",
5
5
  "keywords": [
6
6
  "bakery",
package/src/index.ts CHANGED
@@ -322,7 +322,7 @@ async function main(): Promise<number> {
322
322
 
323
323
  const choices = await resolveChoices(parsed.options)
324
324
  if (!choices) {
325
- console.log('\nCancelled. Nothing was written.')
325
+ console.log('\nCanceled. Nothing was written.')
326
326
  return 130
327
327
  }
328
328
 
package/src/prompt.ts CHANGED
@@ -138,31 +138,70 @@ function clearLines(n: number): void {
138
138
  write(`${ESC}[0J`)
139
139
  }
140
140
 
141
- async function* keypresses(): AsyncGenerator<string> {
142
- process.stdin.setRawMode?.(true)
143
- process.stdin.resume()
144
- try {
145
- for await (const chunk of process.stdin) {
146
- // A single read can carry a whole escape sequence, and on a fast paste it
147
- // can carry several keys at once. Arrow keys are the only multi-byte
148
- // sequence handled, so they are matched first and the rest is yielded
149
- // character by character.
150
- const text = Buffer.from(chunk).toString('utf8')
151
- let i = 0
152
- while (i < text.length) {
153
- if (text.startsWith(`${ESC}[`, i) && i + 2 < text.length) {
154
- yield text.slice(i, i + 3)
155
- i += 3
156
- continue
157
- }
158
- yield text[i]!
159
- i += 1
160
- }
141
+ /**
142
+ * Split what one read of stdin delivered into keypresses.
143
+ *
144
+ * A single read can carry a whole escape sequence, and on a fast paste it can
145
+ * carry several keys at once. Arrow keys are the only multi-byte sequence
146
+ * handled, so they are matched first and the rest is split by character:
147
+ * by code point, so a character outside the basic plane stays one key.
148
+ */
149
+ export function splitKeys(text: string): string[] {
150
+ const keys: string[] = []
151
+ let i = 0
152
+ while (i < text.length) {
153
+ if (text.startsWith(`${ESC}[`, i) && i + 2 < text.length) {
154
+ keys.push(text.slice(i, i + 3))
155
+ i += 3
156
+ continue
161
157
  }
162
- } finally {
163
- process.stdin.setRawMode?.(false)
164
- process.stdin.pause()
158
+ const key = String.fromCodePoint(text.codePointAt(i)!)
159
+ keys.push(key)
160
+ i += key.length
165
161
  }
162
+ return keys
163
+ }
164
+
165
+ /**
166
+ * Hand keypresses to `onKey` until it returns true, then let go of stdin
167
+ * without closing it, so the next prompt can read from it too.
168
+ *
169
+ * Listeners, not `for await (const chunk of process.stdin)`. Leaving a
170
+ * `for await` early returns the stream's iterator, and returning a readable's
171
+ * iterator destroys the stream: in 2.1.1 the first prompt answered and the
172
+ * second died on the destroyed stdin with `AbortError: The operation was
173
+ * aborted`, on any terminal, whenever more than one question was asked.
174
+ *
175
+ * Keys after the answering one, in the same read, are dropped: they were
176
+ * typed before the next prompt was on screen.
177
+ */
178
+ function readKeys(onKey: (key: string) => boolean): Promise<void> {
179
+ const stdin = process.stdin
180
+ return new Promise(resolve => {
181
+ const finish = () => {
182
+ stdin.off('data', onData)
183
+ stdin.off('end', onEnd)
184
+ stdin.setRawMode?.(false)
185
+ stdin.pause()
186
+ resolve()
187
+ }
188
+ const onData = (chunk: Buffer | string) => {
189
+ for (const key of splitKeys(Buffer.from(chunk).toString('utf8'))) {
190
+ if (onKey(key)) return finish()
191
+ }
192
+ }
193
+ // The other end went away. Taken as Ctrl-D, which every prompt treats as
194
+ // a cancel, rather than waiting for a key that cannot come.
195
+ const onEnd = () => {
196
+ onKey(KEY.ctrlD)
197
+ finish()
198
+ }
199
+
200
+ stdin.setRawMode?.(true)
201
+ stdin.on('data', onData)
202
+ stdin.on('end', onEnd)
203
+ stdin.resume()
204
+ })
166
205
  }
167
206
 
168
207
  export async function confirm(
@@ -172,17 +211,19 @@ export async function confirm(
172
211
  const suffix = fallback ? 'Y/n' : 'y/N'
173
212
  write(`${GREEN}?${RESET} ${question} ${DIM}(${suffix})${RESET} `)
174
213
 
175
- for await (const key of keypresses()) {
214
+ let answer: boolean | null = null
215
+ await readKeys(key => {
176
216
  const result = applyConfirmKey(key, fallback)
177
- if (result.kind === 'ignore') continue
217
+ if (result.kind === 'ignore') return false
178
218
  if (result.kind === 'cancel') {
179
219
  write('\n')
180
- return null
220
+ return true
181
221
  }
222
+ answer = result.value
182
223
  write(`${CYAN}${result.value ? 'yes' : 'no'}${RESET}\n`)
183
- return result.value
184
- }
185
- return null
224
+ return true
225
+ })
226
+ return answer
186
227
  }
187
228
 
188
229
  /** Returns the chosen ids, or `null` if the user canceled. */
@@ -191,41 +232,34 @@ export async function multiselect(
191
232
  choices: Choice[],
192
233
  ): Promise<string[] | null> {
193
234
  let state: MultiselectState = { choices, cursor: 0, selected: new Set() }
194
-
195
235
  let frame = renderMultiselect(question, state)
196
236
  write(`${ESC}[?25l${frame}`) // hide the cursor while redrawing
197
237
 
238
+ let chosen: string[] | null = null
198
239
  try {
199
- for await (const key of keypresses()) {
240
+ await readKeys(key => {
200
241
  const result = applyKey(state, key)
201
- if (result.kind === 'cancel') {
202
- clearLines(frame.split('\n').length - 1)
203
- return null
204
- }
242
+ clearLines(frame.split('\n').length - 1)
243
+ if (result.kind === 'cancel') return true
205
244
 
206
- const previous = frame
207
245
  state = result.state
208
- frame = renderMultiselect(question, state)
209
-
210
246
  if (result.kind === 'submit') {
211
- clearLines(previous.split('\n').length - 1)
212
- const names = state.choices
213
- .filter(c => state.selected.has(c.id))
214
- .map(c => c.label)
247
+ const picked = state.choices.filter(c => state.selected.has(c.id))
248
+ const names = picked.map(c => c.label)
215
249
  write(
216
250
  `${GREEN}?${RESET} ${question} ` +
217
251
  `${CYAN}${names.length ? names.join(', ') : 'none'}${RESET}\n`,
218
252
  )
219
- return state.choices
220
- .filter(c => state.selected.has(c.id))
221
- .map(c => c.id)
253
+ chosen = picked.map(c => c.id)
254
+ return true
222
255
  }
223
256
 
224
- clearLines(previous.split('\n').length - 1)
257
+ frame = renderMultiselect(question, state)
225
258
  write(frame)
226
- }
259
+ return false
260
+ })
227
261
  } finally {
228
262
  write(`${ESC}[?25h`) // always give the cursor back
229
263
  }
230
- return null
264
+ return chosen
231
265
  }