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 +19 -0
- package/README.md +49 -0
- package/package.json +44 -0
- package/src/index.ts +359 -0
- package/src/prompt.ts +231 -0
- package/src/template.ts +474 -0
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
|
+
}
|
package/src/template.ts
ADDED
|
@@ -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
|
+
}
|