create-bakery 2.1.2 → 2.2.1

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.2",
4
- "description": "Scaffold a Bakery app: bun create bakery <directory>",
3
+ "version": "2.2.1",
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,18 +407,40 @@ 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) {
@@ -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
@@ -263,3 +263,71 @@ export async function multiselect(
263
263
  }
264
264
  return chosen
265
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
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',