@weft-js/create 0.0.0-stage → 0.1.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 +16 -0
- package/README.md +27 -2
- package/bin/create-weft.mjs +73 -0
- package/package.json +28 -4
- package/scripts/tarballs.mjs +27 -0
- package/src/data.mjs +288 -0
- package/src/index.mjs +241 -0
- package/src/stack.mjs +501 -0
- package/src/wizard.mjs +78 -0
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
|
-
#
|
|
1
|
+
# @weft-js/create
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Early release (0.1.0).** 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
}
|