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 +7 -0
- package/package.json +2 -2
- package/src/index.ts +171 -37
- package/src/prompt.ts +68 -0
- package/src/template.ts +19 -4
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
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
200
|
+
const target = resolveTarget(dir, name)
|
|
201
|
+
if (!target.ok) {
|
|
182
202
|
return {
|
|
183
203
|
ok: false,
|
|
184
204
|
message:
|
|
185
|
-
`"${
|
|
186
|
-
'
|
|
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: {
|
|
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 {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
)
|
|
320
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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',
|