@weft-js/create 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ravonus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @weft-js/create
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Early release (0.1.1).** APIs will change between minor versions.
4
+
5
+ Starts a new weft app.
6
+
7
+ ```bash
8
+ npm create @weft-js my-app
9
+ ```
10
+
11
+ In a terminal it asks what the flags don't answer. `--yes` takes the defaults.
12
+
13
+ | Flag | Values | Default |
14
+ |---|---|---|
15
+ | `--ui` | `warp`, `headless` (warp-core only), `tailwind`, `none` | `warp` |
16
+ | `--db` | `sqlite`, `postgres`, `mysql`, `none` | `sqlite` |
17
+ | `--layer` | `drizzle`, `prisma`, `native` | `drizzle` |
18
+ | `--auth`, `--no-auth` | Sign-in, when there's a database | on |
19
+ | `--auth-provider` | `better-auth`, `custom` | `better-auth` |
20
+ | `--runtime` | `node`, `bun`, `deno` | the one running it |
21
+ | `--config` | `ts`, `json`, `yaml`: the format of the weft config | `ts` |
22
+ | `--pm` | `npm`, `pnpm`, `bun`, `deno`, `yarn` | the one you ran it with |
23
+ | `--git`, `--no-git`, `--install`, `--no-install` | Start a git repository; install dependencies | on, or off with no terminal and no `--yes` |
24
+ | `--name` | The package name | the folder's name |
25
+
26
+ With npm, flags go after `--`: `npm create @weft-js my-app -- --ui tailwind --db none`.
27
+
28
+ Until weft is on npm, make an app from a checkout instead: [Installation](../../site/content/learn/installation.md).
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // create-weft [dir] [--ui warp|headless|tailwind|none] [--db sqlite|postgres|mysql|none] [--layer drizzle|prisma|native] [--[no-]auth] [--auth-provider better-auth|custom] [--runtime node|bun|deno]
3
+ // [--config ts|json|yaml] [--pm npm|pnpm|bun|deno|yarn] [--[no-]git] [--[no-]install] [--name <name>] [--tarballs <dir>] [--yes]
4
+ // In a terminal it asks what the flags don't answer; --yes, or no terminal, takes the defaults.
5
+ import { execFileSync } from 'node:child_process'
6
+ import { readFileSync, writeFileSync } from 'node:fs'
7
+ import { join, resolve } from 'node:path'
8
+ import { createInterface } from 'node:readline/promises'
9
+ import { parseArgs } from 'node:util'
10
+ import { CONFIGS, DB, LAYERS, RUNTIMES, scaffold, UI } from '../src/index.mjs'
11
+ import { resolveChoices } from '../src/wizard.mjs'
12
+ import { pin } from '../scripts/tarballs.mjs'
13
+
14
+ const usage = `Usage: npm create @weft-js -- [dir] [--ui ${UI.join('|')}] [--db ${DB.join('|')}] [--layer ${LAYERS.join('|')}] [--[no-]auth] [--auth-provider better-auth|custom] [--runtime ${RUNTIMES.join('|')}] [--config ${CONFIGS.join('|')}] [--pm <manager>] [--[no-]git] [--[no-]install] [--name <name>] [--tarballs <dir>] [--yes]`
15
+ const string = { type: 'string' }, boolean = { type: 'boolean' }
16
+ const { values, positionals } = parseArgs({
17
+ allowPositionals: true,
18
+ allowNegative: true,
19
+ options: { ui: string, db: string, layer: string, auth: boolean, 'auth-provider': string, runtime: string, config: string, pm: string, git: boolean, install: boolean, name: string, tarballs: string, yes: { ...boolean, short: 'y' }, help: { ...boolean, short: 'h' } },
20
+ })
21
+ const interactive = process.stdin.isTTY && process.stdout.isTTY
22
+ if (values.help || positionals.length > 1 || (!positionals.length && !interactive)) {
23
+ console.log(usage)
24
+ process.exit(values.help ? 0 : 1)
25
+ }
26
+
27
+ async function directory() {
28
+ if (positionals.length) return positionals[0]
29
+ if (values.yes) return 'my-weft-app'
30
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
31
+ const answer = await rl.question('App directory (my-weft-app) ')
32
+ rl.close()
33
+ return answer.trim() || 'my-weft-app'
34
+ }
35
+
36
+ /** Git and the install are conveniences: the app is already written, so a failure here only leaves a step for later. */
37
+ function step(what, command, args, cwd) {
38
+ try {
39
+ execFileSync(command, args, { cwd, stdio: ['ignore', 'ignore', 'inherit'], shell: process.platform === 'win32' }) // npm is npm.cmd there
40
+ return true
41
+ } catch {
42
+ console.error(`create-weft: ${what} didn't finish; run \`${[command, ...args].join(' ')}\` yourself.`)
43
+ return false
44
+ }
45
+ }
46
+
47
+ try {
48
+ const { yes, help: _help, name, tarballs, 'auth-provider': authProvider, ...flags } = values
49
+ if (authProvider && !['better-auth', 'custom'].includes(authProvider)) throw new Error('--auth-provider must be better-auth or custom')
50
+ if (authProvider) flags.auth = authProvider === 'custom' ? 'custom' : true
51
+ const where = await directory()
52
+ const dir = resolve(where)
53
+ const choices = await resolveChoices(flags, { interactive, yes, io: { input: process.stdin, output: process.stdout } })
54
+ const { files } = scaffold(dir, { ...choices, ...(name && { name }) })
55
+ // Before the install: a checkout's packages aren't on npm, so `local.mjs new` needs them wired in from the start.
56
+ if (tarballs) {
57
+ const file = join(dir, 'package.json')
58
+ writeFileSync(file, JSON.stringify(pin(JSON.parse(readFileSync(file, 'utf8')), JSON.parse(readFileSync(join(tarballs, 'tarballs.json'), 'utf8'))), null, 2))
59
+ }
60
+ console.log(`\nCreated ${files.length} files in ${dir}`)
61
+ if (choices.git) step('git init', 'git', ['init', '--quiet'], dir)
62
+ const installed = choices.install && step('The install', choices.pm, ['install'], dir)
63
+ const weft = { npm: 'npx weft', pnpm: 'pnpm exec weft', bun: 'bunx --bun weft', deno: 'deno run -A npm:@weft-js/cli', yarn: 'yarn weft' }[choices.pm]
64
+ // AGENTS.md comes from the weft just installed, so it always matches the compiler the app runs.
65
+ if (installed) {
66
+ const [command, ...args] = weft.split(' ')
67
+ step('AGENTS.md', command, [...args, 'agents-md'], dir)
68
+ }
69
+ console.log(`\n cd ${where}${installed ? '' : `\n ${choices.pm} install\n ${weft} agents-md`}${choices.db === 'none' ? '' : `\n ${weft} db generate`}\n ${weft} dev\n`)
70
+ } catch (e) {
71
+ console.error(`create-weft: ${e.message}`)
72
+ process.exit(1)
73
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@weft-js/create",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Scaffold a weft app: npm create @weft-js",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-weft": "./bin/create-weft.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.mjs",
12
+ "./stack": "./src/stack.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=26"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "keywords": [
21
+ "weft"
22
+ ],
23
+ "files": [
24
+ "bin",
25
+ "src",
26
+ "scripts/tarballs.mjs",
27
+ "README.md",
28
+ "LICENSE"
29
+ ]
30
+ }
@@ -0,0 +1,27 @@
1
+ // Every package as a tarball, and an app's package.json pointed at them: what the packed gates and `local.mjs` share.
2
+ import { execFileSync } from 'node:child_process'
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ export const pkgs = fileURLToPath(new URL('../../', import.meta.url))
8
+
9
+ /** Pack each package into `dest`. Returns `{ name: 'file:…tgz' }`. */
10
+ export function packAll(dest) {
11
+ mkdirSync(dest, { recursive: true })
12
+ const tarballs = {}
13
+ for (const dir of readdirSync(pkgs)) {
14
+ const file = join(pkgs, dir, 'package.json')
15
+ if (!existsSync(file)) continue
16
+ const { name } = JSON.parse(readFileSync(file, 'utf8'))
17
+ const packed = execFileSync('npm', ['pack', '--silent', '--pack-destination', dest], { cwd: join(pkgs, dir), encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] })
18
+ tarballs[name] = `file:${join(dest, packed.trim().split('\n').pop())}`
19
+ }
20
+ return tarballs
21
+ }
22
+
23
+ /** `pkg` with its weft dependencies on the tarballs. The overrides reach the ones packages declare for each other. */
24
+ export function pin(pkg, tarballs) {
25
+ for (const deps of [pkg.dependencies, pkg.devDependencies]) for (const name in deps) if (tarballs[name]) deps[name] = tarballs[name]
26
+ return { ...pkg, overrides: { ...pkg.overrides, ...tarballs } }
27
+ }
package/src/data.mjs ADDED
@@ -0,0 +1,288 @@
1
+ // The Native and Prisma data layers of a scaffold (research 34 §4), beside Drizzle's in stack.mjs. Each writes the same
2
+ // todo app, so the same e2e holds for every layer. `weft add db` writes these files too.
3
+
4
+ const engineSql = {
5
+ sqlite: { bool: 'integer not null default 0', big: 'integer' },
6
+ postgres: { bool: 'boolean not null default false', big: 'bigint' },
7
+ mysql: { bool: 'boolean not null default false', big: 'bigint' },
8
+ }
9
+
10
+ // Better Auth's tables under its default names, which are camelCase, so they're quoted. MySQL can't key a TEXT column.
11
+ export function nativeAuthSql(db) {
12
+ const q = name => (db === 'mysql' ? `\`${name}\`` : `"${name}"`)
13
+ const key = 'varchar(255)'
14
+ const text = 'text'
15
+ const when = { sqlite: 'date', postgres: 'timestamptz', mysql: 'datetime(3)' }[db]
16
+ const bool = { sqlite: 'integer', postgres: 'boolean', mysql: 'boolean' }[db]
17
+ const table = (name, columns) => `create table ${q(name)} (\n${columns.map(([c, type]) => ` ${q(c)} ${type}`).join(',\n')}\n);\n`
18
+ const ref = to => `references ${q(to)} (${q('id')}) on delete cascade`
19
+ const stamps = [['createdAt', `${when} not null`], ['updatedAt', `${when} not null`]]
20
+ return [
21
+ table('user', [['id', `${key} primary key`], ['name', `${text} not null`], ['email', `${key} not null unique`], ['emailVerified', `${bool} not null`], ['image', text], ...stamps]),
22
+ table('session', [['id', `${key} primary key`], ['expiresAt', `${when} not null`], ['token', `${key} not null unique`], ...stamps, ['ipAddress', text], ['userAgent', text], ['userId', `${key} not null ${ref('user')}`]]),
23
+ table('account', [['id', `${key} primary key`], ['accountId', `${text} not null`], ['providerId', `${text} not null`], ['userId', `${key} not null ${ref('user')}`], ['accessToken', text], ['refreshToken', text], ['idToken', text], ['accessTokenExpiresAt', when], ['refreshTokenExpiresAt', when], ['scope', text], ['password', text], ...stamps]),
24
+ table('verification', [['id', `${key} primary key`], ['identifier', `${text} not null`], ['value', `${text} not null`], ['expiresAt', `${when} not null`], ['createdAt', when], ['updatedAt', when]]),
25
+ ].join('\n')
26
+ }
27
+
28
+ const nativeTodos = auth => {
29
+ const use = auth ? `\n auth: 'user',` : ''
30
+ const mine = auth ? ` and user_id = \${ctx.user.id}` : ''
31
+ return `import { mutation, query } from '@weft-js/start'
32
+ import { z } from 'zod'
33
+
34
+ interface Todo { id: string; title: string; done: boolean }
35
+ const byId = z.object({ id: z.string() })
36
+
37
+ export const listTodos = query({
38
+ tags: ['todos'],${use}
39
+ run: async ({ ctx }) => {
40
+ const rows = await ctx.db.sql\`select id, title, done from todos${auth ? ` where user_id = \${ctx.user.id}` : ''} order by created_at\`
41
+ return (rows as unknown as Todo[]).map(t => ({ ...t, done: Boolean(t.done) }))
42
+ },
43
+ })
44
+
45
+ export const addTodo = mutation({
46
+ input: z.object({ title: z.string().trim().min(1).max(200) }),${use}
47
+ invalidates: ['todos'],
48
+ optimistic: ({ input }, cache) =>
49
+ cache.update(listTodos, undefined, list => void list.push({ id: \`tmp:\${input.title}\`, title: input.title, done: false })),
50
+ run: async ({ input, ctx }) => {
51
+ await ctx.db.sql\`insert into todos (id, title, done, created_at${auth ? ', user_id' : ''}) values (\${crypto.randomUUID()}, \${input.title}, \${false}, \${Date.now()}${auth ? `, \${ctx.user.id}` : ''})\`
52
+ },
53
+ })
54
+
55
+ export const toggleTodo = mutation({
56
+ input: byId,${use}
57
+ invalidates: ['todos'],
58
+ optimistic: ({ input }, cache) =>
59
+ cache.update(listTodos, undefined, list => {
60
+ const t = list.find(x => x.id === input.id)
61
+ if (t) t.done = !t.done
62
+ }),
63
+ run: async ({ input, ctx }) => {
64
+ await ctx.db.sql\`update todos set done = not done where id = \${input.id}${mine}\`
65
+ },
66
+ })
67
+
68
+ export const deleteTodo = mutation({
69
+ input: byId,${use}
70
+ invalidates: ['todos'],
71
+ optimistic: ({ input }, cache) => cache.update(listTodos, undefined, list => list.filter(x => x.id !== input.id)),
72
+ run: async ({ input, ctx }) => {
73
+ await ctx.db.sql\`delete from todos where id = \${input.id}${mine}\`
74
+ },
75
+ })
76
+ `
77
+ }
78
+
79
+ export const nativeFiles = ({ db, auth }) => ({
80
+ 'migrations/0000_todos.sql': `${auth ? `${nativeAuthSql(db)}\n` : ''}create table todos (
81
+ id varchar(36) primary key,
82
+ title varchar(200) not null,
83
+ done ${engineSql[db].bool},
84
+ created_at ${engineSql[db].big} not null${auth ? `,\n user_id varchar(255) not null references ${db === 'mysql' ? '`user`' : '"user"'} (id) on delete cascade` : ''}
85
+ );
86
+ `,
87
+ 'src/db/index.server.ts': `import { createDb } from '@weft-js/db/plugin-native/${db}'
88
+ import { env } from '../env.server'
89
+
90
+ export const db = createDb({ url: env.DATABASE_URL })
91
+ `,
92
+ ...(auth && {
93
+ 'src/auth.server.ts': `import { createAuth } from '@weft-js/start/auth/better-auth'
94
+ import { nativeAuth } from '@weft-js/db/plugin-native/auth'
95
+ import { db } from '#/db/index.server'
96
+ import { env } from './env.server'
97
+
98
+ export default createAuth({ db, secret: env.BETTER_AUTH_SECRET, options: { database: nativeAuth(db) } })
99
+ `,
100
+ }),
101
+ 'src/server/todos.ts': nativeTodos(auth),
102
+ })
103
+
104
+ const PROVIDER = { sqlite: 'sqlite', postgres: 'postgresql', mysql: 'mysql' }
105
+ const ADAPTER = {
106
+ sqlite: { pkg: '@prisma/adapter-libsql', name: 'PrismaLibSql', make: "{ url: env.DATABASE_URL }" },
107
+ postgres: { pkg: '@prisma/adapter-pg', name: 'PrismaPg', make: "{ connectionString: env.DATABASE_URL }" },
108
+ mysql: { pkg: '@prisma/adapter-mariadb', name: 'PrismaMariaDb', make: 'env.DATABASE_URL' },
109
+ }
110
+ export const prismaAdapterPackage = db => ADAPTER[db].pkg
111
+
112
+ const authModels = db => {
113
+ const long = db === 'mysql' ? ' @db.Text' : ''
114
+ return `
115
+ // Better Auth's tables: it reads and writes them through Prisma, with these names.
116
+ model User {
117
+ id String @id
118
+ name String
119
+ email String @unique
120
+ emailVerified Boolean @map("email_verified")
121
+ image String?${long}
122
+ createdAt DateTime @map("created_at")
123
+ updatedAt DateTime @map("updated_at")
124
+ sessions Session[]
125
+ accounts Account[]
126
+ todos Todo[]
127
+
128
+ @@map("user")
129
+ }
130
+
131
+ model Session {
132
+ id String @id
133
+ expiresAt DateTime @map("expires_at")
134
+ token String @unique
135
+ createdAt DateTime @map("created_at")
136
+ updatedAt DateTime @map("updated_at")
137
+ ipAddress String? @map("ip_address")
138
+ userAgent String? @map("user_agent")${long}
139
+ userId String @map("user_id")
140
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
141
+
142
+ @@map("session")
143
+ }
144
+
145
+ model Account {
146
+ id String @id
147
+ accountId String @map("account_id")
148
+ providerId String @map("provider_id")
149
+ userId String @map("user_id")
150
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
151
+ accessToken String? @map("access_token")${long}
152
+ refreshToken String? @map("refresh_token")${long}
153
+ idToken String? @map("id_token")${long}
154
+ accessTokenExpiresAt DateTime? @map("access_token_expires_at")
155
+ refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
156
+ scope String?
157
+ password String?
158
+ createdAt DateTime @map("created_at")
159
+ updatedAt DateTime @map("updated_at")
160
+
161
+ @@map("account")
162
+ }
163
+
164
+ model Verification {
165
+ id String @id
166
+ identifier String
167
+ value String${long}
168
+ expiresAt DateTime @map("expires_at")
169
+ createdAt DateTime? @map("created_at")
170
+ updatedAt DateTime? @map("updated_at")
171
+
172
+ @@map("verification")
173
+ }
174
+ `
175
+ }
176
+
177
+ export const prismaAuthModels = db => authModels(db).replace(' todos Todo[]\n', '')
178
+
179
+ const prismaTodos = auth => {
180
+ const use = auth ? `\n auth: 'user',` : ''
181
+ const mine = auth ? `, userId: ctx.user.id` : ''
182
+ return `import { mutation, query } from '@weft-js/start'
183
+ import { z } from 'zod'
184
+
185
+ const byId = z.object({ id: z.string() })
186
+
187
+ export const listTodos = query({
188
+ tags: ['todos'],${use}
189
+ run: ({ ctx }) => ctx.db.todo.findMany({ where: {${mine.slice(1)} }, orderBy: { createdAt: 'asc' }, select: { id: true, title: true, done: true } }),
190
+ })
191
+
192
+ export const addTodo = mutation({
193
+ input: z.object({ title: z.string().trim().min(1).max(200) }),${use}
194
+ invalidates: ['todos'],
195
+ optimistic: ({ input }, cache) =>
196
+ cache.update(listTodos, undefined, list => void list.push({ id: \`tmp:\${input.title}\`, title: input.title, done: false })),
197
+ run: async ({ input, ctx }) => {
198
+ await ctx.db.todo.create({ data: { id: crypto.randomUUID(), title: input.title${mine} } })
199
+ },
200
+ })
201
+
202
+ export const toggleTodo = mutation({
203
+ input: byId,${use}
204
+ invalidates: ['todos'],
205
+ optimistic: ({ input }, cache) =>
206
+ cache.update(listTodos, undefined, list => {
207
+ const t = list.find(x => x.id === input.id)
208
+ if (t) t.done = !t.done
209
+ }),
210
+ run: async ({ input, ctx }) => {
211
+ const todo = await ctx.db.todo.findFirst({ where: { id: input.id${mine} } })
212
+ if (todo) await ctx.db.todo.update({ where: { id: todo.id }, data: { done: !todo.done } })
213
+ },
214
+ })
215
+
216
+ export const deleteTodo = mutation({
217
+ input: byId,${use}
218
+ invalidates: ['todos'],
219
+ optimistic: ({ input }, cache) => cache.update(listTodos, undefined, list => list.filter(x => x.id !== input.id)),
220
+ run: async ({ input, ctx }) => {
221
+ await ctx.db.todo.deleteMany({ where: { id: input.id${mine} } })
222
+ },
223
+ })
224
+ `
225
+ }
226
+
227
+ export const prismaFiles = ({ db, auth }) => ({
228
+ 'prisma/schema.prisma': `generator client {
229
+ provider = "prisma-client"
230
+ output = "../src/generated/prisma"
231
+ }
232
+
233
+ datasource db {
234
+ provider = "${PROVIDER[db]}"
235
+ }
236
+ ${auth ? authModels(db) : ''}
237
+ model Todo {
238
+ id String @id
239
+ title String
240
+ done Boolean @default(false)
241
+ createdAt DateTime @default(now()) @map("created_at")${auth ? `
242
+ userId String @map("user_id")
243
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)` : ''}
244
+
245
+ @@map("todos")
246
+ }
247
+ `,
248
+ 'prisma.config.ts': `import { existsSync } from 'node:fs'
249
+ import { defineConfig } from 'prisma/config'
250
+
251
+ if (existsSync('.env')) process.loadEnvFile()
252
+
253
+ export default defineConfig({ schema: 'prisma/schema.prisma', datasource: { url: process.env.DATABASE_URL ?? '' } })
254
+ `,
255
+ 'src/db/index.server.ts': `${db === 'sqlite' ? "import { mkdirSync } from 'node:fs'\n" : ''}import { ${ADAPTER[db].name} } from '${ADAPTER[db].pkg}'
256
+ import { withWeft } from '@weft-js/db/plugin-prisma'
257
+ import { PrismaClient } from '../generated/prisma/client'
258
+ import { env } from '../env.server'
259
+ ${db === 'sqlite' ? "\nmkdirSync('.data', { recursive: true })\n" : ''}
260
+ const adapter = new ${ADAPTER[db].name}(${ADAPTER[db].make})
261
+ export const db = withWeft(new PrismaClient({ adapter, log: [{ emit: 'event', level: 'query' }] }), '${PROVIDER[db]}')
262
+ `,
263
+ ...(auth && {
264
+ 'src/auth.server.ts': `import { createAuth } from '@weft-js/start/auth/better-auth'
265
+ import { prismaAdapter } from 'better-auth/adapters/prisma'
266
+ import { db } from '#/db/index.server'
267
+ import { env } from './env.server'
268
+
269
+ export default createAuth({ db, secret: env.BETTER_AUTH_SECRET, options: { database: prismaAdapter(db, { provider: '${PROVIDER[db]}' }) } })
270
+ `,
271
+ }),
272
+ 'src/server/todos.ts': prismaTodos(auth),
273
+ })
274
+
275
+ /** `--auth-provider custom`: the seam, with nothing behind it yet. An app that compiles, with no one signed in. */
276
+ export const customAuth = `import { defineAuth } from '@weft-js/start/auth'
277
+
278
+ // Your auth provider. weft needs only these: \`handler\` serves everything under /api/auth, and \`session\` says who a
279
+ // request is, which is \`ctx.user\`. \`auth: 'user'\` on a query or mutation works with whatever you return here.
280
+ export default defineAuth<{ id: string }, { id: string }>({
281
+ async session(request) {
282
+ return { user: null, session: null }
283
+ },
284
+ async handler(request) {
285
+ return new Response('Not found', { status: 404 })
286
+ },
287
+ })
288
+ `
package/src/index.mjs ADDED
@@ -0,0 +1,241 @@
1
+ // create-weft: write a new app's files (research 26 §6, research 28 §6). `--ui`: `warp` adds @weft-js/warp and `warp()` in
2
+ // weft.config.ts, `headless` adds @weft-js/warp-core's spec CSS under the app's own markup, `tailwind` adds Tailwind and its Vite plugin,
3
+ // `none` adds nothing. `--db`, `--layer`
4
+ // and `--auth` add a database, a todo app and a sign-in flow (stack.mjs); `--runtime` is what `weft build` targets.
5
+ // AGENTS.md needs the installed weft, so bin/create-weft.mjs runs `weft agents-md` right after the install. Everything
6
+ // else (.weft/, the slot modules) is generated by `weft` itself on the first `weft dev` or `weft check`, so a scaffold
7
+ // never goes stale against the CLI.
8
+
9
+ import { randomBytes } from 'node:crypto'
10
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
11
+ import { basename, dirname, join } from 'node:path'
12
+ import { customAuth } from './data.mjs'
13
+ import { classes, context, loginPage, stackDeps, stackFiles, TAILWIND_CSS, TAILWIND_VITE, tailwindPackages, todosPage, VERSION } from './stack.mjs'
14
+
15
+ /** What `--ui` takes. */
16
+ export const UI = ['warp', 'headless', 'tailwind', 'none']
17
+ /** What `--db` takes. */
18
+ export const DB = ['sqlite', 'postgres', 'mysql', 'none']
19
+ /** What `--layer` takes. */
20
+ export const LAYERS = ['drizzle', 'prisma', 'native']
21
+ /** What `--runtime` takes. */
22
+ export const RUNTIMES = ['node', 'bun', 'deno']
23
+ /** What `--config` takes. */
24
+ export const CONFIGS = ['ts', 'json', 'yaml']
25
+
26
+
27
+ const json = v => JSON.stringify(v, null, 2) + '\n'
28
+ const title = name => name.replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
29
+
30
+ /** `weft.config.ts`, `.json` or `.yaml`: one schema, so the same app in each (research 34 §2). */
31
+ const LAYER_PLUGIN = { prisma: { pkg: '@weft-js/db/plugin-prisma', name: 'prisma' }, native: { pkg: '@weft-js/db/plugin-native', name: 'native' } }
32
+
33
+ const appPlugins = ({ ui, layer }) => [...(ui === 'warp' ? [{ pkg: '@weft-js/warp', name: 'warp' }] : []), ...(LAYER_PLUGIN[layer] ? [LAYER_PLUGIN[layer]] : [])]
34
+
35
+ /** What each plugin a scaffold picks declares. `weft check` fails a scaffold where these fall behind the plugin. */
36
+ const GRANTS = {
37
+ '@weft-js/warp': ['fs.read:node_modules/@weft-js/warp-core/**', 'fs.write:$TMPDIR/warp-seed-*', 'spawn:node'],
38
+ '@weft-js/db/plugin-prisma': ['fs.read:prisma/**', 'fs.write:prisma/migrations/**', 'spawn:node'],
39
+ '@weft-js/db/plugin-native': ['fs.read:migrations/**', 'fs.write:migrations/**'],
40
+ }
41
+
42
+ /** Choosing a plugin here is approving it. There's no install yet, so `weft plugins approve --all` pins the code later. */
43
+ function pluginLock(options) {
44
+ const plugins = Object.fromEntries(appPlugins(options).map(p => [p.pkg, { version: null, integrity: null, permissions: GRANTS[p.pkg], provides: [], undeclared: [] }]))
45
+ return Object.keys(plugins).length ? { '.weft/plugins.lock.json': json({ version: 1, plugins }) } : {}
46
+ }
47
+
48
+ function configFile(format, { ui, label, runtime, layer }) {
49
+ const plugins = appPlugins({ ui, layer })
50
+ const site = { name: label, title: `%s · ${label}`, description: 'Made with weft.' }
51
+ if (format === 'json') {
52
+ const config = { $schema: './.weft/config.schema.json', site, ...(plugins.length && { plugins: plugins.map(p => p.pkg) }), ...(runtime !== 'node' && { runtime }) }
53
+ return { 'weft.config.json': json(config) }
54
+ }
55
+ if (format === 'yaml') {
56
+ return {
57
+ 'weft.config.yaml':
58
+ `# yaml-language-server: $schema=./.weft/config.schema.json\nsite:\n` +
59
+ Object.entries(site).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}\n`).join('') +
60
+ (plugins.length ? `plugins:\n${plugins.map(p => ` - "${p.pkg}"\n`).join('')}` : '') +
61
+ (runtime !== 'node' ? `runtime: ${runtime}\n` : ''),
62
+ }
63
+ }
64
+ return {
65
+ 'weft.config.ts':
66
+ `import { defineConfig } from '@weft-js/core/config'\n` +
67
+ plugins.map(p => `import { ${p.name} } from '${p.pkg}/plugin'\n`).join('') +
68
+ `\nexport default defineConfig({\n` +
69
+ ` site: { name: ${JSON.stringify(label)}, title: ${JSON.stringify(site.title)}, description: 'Made with weft.' },\n` +
70
+ (plugins.length ? ` plugins: [${plugins.map(p => `${p.name}()`).join(', ')}],\n` : '') +
71
+ (runtime !== 'node' ? ` runtime: '${runtime}',\n` : '') +
72
+ `})\n`,
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The files for an app, as `{ path: text }`.
78
+ * @param {{ ui?: string, name?: string, version?: string, db?: string, layer?: string, auth?: boolean | 'custom', runtime?: string, config?: string, secret?: string }} [options]
79
+ */
80
+ export function template({ ui = 'warp', name = 'weft-app', version = VERSION, db = 'none', layer = 'drizzle', auth = false, runtime = 'node', config = 'ts', secret = randomBytes(32).toString('base64') } = {}) {
81
+ if (!UI.includes(ui)) throw new Error(`--ui must be one of ${UI.join(', ')} (got "${ui}")`)
82
+ if (!DB.includes(db)) throw new Error(`--db must be one of ${DB.join(', ')} (got "${db}")`)
83
+ if (!RUNTIMES.includes(runtime)) throw new Error(`--runtime must be one of ${RUNTIMES.join(', ')} (got "${runtime}")`)
84
+ if (!CONFIGS.includes(config)) throw new Error(`--config must be one of ${CONFIGS.join(', ')} (got "${config}")`)
85
+ if (!LAYERS.includes(layer)) throw new Error(`--layer must be one of ${LAYERS.join(', ')} (got "${layer}")`)
86
+ if (auth && db === 'none') throw new Error('--auth keeps users in a database: add --db sqlite, postgres or mysql')
87
+ const label = title(name)
88
+ const stack = stackDeps({ db, auth: auth === true, layer, version })
89
+ const deps = { '@weft-js/core': version, '@weft-js/start': version }
90
+ if (ui === 'warp') deps['@weft-js/warp'] = version
91
+ if (ui === 'headless') deps['@weft-js/warp-core'] = version
92
+ if (ui === 'tailwind') Object.assign(stack.dev, tailwindPackages)
93
+
94
+ const files = {
95
+ 'package.json': json({
96
+ name,
97
+ private: true,
98
+ type: 'module',
99
+ scripts: { dev: 'weft dev', build: 'weft build', check: 'weft check', typecheck: 'weft tsc', start: 'weft start', ...(db !== 'none' && { 'db:generate': 'weft db generate' }), ...(db !== 'none' && layer === 'prisma' && { postinstall: 'prisma generate' }) },
100
+ dependencies: { ...deps, ...stack.deps },
101
+ devDependencies: { '@weft-js/cli': version, typescript: '7.0.2', ...stack.dev },
102
+ }),
103
+ ...configFile(config, { ui, label, runtime, layer: db === 'none' ? 'drizzle' : layer }),
104
+ 'tsconfig.json': json({
105
+ compilerOptions: {
106
+ target: 'es2022', module: 'esnext', moduleResolution: 'bundler', strict: true, noEmit: true, skipLibCheck: true,
107
+ jsx: 'preserve', jsxImportSource: '@weft-js/core', lib: ['es2023', 'dom', 'dom.iterable'], paths: { '#/*': ['./src/*'] }, types: db === 'none' ? ['@weft-js/core/client'] : ['@weft-js/core/client', 'node'], plugins: [{ name: '@weft-js/cli/ts-plugin' }],
108
+ },
109
+ include: ['src', ...(config === 'ts' ? ['weft.config.ts'] : []), '.weft/*.d.ts'],
110
+ }),
111
+ // VS Code's own tsserver only looks for plugins where it's told, and TypeScript 7 ships no tsserver to select instead.
112
+ '.vscode/settings.json': json({ 'typescript.tsserver.pluginPaths': ['.'] }),
113
+ '.gitignore': `node_modules/\n.weft/*\n!.weft/plugins.lock.json\n.output/\ndist/\n${db === 'none' ? '' : '.env\n.data/\n'}${layer === 'prisma' ? 'src/generated/\n' : ''}`,
114
+ 'src/routes/_layout.tsx': layout(ui, { db, auth }),
115
+ 'src/routes/index.tsx': home(ui, label),
116
+ 'src/routes/about.tsx': `export const meta = { title: 'About', description: 'What this app is.' }\n\nexport default function About() {\n return <h1>About</h1>\n}\n`,
117
+ }
118
+ Object.assign(files, pluginLock({ ui, layer }))
119
+ if (ui !== 'warp') files['src/app.css'] = css(ui)
120
+ if (ui === 'tailwind') files['vite.config.ts'] = TAILWIND_VITE
121
+ if (db !== 'none') {
122
+ Object.assign(files, stackFiles({ db, auth: auth === true, name, secret, layer }), { 'src/routes/todos/index.tsx': todosPage(ui) })
123
+ if (auth === true) files['src/routes/login.tsx'] = loginPage(ui)
124
+ if (auth === 'custom') Object.assign(files, { 'src/auth.server.ts': customAuth, 'src/context.server.ts': context(true) })
125
+ }
126
+ return files
127
+ }
128
+
129
+ /** Write the app into `dir` (which must be missing or empty). */
130
+ export function scaffold(dir, options = {}) {
131
+ if (existsSync(dir) && readdirSync(dir).length) throw new Error(`${dir} is not empty`)
132
+ const files = template({ name: basename(dir).toLowerCase().replace(/[^a-z0-9-_.]/g, '-'), ...options })
133
+ for (const [rel, text] of Object.entries(files)) {
134
+ mkdirSync(dirname(join(dir, rel)), { recursive: true })
135
+ writeFileSync(join(dir, rel), text)
136
+ }
137
+ return { dir, files: Object.keys(files) }
138
+ }
139
+
140
+ function layout(ui, options) {
141
+ const text = shell(ui, options)
142
+ return ui === 'tailwind' ? text.replace('<nav>', '<nav class="flex gap-4 p-4">').replace('<main>', '<main class="p-4">') : text
143
+ }
144
+
145
+ function shell(ui, { db, auth: which }) {
146
+ const auth = which === true
147
+ const imports = ui === 'warp' ? '' : (ui === 'headless' ? `import '@weft-js/warp-core/styles/warp.css'\n` : '') + `import '../app.css'\n`
148
+ const links = db === 'none' ? '' : `\n <Link href="/todos">Todos</Link>`
149
+ if (!auth) {
150
+ return `${imports}import { Link, type RouteProps } from '@weft-js/start'
151
+
152
+ export default function Root(props: RouteProps) {
153
+ return (
154
+ <>
155
+ <nav>
156
+ <Link href="/">Home</Link>
157
+ <Link href="/about">About</Link>${links}
158
+ </nav>
159
+ <main>{props.children}</main>
160
+ </>
161
+ )
162
+ }
163
+ `
164
+ }
165
+ return `${imports}import { Link, type LoadArgs, type RouteProps } from '@weft-js/start'
166
+ import { me, signOut } from '#/server/session'
167
+
168
+ export const load = ({ ctx }: LoadArgs) => me.fetch()
169
+
170
+ export default function Root(props: RouteProps<typeof load>) {
171
+ return (
172
+ <>
173
+ <nav>
174
+ <Link href="/">Home</Link>
175
+ <Link href="/about">About</Link>${links}
176
+ {props.data ? <button type="button" onClick={() => signOut()}>Sign out</button> : <Link href="/login">Sign in</Link>}
177
+ </nav>
178
+ <main>{props.children}</main>
179
+ </>
180
+ )
181
+ }
182
+ `
183
+ }
184
+
185
+ function home(ui, label) {
186
+ if (ui === 'warp') {
187
+ return `import type { Meta } from '@weft-js/start'
188
+ import { state } from '@weft-js/core'
189
+ import { Badge, Button, Card } from '@weft-js/warp'
190
+
191
+ export const meta: Meta = { title: { absolute: ${JSON.stringify(label)} }, description: ${JSON.stringify(`${label}, made with weft.`)} }
192
+
193
+ export default function Home() {
194
+ const count = state(0)
195
+ return (
196
+ <Card eyebrow="weft + warp" title=${JSON.stringify(`Welcome to ${label}`)}>
197
+ <p>
198
+ Clicked <Badge>{count.value}</Badge> times.
199
+ </p>
200
+ <Button onClick={() => count.value++}>Click</Button>
201
+ </Card>
202
+ )
203
+ }
204
+ `
205
+ }
206
+ return `import type { Meta } from '@weft-js/start'
207
+ import { state } from '@weft-js/core'
208
+
209
+ export const meta: Meta = { title: { absolute: ${JSON.stringify(label)} }, description: ${JSON.stringify(`${label}, made with weft.`)} }
210
+
211
+ export default function Home() {
212
+ const count = state(0)
213
+ return (
214
+ <section class=${JSON.stringify(classes(ui, 'card'))}>
215
+ <h1${named(ui, 'title')}>Welcome to ${label}</h1>
216
+ <button${named(ui, 'button')} type="button" onClick={() => count.value++}>
217
+ Clicked {count.value} times
218
+ </button>
219
+ </section>
220
+ )
221
+ }
222
+ `
223
+ }
224
+
225
+ const named = (ui, part) => (classes(ui, part) ? ` class="${classes(ui, part)}"` : '')
226
+
227
+ function css(ui) {
228
+ if (ui === 'tailwind') return TAILWIND_CSS
229
+ // headless rides warp's tokens (warp-core's warp.css, the fluid title size included); none brings its own.
230
+ const v = ui === 'headless'
231
+ ? { bg: 'var(--warp-color-page)', fg: 'var(--warp-color-text)', accent: 'var(--warp-color-accent-solid)', radius: 'var(--warp-radius-surface)', title: 'var(--warp-text-title-size)' }
232
+ : { bg: 'Canvas', fg: 'CanvasText', accent: 'LinkText', radius: '8px', title: 'clamp(1.5rem, 1.2rem + 2cqi, 2.5rem)' }
233
+ return `:root { color-scheme: light dark; }
234
+ body { margin: 0; font-family: system-ui, sans-serif; background: ${v.bg}; color: ${v.fg}; }
235
+ nav { display: flex; gap: 1rem; padding: 1rem; }
236
+ main { padding: 1rem; max-inline-size: 60rem; margin-inline: auto; }
237
+ .card { container-type: inline-size; padding: 1.5rem; border-radius: ${v.radius}; border: 1px solid color-mix(in oklab, currentColor 15%, transparent); }
238
+ .card h1 { font-size: ${v.title}; }
239
+ button { font: inherit; padding: 0.5rem 1rem; border-radius: ${v.radius}; border: 1px solid ${v.accent}; background: none; color: inherit; }
240
+ `
241
+ }
package/src/stack.mjs ADDED
@@ -0,0 +1,501 @@
1
+ // The `--db` and `--auth` part of a scaffold (research 28 §6): a database, a todo app on it, and with `--auth` a
2
+ // sign-in/sign-up flow and a guarded `/todos`. Pinned versions, so a scaffold builds what the matrix tested.
3
+ import { readFileSync } from 'node:fs'
4
+ import { nativeAuthSql, nativeFiles, prismaAdapterPackage, prismaAuthModels, prismaFiles } from './data.mjs'
5
+
6
+ const VERSIONS = {
7
+ '@types/node': '26.6.5',
8
+ '@t3-oss/env-core': '0.13.11', 'better-auth': '1.7.14', 'drizzle-kit': '0.31.11', 'drizzle-orm': '0.45.4', 'drizzle-zod': '0.8.3', kysely: '0.29.7', mysql2: '3.24.6', postgres: '4.0.0', zod: '4.6.7',
9
+ prisma: '7.10.1', '@prisma/client': '7.10.1', '@prisma/adapter-libsql': '7.10.1', '@prisma/adapter-pg': '7.10.1', '@prisma/adapter-mariadb': '7.10.1',
10
+ }
11
+
12
+ const pin = (...names) => Object.fromEntries(names.map(n => [n, VERSIONS[n]]))
13
+
14
+ const drivers = db => (db === 'postgres' ? ['postgres'] : db === 'mysql' ? ['mysql2'] : [])
15
+
16
+ /** Every scaffold pins what it shipped with: this package and weft's others go out at one version. */
17
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
18
+
19
+ /** The pinned dependencies of a database, data layer and auth choice. */
20
+ export function stackDeps({ db, auth, layer = 'drizzle', version = VERSION }) {
21
+ if (db === 'none') return { deps: {}, dev: {} }
22
+ const base = pin('@t3-oss/env-core', 'zod', ...(auth ? ['better-auth'] : []))
23
+ if (layer === 'native') return { deps: { ...base, ...pin(...drivers(db), ...(auth ? ['kysely'] : [])), '@weft-js/db': version }, dev: pin('@types/node') }
24
+ if (layer === 'prisma') return { deps: { ...base, ...pin('@prisma/client', prismaAdapterPackage(db)), '@weft-js/db': version }, dev: pin('prisma', '@types/node') }
25
+ return {
26
+ deps: pin('@t3-oss/env-core', 'drizzle-orm', 'drizzle-zod', 'zod', ...drivers(db), ...(auth ? ['better-auth'] : [])),
27
+ dev: pin('drizzle-kit', '@types/node'),
28
+ }
29
+ }
30
+
31
+ /** Sign-in on an app with a database, for `weft add auth`: new files only, and what to add to the existing ones. */
32
+ export function authFiles({ db, layer, ui }) {
33
+ const files = stackFiles({ db, layer, auth: true, name: 'app', secret: '' })
34
+ const out = { 'src/auth.server.ts': files['src/auth.server.ts'], 'src/server/session.ts': files['src/server/session.ts'], 'src/routes/login.tsx': loginPage(ui) }
35
+ return { files: out, context: files['src/context.server.ts'], tables: { drizzle: authTablesFile(db), prisma: prismaAuthModels(db), native: nativeAuthSql(db) }[layer] }
36
+ }
37
+
38
+ const authTablesFile = db => ({
39
+ sqlite: () => `import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'\n${authTablesSqlite()}`,
40
+ postgres: () => `import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'\n${authTablesPg()}`,
41
+ mysql: () => `import { boolean, mysqlTable, text, timestamp, varchar } from 'drizzle-orm/mysql-core'\n${authTablesMysql()}`,
42
+ }[db]())
43
+
44
+ /** The files of a database, data layer and auth choice, as `{ path: text }`. */
45
+ export function stackFiles({ db, auth, name, secret, layer = 'drizzle' }) {
46
+ const shared = {
47
+ '.env': envFile({ db, auth, name, secret }),
48
+ '.env.example': envFile({ db, auth, name, secret: '' }),
49
+ 'src/env.server.ts': envModule({ db, auth }),
50
+ 'src/context.server.ts': context(auth),
51
+ 'src/routes/todos/index.tsx': '', // filled by the caller, which knows the UI kit
52
+ ...(auth && { 'src/server/session.ts': sessionServer(), 'src/routes/todos/_layout.tsx': guard() }),
53
+ }
54
+ if (layer === 'native') return { ...shared, ...nativeFiles({ db, auth }) }
55
+ if (layer === 'prisma') return { ...shared, ...prismaFiles({ db, auth }) }
56
+ return { ...shared, ...drizzleFiles({ db, auth }) }
57
+ }
58
+
59
+ function drizzleFiles({ db, auth }) {
60
+ return {
61
+ 'drizzle.config.ts': `import { defineConfig } from 'drizzle-kit'\n\nexport default defineConfig({ dialect: '${{ postgres: 'postgresql', sqlite: 'sqlite', mysql: 'mysql' }[db]}', schema: './src/db/schema.ts', out: './drizzle' })\n`,
62
+ 'src/db/schema.ts': { postgres: pgSchema, sqlite: sqliteSchema, mysql: mysqlSchema }[db](auth),
63
+ 'src/db/index.server.ts': `import { createDb } from '@weft-js/start/db/${db}'\nimport { env } from '../env.server'\nimport * as schema from './schema'\n\nexport const db = createDb({ schema, url: env.DATABASE_URL })\n`,
64
+ 'src/server/todos.ts': todosServer(auth, db),
65
+ ...(auth && {
66
+ 'src/auth.server.ts': `import { createAuth } from '@weft-js/start/auth/better-auth'\nimport { drizzleAuth } from '@weft-js/start/auth/drizzle'\nimport { db } from '#/db/index.server'\nimport * as schema from './db/schema'\nimport { env } from './env.server'\n\nexport default createAuth({ db, secret: env.BETTER_AUTH_SECRET, options: { database: drizzleAuth(db, schema) } })\n`,
67
+ }),
68
+ }
69
+ }
70
+
71
+ const database = name => name.replace(/[^a-z0-9_]/g, '_')
72
+ const envFile = ({ db, auth, name, secret }) =>
73
+ ({ postgres: `DATABASE_URL=postgres://localhost:5432/${database(name)}\n`, mysql: `DATABASE_URL=mysql://root@localhost:3306/${database(name)}\n`, sqlite: 'DATABASE_URL=file:.data/app.db\n' }[db]) +
74
+ (auth ? `BETTER_AUTH_SECRET=${secret}\nBETTER_AUTH_URL=http://localhost:5173\n` : '')
75
+
76
+ const envModule = ({ db, auth }) => `import { createEnv } from '@t3-oss/env-core'
77
+ import { z } from 'zod'
78
+
79
+ // Checked when the server starts. A build holds no secrets, so \`weft build\` skips it.
80
+ export const env = createEnv({
81
+ server: {
82
+ DATABASE_URL: ${db !== 'sqlite' ? 'z.url()' : "z.string().min(1).default('file:.data/app.db')"},${auth ? '\n BETTER_AUTH_SECRET: z.string().min(32),' : ''}
83
+ },
84
+ runtimeEnv: process.env,
85
+ emptyStringAsUndefined: true,
86
+ skipValidation: !!process.env.WEFT_BUILD,
87
+ })
88
+ `
89
+
90
+ function sqliteSchema(auth) {
91
+ const owner = auth ? `\n userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),` : ''
92
+ return `import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
93
+ ${auth ? authTablesSqlite() : ''}
94
+ export const todos = sqliteTable('todos', {
95
+ id: text('id').primaryKey(),${owner}
96
+ title: text('title').notNull(),
97
+ done: integer('done', { mode: 'boolean' }).notNull().default(false),
98
+ createdAt: integer('created_at').notNull(),
99
+ })
100
+ `
101
+ }
102
+
103
+ function pgSchema(auth) {
104
+ const owner = auth ? `\n userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),` : ''
105
+ return `import { bigint, boolean, pgTable, text${auth ? ', timestamp' : ''} } from 'drizzle-orm/pg-core'
106
+ ${auth ? authTablesPg() : ''}
107
+ export const todos = pgTable('todos', {
108
+ id: text('id').primaryKey(),${owner}
109
+ title: text('title').notNull(),
110
+ done: boolean('done').notNull().default(false),
111
+ createdAt: bigint('created_at', { mode: 'number' }).notNull(),
112
+ })
113
+ `
114
+ }
115
+
116
+ // MySQL can't index a TEXT column, so ids, keys and anything unique are VARCHAR. Otherwise Postgres's tables hold.
117
+ const authTablesMysql = () => authTablesPg()
118
+ .replaceAll('pgTable', 'mysqlTable')
119
+ .replace(/text\('(id|user_id|email|token|account_id|provider_id|identifier)'\)/g, "varchar('$1', { length: 255 })")
120
+ .replace(/timestamp\('(\w+)'\)/g, "timestamp('$1', { fsp: 3 })")
121
+
122
+ function mysqlSchema(auth) {
123
+ const owner = auth ? `\n userId: varchar('user_id', { length: 255 }).notNull().references(() => user.id, { onDelete: 'cascade' }),` : ''
124
+ return `import { bigint, boolean, mysqlTable, ${auth ? 'text, timestamp, ' : ''}varchar } from 'drizzle-orm/mysql-core'
125
+ ${auth ? authTablesMysql() : ''}
126
+ export const todos = mysqlTable('todos', {
127
+ id: varchar('id', { length: 36 }).primaryKey(),${owner}
128
+ title: varchar('title', { length: 200 }).notNull(),
129
+ done: boolean('done').notNull().default(false),
130
+ createdAt: bigint('created_at', { mode: 'number' }).notNull(),
131
+ })
132
+ `
133
+ }
134
+
135
+ const authTablesSqlite = () => {
136
+ const when = (col, opts = '.notNull()') => `integer('${col}', { mode: 'timestamp_ms' })${opts}`
137
+ return `
138
+ // Better Auth's tables: it reads and writes them through Drizzle, with these names.
139
+ export const user = sqliteTable('user', {
140
+ id: text('id').primaryKey(),
141
+ name: text('name').notNull(),
142
+ email: text('email').notNull().unique(),
143
+ emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(),
144
+ image: text('image'),
145
+ createdAt: ${when('created_at')},
146
+ updatedAt: ${when('updated_at')},
147
+ })
148
+
149
+ export const session = sqliteTable('session', {
150
+ id: text('id').primaryKey(),
151
+ expiresAt: ${when('expires_at')},
152
+ token: text('token').notNull().unique(),
153
+ createdAt: ${when('created_at')},
154
+ updatedAt: ${when('updated_at')},
155
+ ipAddress: text('ip_address'),
156
+ userAgent: text('user_agent'),
157
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
158
+ })
159
+
160
+ export const account = sqliteTable('account', {
161
+ id: text('id').primaryKey(),
162
+ accountId: text('account_id').notNull(),
163
+ providerId: text('provider_id').notNull(),
164
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
165
+ accessToken: text('access_token'),
166
+ refreshToken: text('refresh_token'),
167
+ idToken: text('id_token'),
168
+ accessTokenExpiresAt: ${when('access_token_expires_at', '')},
169
+ refreshTokenExpiresAt: ${when('refresh_token_expires_at', '')},
170
+ scope: text('scope'),
171
+ password: text('password'),
172
+ createdAt: ${when('created_at')},
173
+ updatedAt: ${when('updated_at')},
174
+ })
175
+
176
+ export const verification = sqliteTable('verification', {
177
+ id: text('id').primaryKey(),
178
+ identifier: text('identifier').notNull(),
179
+ value: text('value').notNull(),
180
+ expiresAt: ${when('expires_at')},
181
+ createdAt: ${when('created_at', '')},
182
+ updatedAt: ${when('updated_at', '')},
183
+ })
184
+ `
185
+ }
186
+
187
+ const authTablesPg = () => `
188
+ // Better Auth's tables: it reads and writes them through Drizzle, with these names.
189
+ export const user = pgTable('user', {
190
+ id: text('id').primaryKey(),
191
+ name: text('name').notNull(),
192
+ email: text('email').notNull().unique(),
193
+ emailVerified: boolean('email_verified').notNull(),
194
+ image: text('image'),
195
+ createdAt: timestamp('created_at').notNull(),
196
+ updatedAt: timestamp('updated_at').notNull(),
197
+ })
198
+
199
+ export const session = pgTable('session', {
200
+ id: text('id').primaryKey(),
201
+ expiresAt: timestamp('expires_at').notNull(),
202
+ token: text('token').notNull().unique(),
203
+ createdAt: timestamp('created_at').notNull(),
204
+ updatedAt: timestamp('updated_at').notNull(),
205
+ ipAddress: text('ip_address'),
206
+ userAgent: text('user_agent'),
207
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
208
+ })
209
+
210
+ export const account = pgTable('account', {
211
+ id: text('id').primaryKey(),
212
+ accountId: text('account_id').notNull(),
213
+ providerId: text('provider_id').notNull(),
214
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
215
+ accessToken: text('access_token'),
216
+ refreshToken: text('refresh_token'),
217
+ idToken: text('id_token'),
218
+ accessTokenExpiresAt: timestamp('access_token_expires_at'),
219
+ refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
220
+ scope: text('scope'),
221
+ password: text('password'),
222
+ createdAt: timestamp('created_at').notNull(),
223
+ updatedAt: timestamp('updated_at').notNull(),
224
+ })
225
+
226
+ export const verification = pgTable('verification', {
227
+ id: text('id').primaryKey(),
228
+ identifier: text('identifier').notNull(),
229
+ value: text('value').notNull(),
230
+ expiresAt: timestamp('expires_at').notNull(),
231
+ createdAt: timestamp('created_at'),
232
+ updatedAt: timestamp('updated_at'),
233
+ })
234
+ `
235
+
236
+ /** `src/context.server.ts`: the database on `ctx`, and the session with auth. */
237
+ export const context = auth => auth
238
+ ? `import { session } from '@weft-js/start/auth'
239
+ import auth from '#/auth.server'
240
+ import { db } from '#/db/index.server'
241
+
242
+ export default async ({ request }: { request: Request }) => ({ db, ...(await session(auth, request)) })
243
+
244
+ type Session = Awaited<ReturnType<typeof auth.session>>
245
+
246
+ declare module '@weft-js/start' {
247
+ interface Context {
248
+ db: typeof db
249
+ user: Session['user']
250
+ session: Session['session']
251
+ }
252
+ }
253
+ `
254
+ : `import { db } from '#/db/index.server'
255
+
256
+ export default () => ({ db })
257
+
258
+ declare module '@weft-js/start' {
259
+ interface Context {
260
+ db: typeof db
261
+ }
262
+ }
263
+ `
264
+
265
+ function todosServer(auth, db) {
266
+ const use = auth ? `\n auth: 'user',` : ''
267
+ const mine = auth ? `and(eq(todos.id, input.id), eq(todos.userId, ctx.user.id))` : `eq(todos.id, input.id)`
268
+ // MySQL has no \`returning\`.
269
+ const returning = db === 'mysql' ? '' : '.returning()'
270
+ return `import { mutation, query } from '@weft-js/start'
271
+ import { schemasOf } from '@weft-js/start/db'
272
+ import { ${auth ? 'and, ' : ''}asc, eq, not } from 'drizzle-orm'
273
+ import { z } from 'zod'
274
+ import * as schema from '#/db/schema'
275
+
276
+ const { todos } = schema
277
+ const schemas = schemasOf(schema, { todos: { title: (s: z.ZodString) => s.trim().min(1).max(200) } })
278
+ const byId = z.object({ id: z.string() })
279
+
280
+ export const listTodos = query({
281
+ tags: ['todos'],${use}
282
+ run: ({ ctx }) => ctx.db.select().from(todos)${auth ? '.where(eq(todos.userId, ctx.user.id))' : ''}.orderBy(asc(todos.createdAt)),
283
+ })
284
+
285
+ export const addTodo = mutation({
286
+ input: schemas.todos.insert.pick({ title: true }),${use}
287
+ invalidates: ['todos'],
288
+ optimistic: ({ input }, cache) =>
289
+ cache.update(listTodos, undefined, list => void list.push({ id: \`tmp:\${input.title}\`,${auth ? " userId: ''," : ''} title: input.title, done: false, createdAt: 0 })),
290
+ run: async ({ input, ctx }) => {
291
+ ${db === 'mysql' ? `const row = { id: crypto.randomUUID(),${auth ? ' userId: ctx.user.id,' : ''} title: input.title, createdAt: Date.now() }
292
+ await ctx.db.insert(todos).values(row)
293
+ return { ...row, done: false }` : `const [row] = await ctx.db.insert(todos).values({ id: crypto.randomUUID(),${auth ? ' userId: ctx.user.id,' : ''} title: input.title, createdAt: Date.now() }).returning()
294
+ return row`}
295
+ },
296
+ })
297
+
298
+ export const toggleTodo = mutation({
299
+ input: byId,${use}
300
+ invalidates: ['todos'],
301
+ optimistic: ({ input }, cache) =>
302
+ cache.update(listTodos, undefined, list => {
303
+ const t = list.find(x => x.id === input.id)
304
+ if (t) t.done = !t.done
305
+ }),
306
+ run: ({ input, ctx }) => ctx.db.update(todos).set({ done: not(todos.done) }).where(${mine})${returning},
307
+ })
308
+
309
+ export const deleteTodo = mutation({
310
+ input: byId,${use}
311
+ invalidates: ['todos'],
312
+ optimistic: ({ input }, cache) => cache.update(listTodos, undefined, list => list.filter(x => x.id !== input.id)),
313
+ run: ({ input, ctx }) => ctx.db.delete(todos).where(${mine})${returning},
314
+ })
315
+ `
316
+ }
317
+
318
+ const sessionServer = () => `import { mutation, query, redirect } from '@weft-js/start'
319
+ import { forward } from '@weft-js/start/auth'
320
+ import { z } from 'zod'
321
+ import auth from '#/auth.server'
322
+
323
+ const credentials = z.object({ email: z.email(), password: z.string().min(8) })
324
+
325
+ /** Who is signed in, or null. */
326
+ export const me = query({
327
+ tags: ['session'],
328
+ run: ({ ctx }) => (ctx.user ? { id: ctx.user.id, name: ctx.user.name, email: ctx.user.email } : null),
329
+ })
330
+
331
+ export const signUp = mutation({
332
+ input: credentials.extend({ name: z.string().trim().min(1) }),
333
+ run: async ({ input, ctx }) => {
334
+ await forward(ctx, auth.api.signUpEmail({ body: input, headers: ctx.request.headers, asResponse: true }))
335
+ redirect('/todos')
336
+ },
337
+ })
338
+
339
+ export const signIn = mutation({
340
+ input: credentials,
341
+ run: async ({ input, ctx }) => {
342
+ await forward(ctx, auth.api.signInEmail({ body: input, headers: ctx.request.headers, asResponse: true }))
343
+ redirect('/todos')
344
+ },
345
+ })
346
+
347
+ export const signOut = mutation({
348
+ run: async ({ ctx }) => {
349
+ await forward(ctx, auth.api.signOut({ headers: ctx.request.headers, asResponse: true }))
350
+ redirect('/login')
351
+ },
352
+ })
353
+ `
354
+
355
+ const guard = () => `import { redirect, type LoadArgs, type RouteProps } from '@weft-js/start'
356
+
357
+ // The guard: a layout's \`load\` runs before its pages', so nothing under /todos runs for a visitor.
358
+ export const load = ({ ctx }: LoadArgs) => {
359
+ if (!ctx.user) redirect('/login')
360
+ }
361
+
362
+ export default function TodosLayout(props: RouteProps) {
363
+ return <>{props.children}</>
364
+ }
365
+ `
366
+
367
+ // Tailwind: the same files `weft add tailwind` writes (packages/cli/src/tailwind.mjs, held equal by a test).
368
+ /** What `--ui tailwind` adds, pinned. */
369
+ export const tailwindPackages = { tailwindcss: '4.3.3', '@tailwindcss/vite': '4.3.3' }
370
+ /** `vite.config.ts` for `--ui tailwind`. */
371
+ export const TAILWIND_VITE = `import tailwindcss from '@tailwindcss/vite'
372
+
373
+ export default { plugins: [tailwindcss()] }
374
+ `
375
+ /** The stylesheet for `--ui tailwind`. */
376
+ export const TAILWIND_CSS = `@import "tailwindcss";
377
+ `
378
+ const tw = {
379
+ card: 'mx-auto max-w-2xl space-y-4 rounded-lg border border-current/15 p-6',
380
+ title: 'text-2xl font-semibold',
381
+ button: 'rounded-md border border-current/30 px-3 py-1.5 hover:bg-current/5',
382
+ input: 'rounded-md border border-current/30 px-3 py-1.5',
383
+ }
384
+ /** The class of a scaffold element: Tailwind's utilities, or the plain CSS in src/app.css. */
385
+ export const classes = (ui, part) => (ui === 'tailwind' ? tw[part] : part === 'card' ? 'card' : '')
386
+ const attr = (ui, part) => (ui === 'tailwind' ? ` class="${tw[part]}"` : '')
387
+
388
+ /** The page's controls in each UI kit: warp's components, or plain elements with the same roles and names. */
389
+ export const controls = ui => ui === 'warp'
390
+ ? {
391
+ imports: `import { Alert, Button, Card, Checkbox, Input } from '@weft-js/warp'\n`,
392
+ card: (title, body) => `<Card title=${JSON.stringify(title)}>${body}</Card>`,
393
+ input: (label, box, type = 'text') => `<Input label=${JSON.stringify(label)} type="${type}" value={${box}.value} onInput={e => (${box}.value = e.currentTarget.value)} />`,
394
+ submit: (label, pending) => `<Button type="submit" loading={${pending}}>${label}</Button>`,
395
+ button: (label, onClick, extra = '') => `<Button emphasis="ghost" ${extra}onClick={${onClick}}>${label}</Button>`,
396
+ error: box => `{${box}.value && <Alert tone="critical" live>{${box}.value}</Alert>}`,
397
+ }
398
+ : {
399
+ imports: '',
400
+ card: (title, body) => `<section class="${classes(ui, 'card')}"><h1${attr(ui, 'title')}>${title}</h1>${body}</section>`,
401
+ input: (label, box, type = 'text') => `<label>${label} <input${attr(ui, 'input')} type="${type}" value={${box}.value} onInput={e => (${box}.value = e.currentTarget.value)} /></label>`,
402
+ submit: (label, pending) => `<button${attr(ui, 'button')} type="submit" disabled={${pending}}>${label}</button>`,
403
+ button: (label, onClick, extra = '') => `<button${attr(ui, 'button')} type="button" ${extra}onClick={${onClick}}>${label}</button>`,
404
+ error: box => `{${box}.value && <p role="alert">{${box}.value}</p>}`,
405
+ }
406
+
407
+ /** `/todos`: the list on the server functions, written for this `--ui`. */
408
+ export function todosPage(ui) {
409
+ const c = controls(ui)
410
+ return `import type { Meta } from '@weft-js/start'
411
+ import { Boundary, state } from '@weft-js/core'
412
+ ${c.imports}import { addTodo, deleteTodo, listTodos, toggleTodo } from '#/server/todos'
413
+
414
+ export const meta: Meta = { title: 'Todos' }
415
+
416
+ // The query is made in here, inside the Boundary, so the Boundary sees it: it shows \`pending\` until the list arrives.
417
+ function TodoList() {
418
+ const todos = listTodos()
419
+ return (
420
+ <ul class="todos">
421
+ {(todos.value ?? []).map(t => (
422
+ <li key={t.id} class={t.done ? 'done' : 'open'}>
423
+ <label>
424
+ <input type="checkbox" checked={t.done} onChange={() => toggleTodo({ id: t.id })} /> {t.title}
425
+ </label>
426
+ <button type="button" aria-label={\`Delete \${t.title}\`} onClick={() => deleteTodo({ id: t.id })}>×</button>
427
+ </li>
428
+ ))}
429
+ </ul>
430
+ )
431
+ }
432
+
433
+ export default function Todos() {
434
+ const title = state('')
435
+ const problem = state('')
436
+ const add = async (e: SubmitEvent) => {
437
+ e.preventDefault()
438
+ const text = title.value.trim()
439
+ if (!text) return
440
+ title.value = ''
441
+ problem.value = ''
442
+ await addTodo({ title: text }).catch((err: Error) => (problem.value = err.message))
443
+ }
444
+ return (
445
+ ${c.card('Todos', `
446
+ <form onSubmit={add}>
447
+ ${c.input('New todo', 'title')}
448
+ ${c.submit('Add', 'addTodo.pending.value')}
449
+ </form>
450
+ ${c.error('problem')}
451
+ <Boundary pending={<p>Loading…</p>}>
452
+ <TodoList />
453
+ </Boundary>
454
+ `)}
455
+ )
456
+ }
457
+ `
458
+ }
459
+
460
+ /** `/login`: sign in and sign up, written for this `--ui`. */
461
+ export function loginPage(ui) {
462
+ const c = controls(ui)
463
+ return `import { redirect, type LoadArgs, type Meta } from '@weft-js/start'
464
+ import { state } from '@weft-js/core'
465
+ ${c.imports}import { signIn, signUp } from '#/server/session'
466
+
467
+ export const load = ({ ctx }: LoadArgs) => {
468
+ if (ctx.user) redirect('/todos')
469
+ }
470
+
471
+ export const meta: Meta = { title: 'Sign in' }
472
+
473
+ export default function Login() {
474
+ const creating = state(false)
475
+ const name = state('')
476
+ const email = state('')
477
+ const password = state('')
478
+ const problem = state('')
479
+ const submit = async (e: SubmitEvent) => {
480
+ e.preventDefault()
481
+ problem.value = ''
482
+ const done = creating.value
483
+ ? signUp({ name: name.value, email: email.value, password: password.value })
484
+ : signIn({ email: email.value, password: password.value })
485
+ await done.catch((err: Error) => (problem.value = err.message))
486
+ }
487
+ return (
488
+ ${c.card('Sign in', `
489
+ <form onSubmit={submit}>
490
+ {creating.value && ${c.input('Name', 'name')}}
491
+ ${c.input('Email', 'email', 'email')}
492
+ ${c.input('Password', 'password', 'password')}
493
+ ${c.submit(`{creating.value ? 'Create account' : 'Sign in'}`, 'signIn.pending.value || signUp.pending.value')}
494
+ </form>
495
+ ${c.error('problem')}
496
+ ${c.button(`{creating.value ? 'I have an account' : 'Create an account'}`, '() => (creating.value = !creating.value)')}
497
+ `)}
498
+ )
499
+ }
500
+ `
501
+ }
package/src/wizard.mjs ADDED
@@ -0,0 +1,78 @@
1
+ // The create-weft wizard (research 34 §3): each choice is a list with the default selected. A flag answers its question,
2
+ // and with no terminal or with --yes every unanswered question takes its default, so agents and CI never see a prompt.
3
+ import { emitKeypressEvents } from 'node:readline'
4
+ import { CONFIGS, DB, LAYERS, RUNTIMES, UI } from './index.mjs'
5
+
6
+ const PMS = ['npm', 'pnpm', 'bun', 'deno', 'yarn']
7
+ const LABELS = {
8
+ warp: 'warp', headless: 'Headless (warp behaviour, your markup)', tailwind: 'Tailwind', none: 'None',
9
+ sqlite: 'SQLite', postgres: 'Postgres', mysql: 'MySQL', drizzle: 'Drizzle', prisma: 'Prisma', native: 'Native (no ORM)', node: 'Node', bun: 'Bun', deno: 'Deno', npm: 'npm', pnpm: 'pnpm', yarn: 'Yarn',
10
+ ts: 'TypeScript', json: 'JSON', yaml: 'YAML',
11
+ }
12
+ const list = values => values.map(value => ({ value, label: LABELS[value] ?? value }))
13
+ const yesNo = [{ value: true, label: 'Yes' }, { value: false, label: 'No' }]
14
+ /** Whichever of Node, Bun and Deno is running this: a machine may have only that one. */
15
+ const host = process.versions.deno ? 'deno' : process.versions.bun ? 'bun' : 'node'
16
+ const packageManager = env => (host === 'node' ? PMS.find(pm => env.npm_config_user_agent?.startsWith(pm)) ?? 'npm' : host)
17
+
18
+ /** In order. `when` skips a question that doesn't apply. `sideEffect` questions default to off with no terminal. */
19
+ export const questions = env => [
20
+ { key: 'ui', label: 'UI', default: 'warp', options: list(UI) },
21
+ { key: 'db', label: 'Database', default: 'sqlite', options: list(DB) },
22
+ { key: 'layer', label: 'Data layer', default: 'drizzle', skip: 'drizzle', options: list(LAYERS), when: a => a.db !== 'none' },
23
+ { key: 'auth', label: 'Sign-in', default: true, options: [{ value: true, label: 'Better Auth' }, { value: 'custom', label: 'Bring your own' }, { value: false, label: 'None' }], when: a => a.db !== 'none' },
24
+ { key: 'runtime', label: 'Runtime', default: host, options: list(RUNTIMES) },
25
+ { key: 'config', label: 'Config file', default: 'ts', options: list(CONFIGS) },
26
+ { key: 'pm', label: 'Package manager', default: packageManager(env), options: list(PMS) },
27
+ { key: 'git', label: 'Start a git repository', default: true, options: yesNo, sideEffect: true },
28
+ { key: 'install', label: 'Install dependencies', default: true, options: yesNo, sideEffect: true },
29
+ ]
30
+
31
+ /** One list on a terminal: arrows move, Enter picks. Resolves with the option's value. */
32
+ export function select({ label, options, default: initial }, { input, output }) {
33
+ let at = Math.max(0, options.findIndex(o => o.value === initial))
34
+ const draw = first => {
35
+ if (!first) output.write(`\x1b[${options.length}A`)
36
+ options.forEach((o, i) => output.write(`\x1b[2K${i === at ? '› ' : ' '}${o.label}\n`))
37
+ }
38
+ return new Promise(ok => {
39
+ output.write(`${label}\n`)
40
+ draw(true)
41
+ emitKeypressEvents(input)
42
+ input.setRawMode?.(true)
43
+ input.resume()
44
+ const done = value => {
45
+ input.off('keypress', onKey)
46
+ input.setRawMode?.(false)
47
+ input.pause()
48
+ ok(value)
49
+ }
50
+ const onKey = (_, key) => {
51
+ if (key?.ctrl && key.name === 'c') {
52
+ input.setRawMode?.(false)
53
+ process.exit(130)
54
+ }
55
+ if (key?.name === 'return' || key?.name === 'enter') return done(options[at].value)
56
+ if (key?.name === 'up') at = (at + options.length - 1) % options.length
57
+ else if (key?.name === 'down') at = (at + 1) % options.length
58
+ else return
59
+ draw(false)
60
+ }
61
+ input.on('keypress', onKey)
62
+ })
63
+ }
64
+
65
+ /** Every choice, from flags, then the terminal if there is one, then the defaults. */
66
+ export async function resolveChoices(given, { interactive, yes = false, env = process.env, io } = {}) {
67
+ const choices = { ...given }
68
+ for (const q of questions(env)) {
69
+ if (q.when && !q.when(choices)) {
70
+ choices[q.key] ??= q.skip ?? false
71
+ continue
72
+ }
73
+ if (choices[q.key] !== undefined) continue
74
+ if (interactive && !yes) choices[q.key] = await select(q, io)
75
+ else choices[q.key] = q.sideEffect && !interactive && !yes ? false : q.default
76
+ }
77
+ return choices
78
+ }