create-bakery 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.
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Kyle Cyrus Santos Obille
2
+
3
+ The Software is provided subject to the standard MIT License, as detailed below, with the addition of the Commons Clause v1.0.
4
+
5
+ The Commons Clause v1.0
6
+
7
+ The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
8
+
9
+ Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.
10
+
11
+ For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.
12
+
13
+ Standard MIT License
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software (subject to the Commons Clause condition above), and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # create-bakery
2
+
3
+ The scaffolder behind `bun create bakery`.
4
+
5
+ ```bash
6
+ bun create bakery my-app
7
+ ```
8
+
9
+ Writes a working [Bakery](https://github.com/obillekyle/bakery) app: a page,
10
+ an API route that round-trips through SQLite, a registered ORM schema, and a
11
+ `db:sync` script. Then:
12
+
13
+ ```bash
14
+ cd my-app
15
+ bun run db:sync
16
+ bun run dev
17
+ ```
18
+
19
+ ## Options
20
+
21
+ | Flag | Effect |
22
+ | --- | --- |
23
+ | `--name <name>` | Package name, when it should differ from the directory |
24
+ | `--no-install` | Write the files and stop |
25
+ | `-h`, `--help` | Usage |
26
+
27
+ Use `.` as the directory to scaffold in place. The positional argument is a
28
+ *path*, so its basename becomes the package name — scoped names come from
29
+ `--name`.
30
+
31
+ It refuses to scaffold into a directory that already has files in it, since that
32
+ is not undoable. A bare `.git` directory is ignored, so
33
+ `git init && bun create bakery .` works.
34
+
35
+ ## Notes
36
+
37
+ - **Bun only** — the generated app depends on Bun APIs throughout.
38
+ - This package has **no dependencies**, not even on Bakery. It writes files.
39
+
40
+ ## License
41
+
42
+ MIT with the Commons Clause v1.0 — see [LICENSE](./LICENSE).
43
+
44
+ **Not an OSI-approved licence.** The Commons Clause removes the right to *sell*
45
+ the software — meaning to charge for a product or service whose value derives
46
+ substantially from it, hosting and support included. Everything else the MIT
47
+ licence grants is unchanged: use it, modify it, ship it inside your own product.
48
+ If your organisation only permits OSI-approved dependencies, this will not pass
49
+ that check.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "create-bakery",
3
+ "version": "1.0.0",
4
+ "description": "Scaffold a Bakery app: bun create bakery <directory>",
5
+ "keywords": [
6
+ "bakery",
7
+ "bun",
8
+ "create",
9
+ "scaffold",
10
+ "starter",
11
+ "template",
12
+ "boilerplate"
13
+ ],
14
+ "author": "obillekyle",
15
+ "license": "SEE LICENSE IN LICENSE",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/obillekyle/bakery.git",
19
+ "directory": "packages/create"
20
+ },
21
+ "homepage": "https://github.com/obillekyle/bakery#readme",
22
+ "bugs": "https://github.com/obillekyle/bakery/issues",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./src/index.ts",
28
+ "bin": {
29
+ "create-bakery": "./src/index.ts"
30
+ },
31
+ "exports": {
32
+ ".": "./src/index.ts",
33
+ "./template": "./src/template.ts",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "!src/**/*.test.ts",
39
+ "!src/tests"
40
+ ],
41
+ "engines": {
42
+ "bun": ">=1.3.14"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdir, readdir, writeFile } from 'node:fs/promises'
4
+ import { basename, resolve } from 'node:path'
5
+ import { confirm, isInteractive, multiselect } from './prompt'
6
+ import {
7
+ dependencyRange,
8
+ isValidAppName,
9
+ PLUGIN_IDS,
10
+ type PluginId,
11
+ type TemplateFile,
12
+ templateFiles,
13
+ } from './template'
14
+
15
+ /**
16
+ * `bun create bakery <dir>`.
17
+ *
18
+ * `bun create x` fetches `create-x` and runs its bin with the remaining
19
+ * arguments, which is the whole reason this is a separate unscoped package
20
+ * rather than another verb on the `bakery` bin: `@bakery-framework/cli` owns that bin,
21
+ * and it is a dependency of the app you are trying to create.
22
+ *
23
+ * Deliberately dependency-free. `bun create` downloads this package on its own,
24
+ * so anything it depends on is a download the user waits through before seeing
25
+ * a single file — and the framework it scaffolds is the last thing it should
26
+ * drag along.
27
+ */
28
+
29
+ const HELP = `bun create bakery <directory>
30
+
31
+ Scaffold a Bakery app.
32
+
33
+ Run it without --orm/--no-orm or --plugins and it asks, so long as you are at a
34
+ terminal. Pass either and it stops asking about that one; pass --yes and it
35
+ stops asking entirely.
36
+
37
+ Arguments:
38
+ <directory> Where to create it. Also the package name, unless --name
39
+ is given. Use "." for the current directory.
40
+
41
+ Options:
42
+ --name <name> Package name, when it should differ from the directory.
43
+ --orm Include the ORM: orm/, db:sync, @bakery-framework/orm.
44
+ --no-orm Leave it out. The example API route keeps posts in memory.
45
+ --plugins <list> Comma-separated, from: ${PLUGIN_IDS.join(', ')}.
46
+ Use --plugins none for an explicit empty set.
47
+ --yes, -y Take the defaults for anything not passed (ORM in, no
48
+ plugins). What a non-interactive shell does anyway.
49
+ --no-install Write the files and stop, without running bun install.
50
+ -h, --help This.
51
+
52
+ Examples:
53
+ bun create bakery my-app
54
+ bun create bakery my-app --no-orm --plugins vue
55
+ bun create bakery . --name my-app --plugins dashboard,analytics
56
+ bun create bakery my-app --yes
57
+ `
58
+
59
+ type Options = {
60
+ dir: string
61
+ name: string
62
+ install: boolean
63
+ /** `null` means "not specified" — ask, or fall back to the default. */
64
+ orm: boolean | null
65
+ plugins: PluginId[] | null
66
+ yes: boolean
67
+ }
68
+
69
+ /**
70
+ * Parse one `--plugins` value into the ids it names.
71
+ *
72
+ * Split out of `parseArgs` because it is the only flag that validates rather
73
+ * than assigns, and inlining it put the loop over the complexity limit — which
74
+ * is the rule doing its job: a `for` over argv should read as a dispatch table.
75
+ */
76
+ function parsePlugins(
77
+ value: string,
78
+ ): { ok: true; plugins: PluginId[] } | { ok: false; message: string } {
79
+ // `none` rather than an empty string, so "I want no plugins" is something you
80
+ // can state — an empty `--plugins=` reads like a mistake and is treated as one
81
+ // by the caller, which rejects an empty value before reaching here.
82
+ if (value === 'none') return { ok: true, plugins: [] }
83
+
84
+ const requested = value
85
+ .split(',')
86
+ .map(s => s.trim())
87
+ .filter(Boolean)
88
+
89
+ const unknown = requested.filter(p => !PLUGIN_IDS.includes(p as PluginId))
90
+ if (unknown.length) {
91
+ return {
92
+ ok: false,
93
+ message:
94
+ `Unknown plugin${unknown.length > 1 ? 's' : ''}: ` +
95
+ `${unknown.join(', ')}. Available: ${PLUGIN_IDS.join(', ')}.`,
96
+ }
97
+ }
98
+
99
+ // De-duplicated and put in a fixed order, so `--plugins dashboard,vue` and
100
+ // `--plugins vue,dashboard` generate byte-identical apps.
101
+ return { ok: true, plugins: PLUGIN_IDS.filter(id => requested.includes(id)) }
102
+ }
103
+
104
+ /**
105
+ * Parse argv into options, or return a message to print and exit on.
106
+ *
107
+ * Returns rather than throws for a *usage* problem: a bad flag is a thing the
108
+ * user typed, and answering it with a stack trace teaches nothing. Throwing is
109
+ * reserved for a failure of the scaffolding itself.
110
+ */
111
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: argv dispatcher — one branch per flag
112
+ export function parseArgs(
113
+ argv: string[],
114
+ ): { ok: true; options: Options } | { ok: false; message: string } {
115
+ let dir: string | null = null
116
+ let name: string | null = null
117
+ let install = true
118
+ let orm: boolean | null = null
119
+ let plugins: PluginId[] | null = null
120
+ let yes = false
121
+
122
+ for (let i = 0; i < argv.length; i++) {
123
+ const arg = argv[i]
124
+
125
+ if (arg === '-h' || arg === '--help') return { ok: false, message: HELP }
126
+
127
+ if (arg === '--no-install') {
128
+ install = false
129
+ continue
130
+ }
131
+
132
+ if (arg === '--yes' || arg === '-y') {
133
+ yes = true
134
+ continue
135
+ }
136
+
137
+ if (arg === '--orm' || arg === '--no-orm') {
138
+ orm = arg === '--orm'
139
+ continue
140
+ }
141
+
142
+ if (arg === '--plugins' || arg.startsWith('--plugins=')) {
143
+ const value = arg.startsWith('--plugins=') ? arg.slice(10) : argv[++i]
144
+ if (!value) {
145
+ return {
146
+ ok: false,
147
+ message: `--plugins needs a value: ${PLUGIN_IDS.join(', ')}, or none.`,
148
+ }
149
+ }
150
+ const parsed = parsePlugins(value)
151
+ if (!parsed.ok) return parsed
152
+ plugins = parsed.plugins
153
+ continue
154
+ }
155
+
156
+ if (arg === '--name' || arg.startsWith('--name=')) {
157
+ const value = arg.startsWith('--name=') ? arg.slice(7) : argv[++i]
158
+ if (!value) return { ok: false, message: '--name needs a value.' }
159
+ name = value
160
+ continue
161
+ }
162
+
163
+ if (arg.startsWith('-')) {
164
+ return { ok: false, message: `Unknown option: ${arg}\n\n${HELP}` }
165
+ }
166
+
167
+ if (dir !== null) {
168
+ return { ok: false, message: `Unexpected argument: ${arg}\n\n${HELP}` }
169
+ }
170
+ dir = arg
171
+ }
172
+
173
+ if (dir === null) return { ok: false, message: HELP }
174
+
175
+ // `.` is the documented way to scaffold in place, and `basename(resolve('.'))`
176
+ // is the containing folder's name — which is the name the user means.
177
+ const resolved = resolve(dir)
178
+ const appName = name ?? basename(resolved)
179
+
180
+ if (!isValidAppName(appName)) {
181
+ return {
182
+ ok: false,
183
+ message:
184
+ `"${appName}" is not a usable package name: lowercase letters, ` +
185
+ 'digits, dot, dash and underscore only, and it may not start with a ' +
186
+ 'dot or a dash. Pass --name to choose a different one.',
187
+ }
188
+ }
189
+
190
+ return {
191
+ ok: true,
192
+ options: { dir: resolved, name: appName, install, orm, plugins, yes },
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Fill in whatever the flags left unspecified.
198
+ *
199
+ * Asks only when there is a terminal on both ends and `--yes` was not passed.
200
+ * A pipe, a CI runner or a `--yes` takes the defaults — ORM in, no plugins —
201
+ * which is what `bun create bakery my-app` has always produced, so adding the
202
+ * prompts changed no existing invocation.
203
+ *
204
+ * Returns `null` when the user cancels, which is a distinct outcome from
205
+ * "chose nothing" and has to stay that way: Ctrl-C should not scaffold.
206
+ */
207
+ export async function resolveChoices(
208
+ options: Options,
209
+ ): Promise<{ orm: boolean; plugins: PluginId[] } | null> {
210
+ const interactive = !options.yes && isInteractive()
211
+
212
+ let orm = options.orm
213
+ if (orm === null) {
214
+ if (!interactive) orm = true
215
+ else {
216
+ const answer = await confirm('Include the ORM?', true)
217
+ if (answer === null) return null
218
+ orm = answer
219
+ }
220
+ }
221
+
222
+ let plugins = options.plugins
223
+ if (plugins === null) {
224
+ if (!interactive) plugins = []
225
+ else {
226
+ const chosen = await multiselect('Plugins', [
227
+ { id: 'vue', label: 'vue', hint: 'single-file components' },
228
+ { id: 'analytics', label: 'analytics', hint: 'request metrics' },
229
+ { id: 'dashboard', label: 'dashboard', hint: 'admin console' },
230
+ ])
231
+ if (chosen === null) return null
232
+ plugins = PLUGIN_IDS.filter(id => chosen.includes(id))
233
+ }
234
+ }
235
+
236
+ return { orm, plugins }
237
+ }
238
+
239
+ /**
240
+ * True when `dir` does not exist, or exists and holds nothing that would be
241
+ * overwritten.
242
+ *
243
+ * Scaffolding is the one operation where "the directory already had something
244
+ * in it" is almost always a mistake, and it is not undoable — so this refuses
245
+ * rather than merges or prompts. `.git` and the editor droppings people
246
+ * routinely create a directory with are ignored, because refusing on those
247
+ * makes `git init && bun create bakery .` fail for no reason.
248
+ */
249
+ export async function isScaffoldable(dir: string): Promise<boolean> {
250
+ const IGNORED = new Set(['.git', '.gitkeep', '.DS_Store', 'Thumbs.db'])
251
+
252
+ let entries: string[]
253
+ try {
254
+ entries = await readdir(dir)
255
+ } catch {
256
+ // Does not exist, which is the common case and the good one. A permission
257
+ // error also lands here and is caught properly by the write that follows —
258
+ // reporting it as "not empty" would be a worse message than the real one.
259
+ return true
260
+ }
261
+
262
+ return entries.every(entry => IGNORED.has(entry))
263
+ }
264
+
265
+ /** Write the template. Directories are created as needed. */
266
+ export async function writeTemplate(
267
+ dir: string,
268
+ files: TemplateFile[],
269
+ ): Promise<void> {
270
+ for (const file of files) {
271
+ const target = resolve(dir, file.path)
272
+ await mkdir(resolve(target, '..'), { recursive: true })
273
+ await writeFile(target, file.contents)
274
+ }
275
+ }
276
+
277
+ /**
278
+ * This package's own version, which the generated dependency range follows.
279
+ *
280
+ * Exported only so a test can prove it still reads the right file: the relative
281
+ * URL breaks silently if this module moves, and the failure is a generated app
282
+ * pinned to the wrong major with every other test still green.
283
+ */
284
+ export async function ownVersion(): Promise<string> {
285
+ const pkg = await Bun.file(new URL('../package.json', import.meta.url)).json()
286
+ return pkg.version
287
+ }
288
+
289
+ async function main(): Promise<number> {
290
+ const parsed = parseArgs(process.argv.slice(2))
291
+
292
+ if (!parsed.ok) {
293
+ // The only console use in this package, and the reason the framework's
294
+ // no-console rule scopes itself to server code: this is a CLI whose entire
295
+ // output is for a human at a terminal, with no logger to route it through.
296
+ console.log(parsed.message)
297
+ return parsed.message === HELP ? 0 : 1
298
+ }
299
+
300
+ const { dir, name, install } = parsed.options
301
+
302
+ // Checked before the prompts, not after: asking someone three questions and
303
+ // then refusing because the directory was never usable is the rudest possible
304
+ // ordering.
305
+ if (!(await isScaffoldable(dir))) {
306
+ console.log(
307
+ `${dir} already has files in it. Bakery will not scaffold over an ` +
308
+ 'existing directory — pick an empty one, or empty this one first.',
309
+ )
310
+ return 1
311
+ }
312
+
313
+ const choices = await resolveChoices(parsed.options)
314
+ if (!choices) {
315
+ console.log('\nCancelled. Nothing was written.')
316
+ return 130
317
+ }
318
+
319
+ const files = templateFiles(
320
+ name,
321
+ dependencyRange(await ownVersion()),
322
+ choices,
323
+ )
324
+ await writeTemplate(dir, files)
325
+
326
+ const summary = [
327
+ choices.orm ? 'with the ORM' : 'without the ORM',
328
+ choices.plugins.length ? `plugins: ${choices.plugins.join(', ')}` : null,
329
+ ]
330
+ .filter(Boolean)
331
+ .join(', ')
332
+ console.log(`Created ${name} in ${dir} (${summary})`)
333
+
334
+ if (install) {
335
+ const proc = Bun.spawn(['bun', 'install'], {
336
+ cwd: dir,
337
+ stdout: 'inherit',
338
+ stderr: 'inherit',
339
+ })
340
+ const code = await proc.exited
341
+ if (code !== 0) {
342
+ console.log(
343
+ '\nbun install failed. The app is written — run it again in ' +
344
+ `${dir} once the problem is fixed.`,
345
+ )
346
+ return code
347
+ }
348
+ }
349
+
350
+ const cd = dir === process.cwd() ? '' : ` cd ${basename(dir)}\n`
351
+ console.log(
352
+ `\nNext:\n\n${cd}${install ? '' : ' bun install\n'}` +
353
+ `${choices.orm ? ' bun run db:sync\n' : ''} bun run dev\n`,
354
+ )
355
+
356
+ return 0
357
+ }
358
+
359
+ if (import.meta.main) process.exit(await main())
package/src/prompt.ts ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The interactive half of `bun create bakery`, written from scratch.
3
+ *
4
+ * Every prompt library worth using is a dependency, and this package declares
5
+ * **none** on purpose — `bun create` downloads it standalone, so anything it
6
+ * pulls in is a download the user waits through before seeing a single file.
7
+ * Two prompts is less code than justifying the exception.
8
+ *
9
+ * The key handling is separated from the terminal I/O for the same reason
10
+ * `template.ts` is separated from `index.ts`: a state machine can be tested
11
+ * exhaustively without a TTY, and the driver below is then thin enough to read
12
+ * in one sitting.
13
+ */
14
+
15
+ const ESC = '\x1b'
16
+ const KEY = {
17
+ up: `${ESC}[A`,
18
+ down: `${ESC}[B`,
19
+ enter: '\r',
20
+ enterLf: '\n',
21
+ space: ' ',
22
+ ctrlC: '\x03',
23
+ ctrlD: '\x04',
24
+ } as const
25
+
26
+ export type Choice = { id: string; label: string; hint?: string }
27
+
28
+ export type MultiselectState = {
29
+ choices: Choice[]
30
+ cursor: number
31
+ selected: Set<string>
32
+ }
33
+
34
+ export type KeyResult =
35
+ | { kind: 'update'; state: MultiselectState }
36
+ | { kind: 'submit'; state: MultiselectState }
37
+ | { kind: 'cancel' }
38
+
39
+ /**
40
+ * Apply one keypress to a multiselect.
41
+ *
42
+ * Pure, and it returns a *new* set rather than mutating: the renderer is called
43
+ * with the result, and a mutated set would make a stale render indistinguishable
44
+ * from a fresh one while debugging.
45
+ *
46
+ * The cursor wraps. `a` toggles everything, which is the shortcut people reach
47
+ * for when the answer is "all of them" and is cheaper to support than to
48
+ * explain the absence of.
49
+ */
50
+ export function applyKey(state: MultiselectState, key: string): KeyResult {
51
+ if (key === KEY.ctrlC || key === KEY.ctrlD) return { kind: 'cancel' }
52
+
53
+ const total = state.choices.length
54
+ const next = (cursor: number, selected: Set<string>): KeyResult => ({
55
+ kind: 'update',
56
+ state: { ...state, cursor, selected },
57
+ })
58
+
59
+ if (key === KEY.up) {
60
+ return next((state.cursor - 1 + total) % total, state.selected)
61
+ }
62
+ if (key === KEY.down) {
63
+ return next((state.cursor + 1) % total, state.selected)
64
+ }
65
+ if (key === KEY.space) {
66
+ const selected = new Set(state.selected)
67
+ const id = state.choices[state.cursor]!.id
68
+ if (!selected.delete(id)) selected.add(id)
69
+ return next(state.cursor, selected)
70
+ }
71
+ if (key === 'a' || key === 'A') {
72
+ const all = state.selected.size === total
73
+ return next(
74
+ state.cursor,
75
+ all ? new Set() : new Set(state.choices.map(c => c.id)),
76
+ )
77
+ }
78
+ if (key === KEY.enter || key === KEY.enterLf) {
79
+ return { kind: 'submit', state }
80
+ }
81
+ return { kind: 'update', state }
82
+ }
83
+
84
+ /** Apply one keypress to a yes/no. `null` means "not answered yet". */
85
+ export function applyConfirmKey(
86
+ key: string,
87
+ fallback: boolean,
88
+ ):
89
+ | { kind: 'answer'; value: boolean }
90
+ | { kind: 'cancel' }
91
+ | { kind: 'ignore' } {
92
+ if (key === KEY.ctrlC || key === KEY.ctrlD) return { kind: 'cancel' }
93
+ if (key === 'y' || key === 'Y') return { kind: 'answer', value: true }
94
+ if (key === 'n' || key === 'N') return { kind: 'answer', value: false }
95
+ if (key === KEY.enter || key === KEY.enterLf) {
96
+ return { kind: 'answer', value: fallback }
97
+ }
98
+ return { kind: 'ignore' }
99
+ }
100
+
101
+ const DIM = `${ESC}[2m`
102
+ const CYAN = `${ESC}[36m`
103
+ const GREEN = `${ESC}[32m`
104
+ const RESET = `${ESC}[0m`
105
+
106
+ export function renderMultiselect(
107
+ question: string,
108
+ state: MultiselectState,
109
+ ): string {
110
+ const lines = state.choices.map((choice, i) => {
111
+ const here = i === state.cursor
112
+ const box = state.selected.has(choice.id) ? `${GREEN}◉${RESET}` : '◯'
113
+ const pointer = here ? `${CYAN}❯${RESET}` : ' '
114
+ const label = here ? `${CYAN}${choice.label}${RESET}` : choice.label
115
+ const hint = choice.hint ? ` ${DIM}${choice.hint}${RESET}` : ''
116
+ return `${pointer} ${box} ${label}${hint}`
117
+ })
118
+
119
+ return (
120
+ `${GREEN}?${RESET} ${question}` +
121
+ ` ${DIM}(↑↓ move, space toggle, a all, enter confirm)${RESET}\n` +
122
+ `${lines.join('\n')}\n`
123
+ )
124
+ }
125
+
126
+ /** True when both ends of the terminal are real, which is what raw mode needs. */
127
+ export function isInteractive(): boolean {
128
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
129
+ }
130
+
131
+ function write(text: string): void {
132
+ process.stdout.write(text)
133
+ }
134
+
135
+ /** Move the cursor up `n` lines and clear from there down. */
136
+ function clearLines(n: number): void {
137
+ if (n > 0) write(`${ESC}[${n}A`)
138
+ write(`${ESC}[0J`)
139
+ }
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
+ }
161
+ }
162
+ } finally {
163
+ process.stdin.setRawMode?.(false)
164
+ process.stdin.pause()
165
+ }
166
+ }
167
+
168
+ export async function confirm(
169
+ question: string,
170
+ fallback: boolean,
171
+ ): Promise<boolean | null> {
172
+ const suffix = fallback ? 'Y/n' : 'y/N'
173
+ write(`${GREEN}?${RESET} ${question} ${DIM}(${suffix})${RESET} `)
174
+
175
+ for await (const key of keypresses()) {
176
+ const result = applyConfirmKey(key, fallback)
177
+ if (result.kind === 'ignore') continue
178
+ if (result.kind === 'cancel') {
179
+ write('\n')
180
+ return null
181
+ }
182
+ write(`${CYAN}${result.value ? 'yes' : 'no'}${RESET}\n`)
183
+ return result.value
184
+ }
185
+ return null
186
+ }
187
+
188
+ /** Returns the chosen ids, or `null` if the user cancelled. */
189
+ export async function multiselect(
190
+ question: string,
191
+ choices: Choice[],
192
+ ): Promise<string[] | null> {
193
+ let state: MultiselectState = { choices, cursor: 0, selected: new Set() }
194
+
195
+ let frame = renderMultiselect(question, state)
196
+ write(`${ESC}[?25l${frame}`) // hide the cursor while redrawing
197
+
198
+ try {
199
+ for await (const key of keypresses()) {
200
+ const result = applyKey(state, key)
201
+ if (result.kind === 'cancel') {
202
+ clearLines(frame.split('\n').length - 1)
203
+ return null
204
+ }
205
+
206
+ const previous = frame
207
+ state = result.state
208
+ frame = renderMultiselect(question, state)
209
+
210
+ 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)
215
+ write(
216
+ `${GREEN}?${RESET} ${question} ` +
217
+ `${CYAN}${names.length ? names.join(', ') : 'none'}${RESET}\n`,
218
+ )
219
+ return state.choices
220
+ .filter(c => state.selected.has(c.id))
221
+ .map(c => c.id)
222
+ }
223
+
224
+ clearLines(previous.split('\n').length - 1)
225
+ write(frame)
226
+ }
227
+ } finally {
228
+ write(`${ESC}[?25h`) // always give the cursor back
229
+ }
230
+ return null
231
+ }
@@ -0,0 +1,474 @@
1
+ /**
2
+ * The app `bun create bakery` writes, as data.
3
+ *
4
+ * Separated from the I/O in `index.ts` so the tests can assert on what gets
5
+ * written without a filesystem, and so the one interesting invariant is
6
+ * checkable: **every `@bakery-framework/*` specifier below resolves through an
7
+ * *enumerated* export, never the `"./*"` wildcard.** That wildcard is a
8
+ * deprecation ramp with one release to live (MONOREPO.md), so a template that
9
+ * leaned on it would generate apps that break on its removal — and it would
10
+ * break them silently, because the wildcard resolves fine today.
11
+ *
12
+ * Derived from `apps/starter`, which is the honest reference: written against
13
+ * public entry points only and booted in CI. The differences are the ones that
14
+ * have to differ — real dependency ranges instead of `workspace:*`, the
15
+ * `bakery` bin instead of a relative path into the repo, and a `.gitignore`.
16
+ */
17
+
18
+ /** A file to write, relative to the target directory. */
19
+ export type TemplateFile = {
20
+ path: string
21
+ contents: string
22
+ }
23
+
24
+ /** The plugins `--plugins` accepts, in the order they are registered. */
25
+ export const PLUGIN_IDS = ['vue', 'analytics', 'dashboard'] as const
26
+ export type PluginId = (typeof PLUGIN_IDS)[number]
27
+
28
+ export type TemplateOptions = {
29
+ /** Generate `orm/` and depend on `@bakery-framework/orm`. */
30
+ orm: boolean
31
+ plugins: PluginId[]
32
+ }
33
+
34
+ /**
35
+ * What each plugin costs a generated app.
36
+ *
37
+ * `peers` matters and is easy to miss: `@bakery-framework/plugin-vue` declares `vue` and
38
+ * `@vue/compiler-sfc` as **peer** dependencies, so scaffolding the plugin
39
+ * without them produces an app that installs cleanly and then fails the first
40
+ * time it compiles an SFC. They go into the generated `dependencies`, because a
41
+ * scaffolded app is an application — it should pin what it needs rather than
42
+ * inherit an unmet peer warning.
43
+ */
44
+ const PLUGINS: Record<
45
+ PluginId,
46
+ { pkg: string; import: string; call: string; peers?: Record<string, string> }
47
+ > = {
48
+ vue: {
49
+ pkg: '@bakery-framework/plugin-vue',
50
+ import: 'vuePlugin',
51
+ call: 'vuePlugin()',
52
+ peers: { vue: '^3.5.0', '@vue/compiler-sfc': '^3.5.0' },
53
+ },
54
+ analytics: {
55
+ pkg: '@bakery-framework/plugin-analytics',
56
+ import: 'analyticsPlugin',
57
+ call: 'analyticsPlugin()',
58
+ },
59
+ dashboard: {
60
+ pkg: '@bakery-framework/plugin-dashboard',
61
+ import: 'dashboardPlugin',
62
+ // Deliberately no `authorize`. Omitted, the dashboard limits itself to
63
+ // loopback in development and denies in production, so a scaffolded app is
64
+ // never born with an open console. `apps/example` passes `() => true`
65
+ // because it is a local demo; a generated app is not.
66
+ call: 'dashboardPlugin()',
67
+ },
68
+ }
69
+
70
+ /**
71
+ * The version range the generated `package.json` asks for.
72
+ *
73
+ * Derived from this package's own version rather than written out, because the
74
+ * scaffolder is published in lockstep with the framework: `create-bakery@4.1.0`
75
+ * scaffolding `^4.0.0` is the drift this avoids. Caret, so a generated app
76
+ * picks up patches without regenerating.
77
+ */
78
+ export function dependencyRange(ownVersion: string): string {
79
+ return `^${ownVersion}`
80
+ }
81
+
82
+ /**
83
+ * A valid npm package name, scoped or not.
84
+ *
85
+ * Stricter than npm on the parts that are worth being strict about — no
86
+ * uppercase, no leading dot or dash — because the generated `name` field is
87
+ * the only place this lands, and a name npm would reject surfaces as a
88
+ * confusing `bun install` failure several steps after the mistake.
89
+ *
90
+ * Scopes are accepted, and are reachable only through `--name`. The positional
91
+ * argument is a *directory path*, so `bun create bakery @co/app` means a nested
92
+ * directory whose basename is `app` — that is what a path argument means, and
93
+ * quietly treating it as a scoped package name instead would be a guess.
94
+ */
95
+ export function isValidAppName(name: string): boolean {
96
+ const SEGMENT = '[a-z0-9][a-z0-9._-]*'
97
+ return (
98
+ new RegExp(`^(?:@${SEGMENT}/)?${SEGMENT}$`).test(name) && name.length <= 214
99
+ )
100
+ }
101
+
102
+ function serverConfig(plugins: PluginId[]): string {
103
+ const imports = plugins
104
+ .map(id => `import ${PLUGINS[id].import} from '${PLUGINS[id].pkg}'\n`)
105
+ .join('')
106
+
107
+ if (!plugins.length) {
108
+ return `import { defineConfig } from '@bakery-framework/core'
109
+
110
+ export default defineConfig({
111
+ root: 'src',
112
+ port: 3000,
113
+ })
114
+ `
115
+ }
116
+
117
+ const calls = plugins.map(id => ` ${PLUGINS[id].call},\n`).join('')
118
+ const dashboardNote = plugins.includes('dashboard')
119
+ ? ` // The dashboard authenticates nobody itself — the app does, because it is\n` +
120
+ ` // the thing that knows who its users are. With no \`authorize\`, access is\n` +
121
+ ` // loopback-only in development and denied in production:\n` +
122
+ ` //\n` +
123
+ ` // dashboardPlugin({ authorize: req => req.session.get('role') === 'admin' })\n`
124
+ : ''
125
+
126
+ return `import { defineConfig } from '@bakery-framework/core'
127
+ ${imports}
128
+ export default defineConfig({
129
+ root: 'src',
130
+ port: 3000,
131
+ ${dashboardNote} plugins: [
132
+ ${calls} ],
133
+ })
134
+ `
135
+ }
136
+
137
+ const INDEX_PAGE = `export default function Home() {
138
+ return (
139
+ <html lang="en">
140
+ <head>
141
+ <title>{{name}}</title>
142
+ </head>
143
+ <body>
144
+ <h1>{{name}}</h1>
145
+ <p>Edit <code>src/index.tsx</code> and save — the page reloads itself.</p>
146
+ <p id="count">loading…</p>
147
+ <script src="/script.js" type="module"></script>
148
+ </body>
149
+ </html>
150
+ )
151
+ }
152
+ `
153
+
154
+ const API_ROUTE = `import { defineRoute, response } from '@bakery-framework/core'
155
+ import DB from '@bakery-framework/orm'
156
+
157
+ // The type parameter declares the body's shape: body.title / body.slug /
158
+ // body.body are strings below, while undeclared keys stay reachable. It states
159
+ // the contract — it does not validate it. The body is still client input.
160
+ export default defineRoute<{ title: string; slug: string; body: string }>(
161
+ async (req, body) => {
162
+ if (req.method === 'POST') {
163
+ await DB.Insert.into('posts')
164
+ .values({
165
+ authorId: 1,
166
+ title: body.title,
167
+ slug: body.slug,
168
+ body: body.body,
169
+ })
170
+ .run()
171
+ return response.json.success('created')
172
+ }
173
+
174
+ const posts = await DB.from('posts').selectAll('posts').array()
175
+ return response.json.success('ok', posts)
176
+ },
177
+ )
178
+ `
179
+
180
+ /**
181
+ * The same route without a database.
182
+ *
183
+ * Kept to the same shape — `defineRoute`, a typed body, one JSON envelope, and
184
+ * `data` as an array — so the client script below is identical either way and
185
+ * the two templates do not drift into demonstrating different things.
186
+ */
187
+ const API_ROUTE_NO_ORM = `import { defineRoute, response } from '@bakery-framework/core'
188
+
189
+ // In memory, and therefore per process: a cluster (\`--threads N\`) gives each
190
+ // worker its own copy. That is the point at which you want the ORM — scaffold
191
+ // with it, or add @bakery-framework/orm later.
192
+ const posts: { title: string }[] = []
193
+
194
+ // The type parameter declares the body's shape. It states the contract — it
195
+ // does not validate it. The body is still client input.
196
+ export default defineRoute<{ title: string }>(async (req, body) => {
197
+ if (req.method === 'POST') {
198
+ posts.push({ title: body.title })
199
+ return response.json.success('created')
200
+ }
201
+
202
+ return response.json.success('ok', posts)
203
+ })
204
+ `
205
+
206
+ const CLIENT_SCRIPT = `const res = await fetch('/api/notes')
207
+ const json = await res.json()
208
+ const el = document.getElementById('count')
209
+ if (el) el.textContent = \`\${json.data?.length ?? 0} posts\`
210
+ `
211
+
212
+ const ORM_SCHEMA = `import { Field, table } from '@bakery-framework/orm'
213
+
214
+ export const users = table('users', {
215
+ id: Field.Primary(),
216
+ username: Field.Varchar(64, null),
217
+ email: Field.Varchar(255, null),
218
+ createdAt: Field.Date.now(),
219
+ })
220
+
221
+ export const posts = table('posts', {
222
+ // The reference lives on the column it constrains, and is always an integer
223
+ // because Field.Primary() always is.
224
+ id: Field.Primary(),
225
+ authorId: Field.Foreign(users.id, { onDelete: 'CASCADE' }),
226
+ title: Field.Varchar(255, null),
227
+ slug: Field.Varchar(255, null),
228
+ // Sized because it has a default: MySQL refuses a literal DEFAULT on TEXT.
229
+ body: Field.Varchar(8192, ''),
230
+ published: Field.Int(0),
231
+ createdAt: Field.Date.now(),
232
+ })
233
+ `
234
+
235
+ const ORM_INDEXES = `import { Field } from '@bakery-framework/orm'
236
+ import { posts, users } from './tables'
237
+
238
+ export const usernameUniq = Field.Unique(users.username)
239
+ export const slugUniq = Field.Unique(posts.slug)
240
+ export const postsByAuthor = Field.Index(posts.authorId)
241
+ `
242
+
243
+ /**
244
+ * The `declare module` block is the whole point of this file: it is what makes
245
+ * the ORM typed. Without it everything still runs and typechecks, the columns
246
+ * are just permissive `any` — which is a quiet enough failure that it is worth
247
+ * generating rather than documenting.
248
+ */
249
+
250
+ const ORM_VIEWS = `import { view } from '@bakery-framework/orm'
251
+ import { posts } from './tables'
252
+
253
+ // A view is a stored SELECT the database treats as a read-only table. Borrowing
254
+ // the source table's columns rather than restating them keeps the two in step.
255
+ export const publishedPosts = view(
256
+ 'published_posts',
257
+ posts,
258
+ 'SELECT * FROM posts WHERE published = 1',
259
+ )
260
+ `
261
+
262
+ const ORM_INDEX = `import type { InferOptionals, InferSchema, InferViews } from '@bakery-framework/orm'
263
+ import * as tables from './tables'
264
+ import * as views from './views'
265
+
266
+ export * from './tables'
267
+ export * from './views'
268
+ export * from './indexes'
269
+
270
+ // Tables *and* views: InferSchema reads both, and InferViews reads the views
271
+ // specifically -- that is what stops DB.Insert.into() targeting one.
272
+ type Model = typeof tables & typeof views
273
+
274
+ declare module '@bakery-framework/orm/schema-registry' {
275
+ interface SchemaRegistry {
276
+ schema: {
277
+ DBSchema: InferSchema<Model>
278
+ DBOptionals: InferOptionals<Model>
279
+ Views: InferViews<Model>
280
+ }
281
+ }
282
+ }
283
+ `
284
+
285
+ /**
286
+ * Schema sync as a script rather than a package script pointing into
287
+ * `node_modules`.
288
+ *
289
+ * `bakery --sync` is not this: it syncs and then *boots the server*, which is
290
+ * the wrong shape for a `db:sync` you run in a deploy step. The CLI's own
291
+ * standalone path is `import.meta.main` inside `@bakery-framework/orm/sync`, so this
292
+ * reproduces exactly what that guard does, through the enumerated export.
293
+ */
294
+ const DB_SYNC_SCRIPT = `import { SyncService } from '@bakery-framework/orm/sync'
295
+
296
+ await SyncService.run()
297
+ process.exit(0)
298
+ `
299
+
300
+ function gitignore(orm: boolean): string {
301
+ // `bakery/` only exists once something opens a database, so an app scaffolded
302
+ // without the ORM does not get a rule for a directory it will never have.
303
+ const data = orm
304
+ ? '\n# `bakery/` holds server.db and backups/. Not tracked either, but do not\n' +
305
+ '# delete it: nothing regenerates what is in there.\nbakery/\n'
306
+ : ''
307
+
308
+ return `node_modules
309
+
310
+ # Disposable — the framework deletes it wholesale on every version bump and
311
+ # dev<->prod switch.
312
+ .cache
313
+ ${data}`
314
+ }
315
+
316
+ function readme(name: string, orm: boolean, plugins: PluginId[]): string {
317
+ const sync = orm
318
+ ? 'bun run db:sync # create the tables in orm/tables.ts\n'
319
+ : ''
320
+
321
+ const ormRows = orm
322
+ ? '| `orm/tables.ts` | Table definitions. Run `bun run db:sync` after editing. |\n' +
323
+ '| `orm/views.ts` | View definitions — stored SELECTs, read-only. |\n' +
324
+ "| `orm/index.ts` | Registers the schema with the ORM's types. Without its `declare module` block the ORM still works, untyped. |\n"
325
+ : ''
326
+
327
+ const pluginSection = plugins.length
328
+ ? `\n## Plugins\n\nRegistered in \`server.config.ts\`:\n\n` +
329
+ plugins.map(id => `- \`${PLUGINS[id].pkg}\``).join('\n') +
330
+ (plugins.includes('dashboard')
331
+ ? '\n\nThe dashboard is loopback-only in development and denied in production ' +
332
+ 'until you give it an `authorize` predicate.'
333
+ : '') +
334
+ (plugins.includes('vue')
335
+ ? '\n\n`vue` and `@vue/compiler-sfc` are direct dependencies rather than ' +
336
+ 'unmet peers, so `.vue` pages compile straight after install.'
337
+ : '') +
338
+ '\n'
339
+ : ''
340
+
341
+ const noOrm = orm
342
+ ? ''
343
+ : '\nScaffolded without the ORM. `src/api/notes.ts` keeps its posts in memory, ' +
344
+ 'which is per process — add `@bakery-framework/orm` when you want them to outlive a ' +
345
+ 'restart or survive `--threads`.\n'
346
+
347
+ return `# ${name}
348
+
349
+ Built with [Bakery](https://github.com/obillekyle/bakery).
350
+
351
+ \`\`\`bash
352
+ bun install
353
+ ${sync}bun run dev
354
+ \`\`\`
355
+
356
+ Then open http://localhost:3000.
357
+ ${noOrm}
358
+ ## Layout
359
+
360
+ | Path | What it is |
361
+ | --- | --- |
362
+ | \`src/\` | Served. Every file is a route — \`src/index.tsx\` is \`/\`, \`src/api/notes.ts\` is \`/api/notes\`. |
363
+ ${ormRows}| \`server.config.ts\` | Port, root directory, plugins. |
364
+ ${pluginSection}
365
+ \`bun run start\` serves in production mode; add \`--threads N\` to fork a cluster.
366
+ `
367
+ }
368
+
369
+ /**
370
+ * The three JSX options are repeated here on purpose, and removing them breaks
371
+ * every page in the generated app.
372
+ *
373
+ * `@bakery-framework/core/tsconfig.app.json` already sets them, and `tsc` picks them up
374
+ * from there — but **Bun's runtime does not follow `extends` into a package
375
+ * specifier**, only a relative path. So at runtime the app is transpiled with
376
+ * Bun's default automatic JSX runtime instead of Bakery's classic
377
+ * `createElement`, and every `.tsx` route fails with `Cannot find module
378
+ * 'react/jsx-dev-runtime'`. Typecheck stays clean throughout, which is what
379
+ * makes it nasty.
380
+ *
381
+ * `extends` still carries everything else, and is what `tsc` reads.
382
+ */
383
+ function tsconfig(orm: boolean): string {
384
+ const include = [
385
+ '"src/**/*.ts"',
386
+ '"src/**/*.tsx"',
387
+ ...(orm ? ['"orm/**/*.ts"', '"scripts/**/*.ts"'] : []),
388
+ '"server.config.ts"',
389
+ ]
390
+ .map(entry => ` ${entry}`)
391
+ .join(',\n')
392
+
393
+ return `{
394
+ "$comment": "The three jsx* options are also set by @bakery-framework/core/tsconfig.app.json, and tsc reads them from there — but Bun's runtime does not follow 'extends' into a package specifier, only a relative path. Without them here, every .tsx page fails at runtime with \\"Cannot find module 'react/jsx-dev-runtime'\\" while typecheck stays clean. Keep them.",
395
+ "extends": "@bakery-framework/core/tsconfig.app.json",
396
+ "compilerOptions": {
397
+ "jsx": "react",
398
+ "jsxFactory": "createElement",
399
+ "jsxFragmentFactory": "Fragment"
400
+ },
401
+ "include": [
402
+ ${include}
403
+ ]
404
+ }
405
+ `
406
+ }
407
+
408
+ /**
409
+ * Build the file list for an app named `name`.
410
+ *
411
+ * `range` is threaded in rather than read from disk so this stays pure — the
412
+ * caller resolves it from the running package's own version.
413
+ */
414
+ export function templateFiles(
415
+ name: string,
416
+ range: string,
417
+ options: TemplateOptions = { orm: true, plugins: [] },
418
+ ): TemplateFile[] {
419
+ const { orm, plugins } = options
420
+
421
+ const dependencies: Record<string, string> = {
422
+ '@bakery-framework/cli': range,
423
+ '@bakery-framework/core': range,
424
+ }
425
+ if (orm) dependencies['@bakery-framework/orm'] = range
426
+ for (const id of plugins) {
427
+ dependencies[PLUGINS[id].pkg] = range
428
+ Object.assign(dependencies, PLUGINS[id].peers ?? {})
429
+ }
430
+
431
+ const pkg = {
432
+ name,
433
+ version: '0.1.0',
434
+ private: true,
435
+ type: 'module',
436
+ scripts: {
437
+ dev: 'bakery --dev',
438
+ start: 'bakery',
439
+ ...(orm ? { 'db:sync': 'bun run scripts/db-sync.ts' } : {}),
440
+ },
441
+ // Sorted, because the key order here is otherwise "whichever plugin was
442
+ // listed first", and a generated file that differs run to run is a diff
443
+ // nobody can read.
444
+ dependencies: Object.fromEntries(
445
+ Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b)),
446
+ ),
447
+ }
448
+
449
+ const files: TemplateFile[] = [
450
+ { path: 'package.json', contents: `${JSON.stringify(pkg, null, 2)}\n` },
451
+ { path: 'tsconfig.json', contents: tsconfig(orm) },
452
+ { path: '.gitignore', contents: gitignore(orm) },
453
+ { path: 'README.md', contents: readme(name, orm, plugins) },
454
+ { path: 'server.config.ts', contents: serverConfig(plugins) },
455
+ {
456
+ path: 'src/index.tsx',
457
+ contents: INDEX_PAGE.replaceAll('{{name}}', name),
458
+ },
459
+ { path: 'src/api/notes.ts', contents: orm ? API_ROUTE : API_ROUTE_NO_ORM },
460
+ { path: 'src/script.ts', contents: CLIENT_SCRIPT },
461
+ ]
462
+
463
+ if (orm) {
464
+ files.push(
465
+ { path: 'scripts/db-sync.ts', contents: DB_SYNC_SCRIPT },
466
+ { path: 'orm/tables.ts', contents: ORM_SCHEMA },
467
+ { path: 'orm/views.ts', contents: ORM_VIEWS },
468
+ { path: 'orm/indexes.ts', contents: ORM_INDEXES },
469
+ { path: 'orm/index.ts', contents: ORM_INDEX },
470
+ )
471
+ }
472
+
473
+ return files
474
+ }