create-bakery 2.1.1 → 2.2.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.
package/README.md CHANGED
@@ -6,6 +6,10 @@ The scaffolder behind `bun create bakery`.
6
6
  bun create bakery my-app
7
7
  ```
8
8
 
9
+ Leave out the directory and it asks for one, offering `bakery-app`. At a
10
+ terminal it also asks whether to include the ORM and which plugins to add,
11
+ unless a flag already said.
12
+
9
13
  Writes a working [Bakery](https://github.com/obillekyle/bakery) app: a page,
10
14
  an API route that round-trips through SQLite, a registered ORM schema, and a
11
15
  `db:sync` script. Then:
@@ -20,7 +24,10 @@ bun run dev
20
24
 
21
25
  | Flag | Effect |
22
26
  | --- | --- |
27
+ | `--orm` / `--no-orm` | Include the ORM (`orm/`, `db:sync`), or leave it out |
28
+ | `--plugins <list>` | Comma-separated from `vue`, `analytics`, `dashboard`, `db-explorer`, or `none` |
23
29
  | `--name <name>` | Package name, when it should differ from the directory |
30
+ | `--yes`, `-y` | Take the defaults for anything not passed: `bakery-app`, the ORM in, no plugins |
24
31
  | `--no-install` | Write the files and stop |
25
32
  | `-h`, `--help` | Usage |
26
33
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bakery",
3
- "version": "2.1.1",
4
- "description": "Scaffold a Bakery app: bun create bakery <directory>",
3
+ "version": "2.2.0",
4
+ "description": "Scaffold a Bakery app: bun create bakery [directory]",
5
5
  "keywords": [
6
6
  "bakery",
7
7
  "bun",
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { mkdir, readdir, writeFile } from 'node:fs/promises'
4
4
  import { basename, resolve } from 'node:path'
5
- import { confirm, isInteractive, multiselect } from './prompt'
5
+ import { confirm, isInteractive, multiselect, text } from './prompt'
6
6
  import {
7
7
  dependencyRange,
8
8
  isValidAppName,
@@ -27,16 +27,19 @@ import {
27
27
  * drag along.
28
28
  */
29
29
 
30
- const HELP = `bun create bakery <directory>
30
+ /** The folder offered when none was given, and taken under `--yes`. */
31
+ export const DEFAULT_FOLDER = 'bakery-app'
32
+
33
+ const HELP = `bun create bakery [directory]
31
34
 
32
35
  Scaffold a Bakery app.
33
36
 
34
- Run it without --orm/--no-orm or --plugins and it asks, so long as you are at a
35
- terminal. Pass either and it stops asking about that one; pass --yes and it
36
- stops asking entirely.
37
+ At a terminal it asks for whatever you leave out: the directory, the ORM
38
+ (--orm/--no-orm) and the plugins (--plugins). Pass one and it stops asking
39
+ about that one; pass --yes and it stops asking entirely.
37
40
 
38
41
  Arguments:
39
- <directory> Where to create it. Also the package name, unless --name
42
+ [directory] Where to create it. Also the package name, unless --name
40
43
  is given. Use "." for the current directory.
41
44
 
42
45
  Options:
@@ -45,21 +48,30 @@ Options:
45
48
  --no-orm Leave it out. The example API route keeps posts in memory.
46
49
  --plugins <list> Comma-separated, from: ${PLUGIN_IDS.join(', ')}.
47
50
  Use --plugins none for an explicit empty set.
48
- --yes, -y Take the defaults for anything not passed (ORM in, no
49
- plugins). What a non-interactive shell does anyway.
51
+ --yes, -y Take the defaults for anything not passed: the directory
52
+ ${DEFAULT_FOLDER}, the ORM in, no plugins. Without a
53
+ terminal the ORM and plugins default the same way, and the
54
+ directory has to be given.
50
55
  --no-install Write the files and stop, without running bun install.
51
56
  -h, --help This.
52
57
 
53
58
  Examples:
59
+ bun create bakery
54
60
  bun create bakery my-app
55
61
  bun create bakery my-app --no-orm --plugins vue
56
62
  bun create bakery . --name my-app --plugins dashboard,analytics
57
63
  bun create bakery my-app --yes
58
64
  `
59
65
 
66
+ /** What a package name may hold, for the messages that refuse one. */
67
+ const NAME_RULE =
68
+ 'Use lowercase letters, digits, dots, dashes and underscores, starting with a letter or a digit'
69
+
60
70
  type Options = {
61
- dir: string
62
- name: string
71
+ /** As typed, or `null` when it was left out and is asked for. */
72
+ dir: string | null
73
+ /** `--name`, or `null` to take the directory's name. */
74
+ name: string | null
63
75
  install: boolean
64
76
  /** `null` means "not specified". Ask, or fall back to the default. */
65
77
  orm: boolean | null
@@ -171,29 +183,117 @@ export function parseArgs(
171
183
  dir = arg
172
184
  }
173
185
 
174
- if (dir === null) return { ok: false, message: HELP }
186
+ // Checked on its own, before any folder is known: no answer to the folder
187
+ // question can repair a name typed on the command line.
188
+ if (name !== null && !isValidAppName(name)) {
189
+ return {
190
+ ok: false,
191
+ message: `"${name}" is not a usable package name. ${NAME_RULE}.`,
192
+ }
193
+ }
175
194
 
176
- // `.` is the documented way to scaffold in place, and `basename(resolve('.'))`
177
- // is the containing folder's name, which is the name the user means.
178
- const resolved = resolve(dir)
179
- const appName = name ?? basename(resolved)
195
+ // Left out, the directory is asked for once the flags are read (`chooseFolder`).
196
+ if (dir === null) {
197
+ return { ok: true, options: { dir, name, install, orm, plugins, yes } }
198
+ }
180
199
 
181
- if (!isValidAppName(appName)) {
200
+ const target = resolveTarget(dir, name)
201
+ if (!target.ok) {
182
202
  return {
183
203
  ok: false,
184
204
  message:
185
- `"${appName}" is not a usable package name: lowercase letters, ` +
186
- 'digits, dot, dash and underscore only, and it may not start with a ' +
187
- 'dot or a dash. Pass --name to choose a different one.',
205
+ `"${target.name}" is not a usable package name. ${NAME_RULE}, ` +
206
+ 'or pass --name to choose a different one.',
188
207
  }
189
208
  }
190
209
 
191
210
  return {
192
211
  ok: true,
193
- options: { dir: resolved, name: appName, install, orm, plugins, yes },
212
+ options: { ...target.target, install, orm, plugins, yes },
213
+ }
214
+ }
215
+
216
+ /** Where the app goes, and the package name it is written with. */
217
+ export type Target = { dir: string; name: string }
218
+
219
+ /**
220
+ * A directory argument, and `--name` if given, as the absolute directory and
221
+ * the package name; or the name that cannot be one.
222
+ */
223
+ export function resolveTarget(
224
+ dirArg: string,
225
+ nameArg: string | null,
226
+ ): { ok: true; target: Target } | { ok: false; name: string } {
227
+ // `.` is the documented way to scaffold in place, and `basename(resolve('.'))`
228
+ // is the containing folder's name, which is the name the user means.
229
+ const dir = resolve(dirArg)
230
+ const name = nameArg ?? basename(dir)
231
+ return isValidAppName(name)
232
+ ? { ok: true, target: { dir, name } }
233
+ : { ok: false, name }
234
+ }
235
+
236
+ /** How `chooseFolder` talks to the person running it. Swapped out by tests. */
237
+ export type FolderIO = {
238
+ /** Ask for a line. `null` when there is no terminal to ask at. */
239
+ ask: ((question: string, fallback: string) => Promise<string | null>) | null
240
+ say: (line: string) => void
241
+ }
242
+
243
+ /**
244
+ * The directory, when the command line left it out.
245
+ *
246
+ * Asked for at a terminal, and asked again until the answer is usable: a
247
+ * folder whose name cannot be a package name, or one that already has files,
248
+ * is explained and asked for again rather than ending the run, since the
249
+ * person is right there to answer. `--yes` takes `DEFAULT_FOLDER` (or the
250
+ * last part of `--name`). Without a terminal there is nobody to ask, and a
251
+ * folder nobody named is not created: `'unasked'`.
252
+ *
253
+ * Returns `null` when the question was canceled.
254
+ */
255
+ export async function chooseFolder(
256
+ options: Pick<Options, 'name' | 'yes'>,
257
+ io: FolderIO,
258
+ ): Promise<Target | null | 'unasked'> {
259
+ const fallback = options.name?.split('/').pop() ?? DEFAULT_FOLDER
260
+
261
+ if (options.yes || !io.ask) {
262
+ if (!options.yes) return 'unasked'
263
+ const target = resolveTarget(fallback, options.name)
264
+ if (!target.ok) return 'unasked'
265
+ if (!(await isScaffoldable(target.target.dir))) {
266
+ io.say(notEmpty(target.target.dir))
267
+ return 'unasked'
268
+ }
269
+ return target.target
270
+ }
271
+
272
+ for (;;) {
273
+ const answer = await io.ask('Folder name', fallback)
274
+ if (answer === null) return null
275
+
276
+ const target = resolveTarget(answer, options.name)
277
+ if (!target.ok) {
278
+ io.say(`"${target.name}" is not a usable package name. ${NAME_RULE}.`)
279
+ continue
280
+ }
281
+ if (!(await isScaffoldable(target.target.dir))) {
282
+ io.say(`${answer} already has files in it. Choose another folder.`)
283
+ continue
284
+ }
285
+ return target.target
194
286
  }
195
287
  }
196
288
 
289
+ /** Why a directory given on the command line, or by `--yes`, was refused. */
290
+ function notEmpty(dir: string): string {
291
+ return (
292
+ `${dir} already has files in it. Bakery will not scaffold over an ` +
293
+ 'existing directory. Pick an empty one, or empty this one first.'
294
+ )
295
+ }
296
+
197
297
  /**
198
298
  * Fill in whatever the flags left unspecified.
199
299
  *
@@ -307,22 +407,44 @@ async function main(): Promise<number> {
307
407
  return parsed.message === HELP ? 0 : 1
308
408
  }
309
409
 
310
- const { dir, name, install } = parsed.options
311
-
312
- // Checked before the prompts, not after: asking someone three questions and
313
- // then refusing because the directory was never usable is the rudest possible
314
- // ordering.
315
- if (!(await isScaffoldable(dir))) {
316
- console.log(
317
- `${dir} already has files in it. Bakery will not scaffold over an ` +
318
- 'existing directory. Pick an empty one, or empty this one first.',
319
- )
320
- return 1
410
+ const { install } = parsed.options
411
+ const interactive = !parsed.options.yes && isInteractive()
412
+
413
+ // The directory comes first, and is checked before the other questions, not
414
+ // after: asking someone three questions and then refusing because the
415
+ // directory was never usable is the rudest possible ordering.
416
+ let target: Target
417
+ if (parsed.options.dir !== null && parsed.options.name !== null) {
418
+ target = { dir: parsed.options.dir, name: parsed.options.name }
419
+ if (!(await isScaffoldable(target.dir))) {
420
+ console.log(notEmpty(target.dir))
421
+ return 1
422
+ }
423
+ } else {
424
+ const chosen = await chooseFolder(parsed.options, {
425
+ ask: interactive ? text : null,
426
+ say: line => console.log(line),
427
+ })
428
+ if (chosen === null) {
429
+ console.log('\nCanceled. Nothing was written.')
430
+ return 130
431
+ }
432
+ if (chosen === 'unasked') {
433
+ if (!parsed.options.yes) {
434
+ console.log(
435
+ 'Name the directory to create the app in: bun create bakery <directory>. ' +
436
+ 'At a terminal it asks for one instead.',
437
+ )
438
+ }
439
+ return 1
440
+ }
441
+ target = chosen
321
442
  }
443
+ const { dir, name } = target
322
444
 
323
445
  const choices = await resolveChoices(parsed.options)
324
446
  if (!choices) {
325
- console.log('\nCancelled. Nothing was written.')
447
+ console.log('\nCanceled. Nothing was written.')
326
448
  return 130
327
449
  }
328
450
 
@@ -342,12 +464,24 @@ async function main(): Promise<number> {
342
464
  console.log(`Created ${name} in ${dir} (${summary})`)
343
465
 
344
466
  if (install) {
345
- const proc = Bun.spawn(['bun', 'install'], {
346
- cwd: dir,
347
- stdout: 'inherit',
348
- stderr: 'inherit',
349
- })
350
- const code = await proc.exited
467
+ const run = (flags: string[]) =>
468
+ Bun.spawn(['bun', 'install', ...flags], {
469
+ cwd: dir,
470
+ stdout: 'inherit',
471
+ stderr: 'inherit',
472
+ }).exited
473
+
474
+ let code = await run([])
475
+ // A release a few minutes old is on npm before Bun's cached package list
476
+ // knows it, and the app asks for this scaffolder's own minor. On
477
+ // 2026-10-04, `bun create bakery@latest` 2.1.2 wrote `^2.1.2` and the
478
+ // install failed with "No version matching" until the cache was skipped.
479
+ if (code !== 0) {
480
+ console.log(
481
+ "\nRetrying with Bun's package cache skipped, in case a release is minutes old.\n",
482
+ )
483
+ code = await run(['--no-cache'])
484
+ }
351
485
  if (code !== 0) {
352
486
  console.log(
353
487
  '\nbun install failed. The app is written. Run it again in ' +
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,102 @@ 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
265
+ }
266
+
267
+ export type TextResult =
268
+ | { kind: 'update'; value: string }
269
+ | { kind: 'submit'; value: string }
270
+ | { kind: 'cancel' }
271
+
272
+ /**
273
+ * Apply one keypress to a line of text being typed.
274
+ *
275
+ * Backspace arrives as DEL (`\x7f`) from most terminals and as `\b` from the
276
+ * Windows console, so both delete. Escape sequences (the arrows) are ignored
277
+ * rather than typed, and so is any other control character.
278
+ */
279
+ export function applyTextKey(value: string, key: string): TextResult {
280
+ if (key === KEY.ctrlC || key === KEY.ctrlD) return { kind: 'cancel' }
281
+ if (key === KEY.enter || key === KEY.enterLf) {
282
+ return { kind: 'submit', value }
283
+ }
284
+ if (key === '\x7f' || key === '\b') {
285
+ return { kind: 'update', value: Array.from(value).slice(0, -1).join('') }
286
+ }
287
+ const code = key.codePointAt(0) ?? 0
288
+ if (key.startsWith(ESC) || code < 0x20) return { kind: 'update', value }
289
+ return { kind: 'update', value: value + key }
290
+ }
291
+
292
+ export function renderText(
293
+ question: string,
294
+ fallback: string,
295
+ value: string,
296
+ ): string {
297
+ const hint = fallback ? ` ${DIM}(${fallback})${RESET}` : ''
298
+ return `${GREEN}?${RESET} ${question}${hint} ${value}`
299
+ }
300
+
301
+ /**
302
+ * Ask for a line of text. Enter on an empty line takes `fallback`. Returns
303
+ * `null` if the user canceled.
304
+ */
305
+ export async function text(
306
+ question: string,
307
+ fallback: string,
308
+ ): Promise<string | null> {
309
+ let value = ''
310
+ const draw = () =>
311
+ write(`\r${ESC}[2K${renderText(question, fallback, value)}`)
312
+ draw()
313
+
314
+ let answer: string | null = null
315
+ await readKeys(key => {
316
+ const result = applyTextKey(value, key)
317
+ if (result.kind === 'cancel') {
318
+ write('\n')
319
+ return true
320
+ }
321
+ if (result.kind === 'submit') {
322
+ answer = result.value.trim() || fallback
323
+ write(
324
+ `\r${ESC}[2K${GREEN}?${RESET} ${question} ${CYAN}${answer}${RESET}\n`,
325
+ )
326
+ return true
327
+ }
328
+ value = result.value
329
+ draw()
330
+ return false
331
+ })
332
+ return answer
231
333
  }
package/src/template.ts CHANGED
@@ -103,9 +103,19 @@ export const PLUGINS_NEEDING_ORM: readonly PluginId[] = ['db-explorer']
103
103
  * scaffolder is published in lockstep with the framework: `create-bakery@4.1.0`
104
104
  * scaffolding `^4.0.0` is the drift this avoids. Caret, so a generated app
105
105
  * picks up patches without regenerating.
106
+ *
107
+ * **The floor is this version's minor, not its patch.** A patch adds nothing
108
+ * a template could depend on, and asking for it exactly made every patch
109
+ * release uninstallable for its first minutes: create-bakery 2.1.2 reached
110
+ * npm's `latest` while the registry, and Bun's cached copy of it, still
111
+ * listed core at 2.1.1, so `^2.1.2` resolved to nothing (2026-10-04).
112
+ * `^2.1.0` takes 2.1.2 as soon as it is listed and 2.1.1 until then. A
113
+ * prerelease keeps its whole version, since `^2.0.0` would not match
114
+ * `2.0.0-rc.5` at all.
106
115
  */
107
116
  export function dependencyRange(ownVersion: string): string {
108
- return `^${ownVersion}`
117
+ const release = /^(\d+)\.(\d+)\.\d+$/.exec(ownVersion)
118
+ return release ? `^${release[1]}.${release[2]}.0` : `^${ownVersion}`
109
119
  }
110
120
 
111
121
  /**
@@ -345,8 +355,7 @@ declare module '@bakery-framework/orm/schema-registry' {
345
355
  */
346
356
  const DB_SYNC_SCRIPT = `import { SyncService } from '@bakery-framework/orm/sync'
347
357
 
348
- await SyncService.run()
349
- process.exit(0)
358
+ process.exit((await SyncService.run()) ? 0 : 1)
350
359
  `
351
360
 
352
361
  function gitignore(orm: boolean): string {
@@ -395,7 +404,10 @@ function readme(name: string, orm: boolean, plugins: PluginId[]): string {
395
404
  : '') +
396
405
  (plugins.includes('vue')
397
406
  ? '\n\n`vue` and `@vue/compiler-sfc` are direct dependencies rather than ' +
398
- 'unmet peers, so `.vue` pages compile straight after install.'
407
+ 'unmet peers, so `.vue` pages compile straight after install.' +
408
+ '\n\n`bun run build` bundles the pages into a few cached files, which ' +
409
+ '`bun run start` then serves in place of a request per component. ' +
410
+ 'Run it before starting, and again after changing a page.'
399
411
  : '') +
400
412
  '\n'
401
413
  : ''
@@ -500,6 +512,9 @@ export function templateFiles(
500
512
  // would stop at TS6053. `-b`, because the root claims no files and
501
513
  // `tsc -p` on it checks nothing while reporting success.
502
514
  typecheck: 'bakery --types && tsc -b',
515
+ // Vue pages bundled for production. Only with the plugin: without it
516
+ // the command has nothing to do.
517
+ ...(plugins.includes('vue') ? { build: 'bakery --build' } : {}),
503
518
  ...(orm
504
519
  ? {
505
520
  'db:sync': 'bun run scripts/db-sync.ts',