create-basalt 1.6.0 → 1.8.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/README.md +85 -8
- package/dist/args.d.ts +2 -1
- package/dist/args.js +8 -0
- package/dist/cli.js +9 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +13 -3
- package/dist/latest-versions.js +5 -0
- package/dist/templates.d.ts +38 -2
- package/dist/templates.js +593 -41
- package/dist/versions.js +10 -6
- package/dist/wizard.d.ts +2 -1
- package/dist/wizard.js +4 -2
- package/package.json +17 -8
package/dist/templates.js
CHANGED
|
@@ -46,13 +46,34 @@ export function packageJson(options) {
|
|
|
46
46
|
basalt.push('@basaltkit/mcp');
|
|
47
47
|
if (options.cli) {
|
|
48
48
|
// Runtime deps: app.ts uses commandsPlugin (@basaltkit/cli); prisma powers
|
|
49
|
-
// `basalt prisma:sync` (and prismaPlugin
|
|
50
|
-
//
|
|
49
|
+
// `basalt prisma:sync` (and prismaPlugin, wired by --prisma).
|
|
50
|
+
// @basaltkit/generator is dev-only (below).
|
|
51
51
|
basalt.push('@basaltkit/cli', '@basaltkit/prisma');
|
|
52
52
|
}
|
|
53
|
+
if (options.prisma) {
|
|
54
|
+
// The database layer: prismaPlugin + tenancyExtension, plus the store
|
|
55
|
+
// packages for whichever domains are on (the memory ones are gone).
|
|
56
|
+
if (!options.cli)
|
|
57
|
+
basalt.push('@basaltkit/prisma');
|
|
58
|
+
if (options.tenancy)
|
|
59
|
+
basalt.push('@basaltkit/tenancy-prisma');
|
|
60
|
+
if (options.auth)
|
|
61
|
+
basalt.push('@basaltkit/auth-prisma');
|
|
62
|
+
if (enforcesMembership(options))
|
|
63
|
+
basalt.push('@basaltkit/teams-prisma');
|
|
64
|
+
if (options.billing)
|
|
65
|
+
basalt.push('@basaltkit/subscriptions-prisma');
|
|
66
|
+
}
|
|
53
67
|
const dependencies = { zod: thirdPartyVersionOf('zod') };
|
|
54
68
|
for (const pkg of basalt)
|
|
55
69
|
dependencies[pkg] = versionOf(pkg);
|
|
70
|
+
if (options.prisma) {
|
|
71
|
+
// Prisma 7 talks to PostgreSQL through a driver adapter, so `pg` is a real
|
|
72
|
+
// runtime dependency (see src/db.ts); the `prisma` CLI is dev-only.
|
|
73
|
+
for (const pkg of ['@prisma/adapter-pg', '@prisma/client', 'pg']) {
|
|
74
|
+
dependencies[pkg] = thirdPartyVersionOf(pkg);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
56
77
|
const devDependencies = {
|
|
57
78
|
'@basaltkit/testing': versionOf('@basaltkit/testing'),
|
|
58
79
|
};
|
|
@@ -70,6 +91,10 @@ export function packageJson(options) {
|
|
|
70
91
|
// app ships and runs without it (see `.mcp.json` for the client config).
|
|
71
92
|
devDependencies['@basaltkit/ai-mcp'] = versionOf('@basaltkit/ai-mcp');
|
|
72
93
|
}
|
|
94
|
+
if (options.prisma) {
|
|
95
|
+
for (const pkg of ['@types/pg', 'prisma'])
|
|
96
|
+
devDependencies[pkg] = thirdPartyVersionOf(pkg);
|
|
97
|
+
}
|
|
73
98
|
return `${JSON.stringify({
|
|
74
99
|
name: options.name,
|
|
75
100
|
version: '0.1.0',
|
|
@@ -83,12 +108,27 @@ export function packageJson(options) {
|
|
|
83
108
|
test: 'vitest run',
|
|
84
109
|
typecheck: 'tsc --noEmit',
|
|
85
110
|
...(options.cli ? { basalt: 'tsx bin/basalt.ts' } : {}),
|
|
111
|
+
...(options.prisma
|
|
112
|
+
? {
|
|
113
|
+
// Generate right after install so `pnpm typecheck` has the client
|
|
114
|
+
// types (generation needs no database).
|
|
115
|
+
postinstall: 'prisma generate',
|
|
116
|
+
'db:generate': 'prisma generate',
|
|
117
|
+
// Development and deployment BOTH go through migrations: they are
|
|
118
|
+
// what creates `_prisma_migrations`, which the app's
|
|
119
|
+
// prismaPlugin({ assertMigrated: true }) requires at boot. There
|
|
120
|
+
// is deliberately no `db:push` script.
|
|
121
|
+
'db:migrate': 'prisma migrate dev',
|
|
122
|
+
'db:deploy': 'prisma migrate deploy',
|
|
123
|
+
...(options.tenancy ? { 'db:seed': 'tsx prisma/seed.ts' } : {}),
|
|
124
|
+
}
|
|
125
|
+
: {}),
|
|
86
126
|
},
|
|
87
127
|
dependencies: Object.fromEntries(Object.entries(dependencies).sort()),
|
|
88
128
|
devDependencies: Object.fromEntries(Object.entries(devDependencies).sort()),
|
|
89
129
|
}, null, 2)}\n`;
|
|
90
130
|
}
|
|
91
|
-
export function tsconfigJson() {
|
|
131
|
+
export function tsconfigJson(options) {
|
|
92
132
|
return `${JSON.stringify({
|
|
93
133
|
compilerOptions: {
|
|
94
134
|
target: 'ES2022',
|
|
@@ -101,29 +141,53 @@ export function tsconfigJson() {
|
|
|
101
141
|
noEmit: true,
|
|
102
142
|
types: ['node'],
|
|
103
143
|
},
|
|
104
|
-
|
|
144
|
+
// The generated Prisma client lives under src/generated (git-ignored, and
|
|
145
|
+
// re-created by `prisma generate`); the seed script under prisma/ is
|
|
146
|
+
// type-checked too.
|
|
147
|
+
include: ['src', 'tests', ...(options.prisma && options.tenancy ? ['prisma/seed.ts'] : [])],
|
|
105
148
|
}, null, 2)}\n`;
|
|
106
149
|
}
|
|
107
150
|
export function envTs(options) {
|
|
151
|
+
const prefix = envPrefix(options.name);
|
|
108
152
|
return `import { defineEnv${options.auth ? ', secret' : ''} } from '@basaltkit/env'
|
|
109
153
|
import { LOG_LEVELS } from '@basaltkit/logger'
|
|
110
154
|
import { z } from 'zod'
|
|
111
155
|
|
|
112
|
-
export const env = defineEnv(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
156
|
+
export const env = defineEnv(
|
|
157
|
+
{
|
|
158
|
+
PORT: z.coerce.number().default(3000),
|
|
159
|
+
HOST: z.string().default('0.0.0.0'),
|
|
160
|
+
// Typed against the logger's LogLevel union — a free-form string here fails
|
|
161
|
+
// \`pnpm typecheck\` where loggerPlugin({ level }) consumes it.
|
|
162
|
+
LOG_LEVEL: z.enum(LOG_LEVELS).default('info'),
|
|
163
|
+
// Unset counts as production (fail-closed); \`pnpm dev\` sets development.
|
|
164
|
+
// NODE_ENV is a Node-wide convention and is never prefixed.
|
|
165
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),${options.prisma
|
|
120
166
|
? `
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
167
|
+
// Required: the PostgreSQL connection string, read as ${prefix}_DATABASE_URL
|
|
168
|
+
// first (a stray DATABASE_URL exported in your shell must not win — that is
|
|
169
|
+
// how an app boots against another project's database). src/db.ts builds the
|
|
170
|
+
// Prisma client from it; prisma.config.ts gives the CLI the same URL.
|
|
171
|
+
DATABASE_URL: z.string().min(1),`
|
|
172
|
+
: ''}${options.auth
|
|
173
|
+
? `
|
|
174
|
+
// Signs JWTs and sessions. secret() is fail-closed: required unless NODE_ENV
|
|
175
|
+
// is explicitly development/test (no fallback when NODE_ENV is unset),
|
|
176
|
+
// rejected if it looks like a placeholder. \`pnpm dev\` uses devDefault.
|
|
177
|
+
APP_SECRET: secret({ minLength: 32, devDefault: 'dev-only-insecure-secret-please-change-me' }),`
|
|
125
178
|
: ''}
|
|
126
|
-
}
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
// Every variable is read as ${prefix}_<NAME> first (${prefix}_PORT before PORT),
|
|
182
|
+
// falling back to the bare name. \`--env-file\` never overrides a variable
|
|
183
|
+
// already exported in your shell, so without the prefix another project's
|
|
184
|
+
// exported PORT / DATABASE_URL would silently win. The keys above stay bare:
|
|
185
|
+
// the rest of the app still reads env.PORT.
|
|
186
|
+
// To require the prefixed names (no bare fallback at all), write:
|
|
187
|
+
// prefix: { value: '${prefix}', fallback: false }
|
|
188
|
+
prefix: '${prefix}',
|
|
189
|
+
},
|
|
190
|
+
)
|
|
127
191
|
`;
|
|
128
192
|
}
|
|
129
193
|
/**
|
|
@@ -151,13 +215,34 @@ export function envExample(options) {
|
|
|
151
215
|
return `# Nothing loads this file for you: copy it to .env and start with
|
|
152
216
|
# \`node --env-file=.env\` / \`tsx --env-file=.env\`, or export the variables.
|
|
153
217
|
# --env-file NEVER overrides a variable already exported in your shell: if
|
|
154
|
-
# another project exported DATABASE_URL,
|
|
155
|
-
#
|
|
156
|
-
PORT
|
|
157
|
-
|
|
158
|
-
|
|
218
|
+
# another project exported PORT or DATABASE_URL, an app reading the generic
|
|
219
|
+
# name would silently use THAT one. That is why src/env.ts reads app-prefixed
|
|
220
|
+
# names — ${prefix}_PORT wins over a stray PORT. The bare names still work as a
|
|
221
|
+
# fallback, so an existing deployment exporting them keeps booting.
|
|
222
|
+
${prefix}_PORT=3000
|
|
223
|
+
${prefix}_HOST=0.0.0.0
|
|
224
|
+
${prefix}_LOG_LEVEL=info
|
|
225
|
+
# NODE_ENV is a Node-wide convention and is never prefixed.
|
|
159
226
|
NODE_ENV=development
|
|
160
|
-
${options.auth
|
|
227
|
+
${options.auth
|
|
228
|
+
? `# Required unless NODE_ENV is development/test — \`pnpm start\` refuses to boot
|
|
229
|
+
# without it. Generate a strong one: openssl rand -base64 48
|
|
230
|
+
# ${prefix}_APP_SECRET=
|
|
231
|
+
`
|
|
232
|
+
: ''}${options.prisma
|
|
233
|
+
? `# Required: the app does not boot without it. The Prisma CLI reads the same
|
|
234
|
+
# name from prisma.config.ts, so \`pnpm db:migrate\` and the running app always
|
|
235
|
+
# agree on which database they mean.
|
|
236
|
+
${prefix}_DATABASE_URL=postgres://postgres:postgres@localhost:5432/${databaseName(options.name)}
|
|
237
|
+
`
|
|
238
|
+
: `# When you add a database, declare DATABASE_URL in src/env.ts and set it here
|
|
239
|
+
# under the prefixed name (or scaffold with --prisma, which does it for you):
|
|
240
|
+
# ${prefix}_DATABASE_URL=postgres://user:pass@localhost:5432/${options.name.replace(/^@[^/]+\//, '')}
|
|
241
|
+
`}`;
|
|
242
|
+
}
|
|
243
|
+
/** A PostgreSQL-safe database name derived from the project name. */
|
|
244
|
+
export function databaseName(name) {
|
|
245
|
+
return name.replace(/^@[^/]+\//, '').replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase();
|
|
161
246
|
}
|
|
162
247
|
export function appTs(options) {
|
|
163
248
|
const imports = [
|
|
@@ -182,24 +267,73 @@ export function appTs(options) {
|
|
|
182
267
|
})`,
|
|
183
268
|
];
|
|
184
269
|
let routesExpression = 'appRoutes';
|
|
270
|
+
/** Module-level bindings emitted between the imports and `buildApp`. */
|
|
271
|
+
const preamble = [];
|
|
272
|
+
if (options.prisma) {
|
|
273
|
+
imports.push(`import { prismaPlugin } from '@basaltkit/prisma'`);
|
|
274
|
+
imports.push(`import { db, prisma } from './db.js'`);
|
|
275
|
+
plugins.push(`prismaPlugin({
|
|
276
|
+
// \`db\` is the tenant-scoped client (src/db.ts): db<PrismaClient>() in a
|
|
277
|
+
// handler returns it, already filtered by the request's tenant.
|
|
278
|
+
client: db,
|
|
279
|
+
// Fail the BOOT when this database is not the migrated one — a shell that
|
|
280
|
+
// exported another project's DATABASE_URL, a typo in the database name.
|
|
281
|
+
// Without it the app starts and dies on the first query instead. Needs
|
|
282
|
+
// \`prisma migrate dev\`/\`migrate deploy\` (they write _prisma_migrations);
|
|
283
|
+
// \`prisma db push\` does not, so never use it against this app.
|
|
284
|
+
assertMigrated: true,
|
|
285
|
+
})`);
|
|
286
|
+
}
|
|
185
287
|
if (options.tenancy) {
|
|
186
|
-
|
|
187
|
-
|
|
288
|
+
if (options.prisma) {
|
|
289
|
+
imports.push(`import { headerResolver, subdomainResolver, tenancyPlugin } from '@basaltkit/tenancy'`);
|
|
290
|
+
imports.push(`import { prismaTenantSource } from '@basaltkit/tenancy-prisma'`);
|
|
291
|
+
// The stores read their own tables, outside any tenant context (a login
|
|
292
|
+
// happens before a tenant is known) — so they take the UNSCOPED client.
|
|
293
|
+
preamble.push(`/** Tenants live in the \`tenants\` table — seed one with \`pnpm db:seed\`. */
|
|
294
|
+
const tenants = prismaTenantSource(prisma)`);
|
|
295
|
+
plugins.push(`tenancyPlugin({
|
|
296
|
+
source: tenants,
|
|
297
|
+
resolvers: [headerResolver(), subdomainResolver({ base: 'localhost' })],
|
|
298
|
+
})`);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
imports.push(`import { headerResolver, MemoryTenantSource, subdomainResolver, tenancyPlugin } from '@basaltkit/tenancy'`);
|
|
302
|
+
plugins.push(`tenancyPlugin({
|
|
188
303
|
// Replace MemoryTenantSource with your database-backed source.
|
|
189
304
|
source: new MemoryTenantSource().add({ id: 'demo', name: 'Demo Tenant' }),
|
|
190
305
|
resolvers: [headerResolver(), subdomainResolver({ base: 'localhost' })],
|
|
191
306
|
})`);
|
|
307
|
+
}
|
|
192
308
|
}
|
|
193
309
|
if (options.auth) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
310
|
+
if (options.prisma) {
|
|
311
|
+
imports.push(`import { authPlugin, authRoutes, mfaRoutes } from '@basaltkit/auth'`);
|
|
312
|
+
imports.push(`import { prismaAuthStores } from '@basaltkit/auth-prisma'`);
|
|
313
|
+
imports.push(`import { env } from './env.js'`);
|
|
314
|
+
preamble.push(`/** Users, sessions, refresh tokens, email/reset tokens, MFA and API keys. */
|
|
315
|
+
const authStores = prismaAuthStores(prisma)`);
|
|
316
|
+
plugins.push(`authPlugin({
|
|
317
|
+
users: authStores.users,
|
|
318
|
+
sessions: authStores.sessions,
|
|
319
|
+
refreshTokens: authStores.refreshTokens,
|
|
320
|
+
// Email verification and password reset.
|
|
321
|
+
tokens: authStores.tokens,
|
|
322
|
+
mfa: authStores.mfa,
|
|
323
|
+
secret: env.APP_SECRET,
|
|
324
|
+
})`);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
imports.push(`import { authPlugin, authRoutes, mfaRoutes, MemoryUserSource } from '@basaltkit/auth'`);
|
|
328
|
+
imports.push(`import { env } from './env.js'`);
|
|
329
|
+
plugins.push(`authPlugin({
|
|
197
330
|
// Replace MemoryUserSource with your database-backed source (e.g.
|
|
198
331
|
// @basaltkit/auth-sqlite or @basaltkit/auth-prisma). The default in-memory
|
|
199
332
|
// MFA store is enough for the ready-made TOTP flow below.
|
|
200
333
|
users: new MemoryUserSource(),
|
|
201
334
|
secret: env.APP_SECRET,
|
|
202
335
|
})`);
|
|
336
|
+
}
|
|
203
337
|
// authRoutes(): register, login, logout, refresh, me, email verification and
|
|
204
338
|
// password recovery. mfaRoutes(): TOTP enroll/activate/status/disable.
|
|
205
339
|
routesExpression = '[...appRoutes, ...authRoutes(), ...mfaRoutes()]';
|
|
@@ -212,10 +346,21 @@ export function appTs(options) {
|
|
|
212
346
|
// meta: { central: true }.
|
|
213
347
|
imports[0] = `import { createApp, definePlugin } from '@basaltkit/core'`;
|
|
214
348
|
imports.push(`import { TEAMS, teamsPlugin, tenantMembershipPlugin } from '@basaltkit/teams'`);
|
|
215
|
-
|
|
349
|
+
if (options.prisma) {
|
|
350
|
+
imports.push(`import { prismaTeamsStores } from '@basaltkit/teams-prisma'`);
|
|
351
|
+
preamble.push(`/** Memberships and invitations (\`team_memberships\`, \`team_invitations\`). */
|
|
352
|
+
const teamStores = prismaTeamsStores(prisma)`);
|
|
353
|
+
plugins.push(`teamsPlugin({
|
|
354
|
+
memberships: teamStores.memberships,
|
|
355
|
+
invitations: teamStores.invitations,
|
|
356
|
+
})`);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
plugins.push(`teamsPlugin({
|
|
216
360
|
// TODO: the default membership/invitation stores are in-memory — pass
|
|
217
361
|
// persistent ones (memberships, invitations) before production.
|
|
218
362
|
})`);
|
|
363
|
+
}
|
|
219
364
|
plugins.push(`// Rejects authenticated requests for a tenant the user is not a member of.
|
|
220
365
|
tenantMembershipPlugin()`);
|
|
221
366
|
plugins.push(`// Dev-only seed: each new registrant joins the 'demo' tenant so the scaffold
|
|
@@ -225,12 +370,22 @@ export function appTs(options) {
|
|
|
225
370
|
}
|
|
226
371
|
if (options.billing) {
|
|
227
372
|
imports.push(`import { definePlans, subscriptionsPlugin } from '@basaltkit/subscriptions'`);
|
|
373
|
+
if (options.prisma) {
|
|
374
|
+
imports.push(`import { prismaSubscriptionsStores } from '@basaltkit/subscriptions-prisma'`);
|
|
375
|
+
preamble.push(`/** Subscriptions, usage counters and webhook idempotency. */
|
|
376
|
+
const subscriptionStores = prismaSubscriptionsStores(prisma)`);
|
|
377
|
+
}
|
|
228
378
|
plugins.push(`subscriptionsPlugin({
|
|
229
379
|
plans: definePlans({
|
|
230
380
|
free: { price: 0, features: { projects: 3 } },
|
|
231
381
|
pro: { price: { monthly: 29, yearly: 290 }, trial: '14d', features: { projects: 50 } },
|
|
232
382
|
}),
|
|
233
|
-
fallbackPlan: 'free'
|
|
383
|
+
fallbackPlan: 'free',${options.prisma
|
|
384
|
+
? `
|
|
385
|
+
store: subscriptionStores.store,
|
|
386
|
+
usage: subscriptionStores.usage,
|
|
387
|
+
webhooks: subscriptionStores.webhooks,`
|
|
388
|
+
: ''}
|
|
234
389
|
})`);
|
|
235
390
|
}
|
|
236
391
|
if (options.cli) {
|
|
@@ -259,7 +414,7 @@ export function appTs(options) {
|
|
|
259
414
|
: '';
|
|
260
415
|
return `${imports.join('\n')}
|
|
261
416
|
import { appRoutes } from './routes.js'
|
|
262
|
-
|
|
417
|
+
${preamble.length > 0 ? `\n${preamble.join('\n\n')}\n` : ''}
|
|
263
418
|
export interface BuildAppOptions {
|
|
264
419
|
logLevel?: LogLevel
|
|
265
420
|
pretty?: boolean${commandsField}
|
|
@@ -398,14 +553,338 @@ const app = buildApp({
|
|
|
398
553
|
process.exit(await runCli({ app }))
|
|
399
554
|
`;
|
|
400
555
|
}
|
|
556
|
+
/**
|
|
557
|
+
* The models each `@basaltkit/<domain>-prisma` package needs, copied VERBATIM
|
|
558
|
+
* from that package's reference `prisma/schema.prisma` — the same blocks
|
|
559
|
+
* `basalt prisma:sync` merges into an existing schema. The scaffold ships the
|
|
560
|
+
* merged result so a new app can run `prisma migrate dev` straight away;
|
|
561
|
+
* `tests/prisma.test.ts` fails if a package's reference schema drifts from the
|
|
562
|
+
* copy here.
|
|
563
|
+
*/
|
|
564
|
+
const PRISMA_MODELS = {
|
|
565
|
+
tenancy: `model Tenant {
|
|
566
|
+
id String @id
|
|
567
|
+
// The open tenant record ({ id, ...anything }) as JSON — attach whatever
|
|
568
|
+
// per-tenant fields you like; they round-trip unchanged.
|
|
569
|
+
data Json
|
|
570
|
+
domains TenantDomain[]
|
|
571
|
+
|
|
572
|
+
@@map("tenants")
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
model TenantDomain {
|
|
576
|
+
domain String @id
|
|
577
|
+
tenantId String
|
|
578
|
+
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
579
|
+
|
|
580
|
+
@@index([tenantId])
|
|
581
|
+
@@map("tenant_domains")
|
|
582
|
+
}`,
|
|
583
|
+
auth: `model AuthUser {
|
|
584
|
+
id String @id
|
|
585
|
+
email String @unique
|
|
586
|
+
passwordHash String
|
|
587
|
+
emailVerified Boolean @default(false)
|
|
588
|
+
|
|
589
|
+
@@map("auth_users")
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
model AuthSession {
|
|
593
|
+
id String @id
|
|
594
|
+
userId String
|
|
595
|
+
expiresAt DateTime
|
|
596
|
+
|
|
597
|
+
@@index([userId])
|
|
598
|
+
@@map("auth_sessions")
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
model AuthRefreshToken {
|
|
602
|
+
token String @id
|
|
603
|
+
familyId String
|
|
604
|
+
userId String
|
|
605
|
+
expiresAt DateTime
|
|
606
|
+
usedAt DateTime?
|
|
607
|
+
|
|
608
|
+
@@index([familyId])
|
|
609
|
+
@@index([userId])
|
|
610
|
+
@@map("auth_refresh_tokens")
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
model AuthToken {
|
|
614
|
+
token String @id
|
|
615
|
+
userId String
|
|
616
|
+
purpose String
|
|
617
|
+
expiresAt DateTime
|
|
618
|
+
usedAt DateTime?
|
|
619
|
+
|
|
620
|
+
@@index([userId, purpose])
|
|
621
|
+
@@map("auth_tokens")
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
model AuthApiKey {
|
|
625
|
+
id String @id
|
|
626
|
+
name String
|
|
627
|
+
prefix String
|
|
628
|
+
hash String @unique
|
|
629
|
+
tenantId String?
|
|
630
|
+
userId String?
|
|
631
|
+
scopes String[]
|
|
632
|
+
createdAt DateTime
|
|
633
|
+
expiresAt DateTime?
|
|
634
|
+
lastUsedAt DateTime?
|
|
635
|
+
revokedAt DateTime?
|
|
636
|
+
|
|
637
|
+
@@index([tenantId])
|
|
638
|
+
@@index([userId])
|
|
639
|
+
@@map("auth_api_keys")
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
model AuthMfa {
|
|
643
|
+
userId String @id
|
|
644
|
+
secret String
|
|
645
|
+
enabled Boolean @default(false)
|
|
646
|
+
recoveryCodes String[]
|
|
647
|
+
lastUsedStep Int?
|
|
648
|
+
|
|
649
|
+
@@map("auth_mfa")
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
model AuthTokenVersion {
|
|
653
|
+
userId String @id
|
|
654
|
+
version Int @default(0)
|
|
655
|
+
|
|
656
|
+
@@map("auth_token_versions")
|
|
657
|
+
}`,
|
|
658
|
+
teams: `model TeamMembership {
|
|
659
|
+
tenantId String
|
|
660
|
+
userId String
|
|
661
|
+
role String
|
|
662
|
+
createdAt DateTime
|
|
663
|
+
|
|
664
|
+
@@id([tenantId, userId])
|
|
665
|
+
@@map("team_memberships")
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
model TeamInvitation {
|
|
669
|
+
id String @id
|
|
670
|
+
tenantId String
|
|
671
|
+
email String
|
|
672
|
+
role String
|
|
673
|
+
token String @unique
|
|
674
|
+
invitedBy String?
|
|
675
|
+
expiresAt DateTime
|
|
676
|
+
acceptedAt DateTime?
|
|
677
|
+
revokedAt DateTime?
|
|
678
|
+
|
|
679
|
+
@@index([tenantId, email])
|
|
680
|
+
@@map("team_invitations")
|
|
681
|
+
}`,
|
|
682
|
+
subscriptions: `model Subscription {
|
|
683
|
+
billableId String @id
|
|
684
|
+
plan String
|
|
685
|
+
period String
|
|
686
|
+
status String
|
|
687
|
+
trialEndsAt DateTime?
|
|
688
|
+
cancelAtPeriodEnd Boolean?
|
|
689
|
+
canceledAt DateTime?
|
|
690
|
+
gatewayRef String?
|
|
691
|
+
pendingPlan String?
|
|
692
|
+
pendingPeriod String?
|
|
693
|
+
|
|
694
|
+
@@map("subscriptions")
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
model UsageCounter {
|
|
698
|
+
billableId String
|
|
699
|
+
feature String
|
|
700
|
+
periodKey String
|
|
701
|
+
value Int @default(0)
|
|
702
|
+
|
|
703
|
+
@@id([billableId, feature, periodKey])
|
|
704
|
+
@@map("usage_counters")
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
model WebhookEvent {
|
|
708
|
+
id String @id
|
|
709
|
+
seenAt DateTime @default(now())
|
|
710
|
+
|
|
711
|
+
@@map("webhook_events")
|
|
712
|
+
}`,
|
|
713
|
+
};
|
|
714
|
+
/**
|
|
715
|
+
* `prisma/schema.prisma` — the datasource, the client generator, the models of
|
|
716
|
+
* every Basalt domain this app enables, and the app's own first model.
|
|
717
|
+
*
|
|
718
|
+
* Prisma 7 no longer takes the connection URL here (prisma.config.ts does), and
|
|
719
|
+
* the client needs an explicit `output`; src/db.ts imports it from there.
|
|
720
|
+
*/
|
|
721
|
+
export function prismaSchema(options) {
|
|
722
|
+
const domains = [];
|
|
723
|
+
if (options.tenancy)
|
|
724
|
+
domains.push(PRISMA_MODELS.tenancy);
|
|
725
|
+
if (options.auth)
|
|
726
|
+
domains.push(PRISMA_MODELS.auth);
|
|
727
|
+
if (enforcesMembership(options))
|
|
728
|
+
domains.push(PRISMA_MODELS.teams);
|
|
729
|
+
if (options.billing)
|
|
730
|
+
domains.push(PRISMA_MODELS.subscriptions);
|
|
731
|
+
return `// Prisma schema for ${options.name}.
|
|
732
|
+
//
|
|
733
|
+
// The blocks below the header are the reference models of the @basaltkit/*-prisma
|
|
734
|
+
// packages this app uses — exactly what \`basalt prisma:sync\` merges. Add another
|
|
735
|
+
// Basalt domain later by installing its \`*-prisma\` package and running
|
|
736
|
+
// \`pnpm basalt prisma:sync\` (with --cli), then \`pnpm db:migrate\`.
|
|
737
|
+
//
|
|
738
|
+
// Apply changes with \`pnpm db:migrate\` (\`prisma migrate dev\`) — never
|
|
739
|
+
// \`prisma db push\`: push writes no _prisma_migrations table, and src/app.ts
|
|
740
|
+
// boots with prismaPlugin({ assertMigrated: true }), which requires it.
|
|
741
|
+
|
|
742
|
+
generator client {
|
|
743
|
+
provider = "prisma-client-js"
|
|
744
|
+
output = "../src/generated/prisma"
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
datasource db {
|
|
748
|
+
// The URL lives in prisma.config.ts (Prisma 7) — and in src/env.ts for the app.
|
|
749
|
+
provider = "postgresql"
|
|
750
|
+
}
|
|
751
|
+
${domains.map((block) => `\n${block}\n`).join('')}
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
// Your models.${options.tenancy
|
|
754
|
+
? ` \`tenantId\` is the column tenancyExtension() (src/db.ts)
|
|
755
|
+
// forces onto every query and every write, so keep it on everything a tenant
|
|
756
|
+
// owns — and make cross-model foreign keys composite (@@unique([tenantId, id])
|
|
757
|
+
// on the target) so the database refuses a cross-tenant link too.`
|
|
758
|
+
: ''}
|
|
759
|
+
|
|
760
|
+
model Project {
|
|
761
|
+
id String @id @default(uuid())${options.tenancy ? '\n tenantId String' : ''}
|
|
762
|
+
name String
|
|
763
|
+
createdAt DateTime @default(now())
|
|
764
|
+
${options.tenancy ? '\n @@index([tenantId])' : ''}
|
|
765
|
+
@@map("projects")
|
|
766
|
+
}
|
|
767
|
+
`;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* `prisma.config.ts` — Prisma 7 reads the connection URL (and the seed command)
|
|
771
|
+
* from here instead of from schema.prisma. It applies the SAME precedence as
|
|
772
|
+
* src/env.ts: the app-prefixed name first, the bare `DATABASE_URL` only as a
|
|
773
|
+
* fallback, so the CLI and the running app can never mean different databases.
|
|
774
|
+
*/
|
|
775
|
+
export function prismaConfigTs(options) {
|
|
776
|
+
const prefix = envPrefix(options.name);
|
|
777
|
+
return `import { loadEnvFile } from 'node:process'
|
|
778
|
+
import { defineConfig } from 'prisma/config'
|
|
779
|
+
|
|
780
|
+
// Prisma loads no .env for you (neither does the app — see src/env.ts). This
|
|
781
|
+
// mirrors \`node --env-file=.env\`: a variable already exported in your shell
|
|
782
|
+
// still wins, the file only fills in what is missing.
|
|
783
|
+
try {
|
|
784
|
+
loadEnvFile('.env')
|
|
785
|
+
} catch {
|
|
786
|
+
// No .env file — the variables come from the environment.
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export default defineConfig({
|
|
790
|
+
schema: 'prisma/schema.prisma',${options.tenancy
|
|
791
|
+
? `
|
|
792
|
+
// Runs after \`prisma migrate dev\` / \`prisma migrate reset\`.
|
|
793
|
+
migrations: { seed: 'tsx prisma/seed.ts' },`
|
|
794
|
+
: ''}
|
|
795
|
+
datasource: {
|
|
796
|
+
// ${prefix}_DATABASE_URL first, bare DATABASE_URL only as a fallback: a
|
|
797
|
+
// stray DATABASE_URL from another project must never decide which database
|
|
798
|
+
// gets migrated.
|
|
799
|
+
url: process.env['${prefix}_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? '',
|
|
800
|
+
},
|
|
801
|
+
})
|
|
802
|
+
`;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* `src/db.ts` — the Prisma client(s). Two bindings on purpose: the unscoped
|
|
806
|
+
* `prisma` for the framework stores (they key their own rows and run outside a
|
|
807
|
+
* tenant context) and, with tenancy, the `db` client the whole application uses,
|
|
808
|
+
* where every query is filtered by the request's tenant.
|
|
809
|
+
*/
|
|
810
|
+
export function dbTs(options) {
|
|
811
|
+
return `import { PrismaPg } from '@prisma/adapter-pg'${options.tenancy ? `\nimport { tenancyExtension } from '@basaltkit/prisma'` : ''}
|
|
812
|
+
import { PrismaClient } from './generated/prisma/client.js'
|
|
813
|
+
import { env } from './env.js'
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* The base client. Prisma 7 talks to PostgreSQL through a driver adapter, so the
|
|
817
|
+
* connection string is passed here; prisma.config.ts hands the CLI the same one.
|
|
818
|
+
*
|
|
819
|
+
* ${options.tenancy
|
|
820
|
+
? `This client is NOT tenant-scoped: the framework stores (auth, tenancy,
|
|
821
|
+
* teams, …) read and write their own tables, keyed by their own columns, and do
|
|
822
|
+
* it before a tenant is known (a login has no tenant yet). Application code
|
|
823
|
+
* should use \`db\` below — or \`db<PrismaClient>()\` from @basaltkit/prisma.`
|
|
824
|
+
: `Application code can also reach it with \`db<PrismaClient>()\` from
|
|
825
|
+
* @basaltkit/prisma, which returns the client registered by prismaPlugin.`}
|
|
826
|
+
*/
|
|
827
|
+
export const prisma = new PrismaClient({
|
|
828
|
+
adapter: new PrismaPg({ connectionString: env.DATABASE_URL }),
|
|
829
|
+
})
|
|
830
|
+
${options.tenancy
|
|
831
|
+
? `
|
|
832
|
+
/**
|
|
833
|
+
* The application client: tenancyExtension() forces the current tenant's
|
|
834
|
+
* \`tenantId\` onto every read, stamps it onto every write, and fails closed
|
|
835
|
+
* (MissingTenantError) when there is no tenant in context — application code
|
|
836
|
+
* cannot forget the filter. src/app.ts registers it with prismaPlugin, so
|
|
837
|
+
* \`db<PrismaClient>()\` inside a handler returns this one.
|
|
838
|
+
*/
|
|
839
|
+
export const db = prisma.$extends(tenancyExtension())
|
|
840
|
+
`
|
|
841
|
+
: `
|
|
842
|
+
/** Registered by prismaPlugin in src/app.ts. */
|
|
843
|
+
export const db = prisma
|
|
844
|
+
`}`;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* `prisma/seed.ts` — creates the `demo` tenant the scaffolded resolvers expect
|
|
848
|
+
* (`x-tenant-id: demo`, `demo.localhost`). Runs from `pnpm db:seed` and after
|
|
849
|
+
* `prisma migrate dev` (prisma.config.ts declares it).
|
|
850
|
+
*/
|
|
851
|
+
export function prismaSeedTs(options) {
|
|
852
|
+
return `import { prismaTenantSource } from '@basaltkit/tenancy-prisma'
|
|
853
|
+
import { prisma } from '../src/db.js'
|
|
854
|
+
|
|
855
|
+
// The demo tenant the header and subdomain resolvers look for out of the box.
|
|
856
|
+
// \`save\` is an upsert, so re-seeding is safe. Tenants are open records: add
|
|
857
|
+
// whatever per-tenant fields you like — they round-trip unchanged.
|
|
858
|
+
const tenants = prismaTenantSource(prisma)
|
|
859
|
+
await tenants.save({ id: 'demo', name: 'Demo Tenant' })
|
|
860
|
+
console.log('Seeded tenant "demo" for ${options.name}.')
|
|
861
|
+
|
|
862
|
+
await prisma.$disconnect()
|
|
863
|
+
`;
|
|
864
|
+
}
|
|
401
865
|
export function appTest(options) {
|
|
866
|
+
const prefix = envPrefix(options.name);
|
|
867
|
+
// With a database the app cannot boot without one: src/env.ts requires
|
|
868
|
+
// DATABASE_URL and prismaPlugin({ assertMigrated: true }) checks the schema.
|
|
869
|
+
// So the suite is gated on a configured database and imports src/app.ts
|
|
870
|
+
// lazily — an eager import would throw at module load instead of skipping.
|
|
402
871
|
return `import { describe, expect, it } from 'vitest'
|
|
403
872
|
import { FASTIFY } from '@basaltkit/fastify'
|
|
404
|
-
|
|
873
|
+
${options.prisma
|
|
874
|
+
? `
|
|
875
|
+
// Needs a migrated database: set ${prefix}_DATABASE_URL (see .env.example), then
|
|
876
|
+
// \`pnpm db:migrate${options.tenancy ? ' && pnpm db:seed' : ''}\`. Without one the suite skips instead of failing.
|
|
877
|
+
const database = process.env['${prefix}_DATABASE_URL'] ?? process.env['DATABASE_URL']
|
|
878
|
+
|
|
879
|
+
describe.skipIf(!database)('app', () => {
|
|
880
|
+
it('boots and responds on /health', async () => {
|
|
881
|
+
const { buildApp } = await import('../src/app.js')
|
|
882
|
+
const app = await buildApp({ logLevel: 'silent' }).boot()`
|
|
883
|
+
: `import { buildApp } from '../src/app.js'
|
|
405
884
|
|
|
406
885
|
describe('app', () => {
|
|
407
886
|
it('boots and responds on /health', async () => {
|
|
408
|
-
const app = await buildApp({ logLevel: 'silent' }).boot()
|
|
887
|
+
const app = await buildApp({ logLevel: 'silent' }).boot()`}
|
|
409
888
|
const server = app.container.get(FASTIFY)
|
|
410
889
|
|
|
411
890
|
const index = await server.inject({ method: 'GET', url: '/' })
|
|
@@ -434,6 +913,7 @@ export function readme(options) {
|
|
|
434
913
|
'typed routes with Zod validation',
|
|
435
914
|
'structured logging with request/tenant context',
|
|
436
915
|
'typed domain events',
|
|
916
|
+
...(options.prisma ? ['PostgreSQL persistence through Prisma (migrations + tenant-scoped client)'] : []),
|
|
437
917
|
...(options.tenancy ? ['multi-tenancy (header + subdomain resolvers)'] : []),
|
|
438
918
|
...(options.auth ? ['authentication (register/login/refresh/me)'] : []),
|
|
439
919
|
...(options.billing ? ['subscriptions with plans and feature limits'] : []),
|
|
@@ -450,22 +930,90 @@ Included: ${features.join(' · ')}.
|
|
|
450
930
|
## Getting started
|
|
451
931
|
|
|
452
932
|
\`\`\`bash
|
|
453
|
-
pnpm install
|
|
933
|
+
pnpm install${options.prisma
|
|
934
|
+
? `
|
|
935
|
+
# point ${envPrefix(options.name)}_DATABASE_URL at a PostgreSQL database (.env.example), then
|
|
936
|
+
pnpm db:migrate # create the tables${options.tenancy ? ` (runs the seed too)` : ''}`
|
|
937
|
+
: ''}
|
|
454
938
|
pnpm dev # API on http://localhost:3000
|
|
455
939
|
pnpm test
|
|
456
940
|
\`\`\`
|
|
941
|
+
${options.prisma
|
|
942
|
+
? `
|
|
943
|
+
## Database
|
|
944
|
+
|
|
945
|
+
PostgreSQL through [Prisma](https://www.prisma.io). \`prisma/schema.prisma\` holds
|
|
946
|
+
the models this app needs — the reference models of the \`@basaltkit/*-prisma\`
|
|
947
|
+
packages it uses, plus your own \`Project\` at the end.
|
|
948
|
+
|
|
949
|
+
\`\`\`bash
|
|
950
|
+
pnpm db:migrate # prisma migrate dev — change the schema, write a migration${options.tenancy
|
|
951
|
+
? `
|
|
952
|
+
pnpm db:seed # the 'demo' tenant the resolvers expect (also run by db:migrate)`
|
|
953
|
+
: ''}
|
|
954
|
+
pnpm db:generate # prisma generate — refresh src/generated/prisma (also runs on install)
|
|
955
|
+
pnpm db:deploy # prisma migrate deploy — apply pending migrations in production
|
|
956
|
+
\`\`\`
|
|
457
957
|
|
|
958
|
+
**Never use \`prisma db push\` on this project.** \`src/app.ts\` boots with
|
|
959
|
+
\`prismaPlugin({ client: db, assertMigrated: true })\`, which refuses to start
|
|
960
|
+
unless the database it reached has the \`_prisma_migrations\` table — the one
|
|
961
|
+
\`migrate dev\` / \`migrate deploy\` write and \`db push\` does not. That check is
|
|
962
|
+
what turns "wrong \`DATABASE_URL\`" (a shell that exported another project's, a
|
|
963
|
+
typo in the database name) into a boot error naming the database and host,
|
|
964
|
+
instead of a 500 on the first request that touches a missing table.
|
|
965
|
+
|
|
966
|
+
\`src/db.ts\` exports two clients:
|
|
967
|
+
|
|
968
|
+
- \`prisma\` — unscoped. The framework stores (auth${options.tenancy ? ', tenancy' : ''}${enforcesMembership(options) ? ', teams' : ''}${options.billing ? ', subscriptions' : ''}) use it: they key
|
|
969
|
+
their own tables and run before a tenant is known.${options.tenancy
|
|
970
|
+
? `
|
|
971
|
+
- \`db\` — \`prisma.$extends(tenancyExtension())\`. Registered with \`prismaPlugin\`,
|
|
972
|
+
so \`db<PrismaClient>()\` inside a handler returns a client that filters every
|
|
973
|
+
read by the request's tenant, stamps every write, and throws instead of running
|
|
974
|
+
unscoped. Keep \`tenantId\` on every model a tenant owns.`
|
|
975
|
+
: ''}
|
|
976
|
+
${options.cli
|
|
977
|
+
? `
|
|
978
|
+
Adding another Basalt domain later (audit, comments, notifications, …): install
|
|
979
|
+
its \`@basaltkit/<domain>-prisma\` package, run \`pnpm basalt prisma:sync\` to merge
|
|
980
|
+
its models into the schema, then \`pnpm db:migrate\`.
|
|
981
|
+
`
|
|
982
|
+
: `
|
|
983
|
+
Adding another Basalt domain later (audit, comments, notifications, …): install
|
|
984
|
+
its \`@basaltkit/<domain>-prisma\` package, copy the models from its
|
|
985
|
+
\`schema.prisma\` into yours, then \`pnpm db:migrate\`. (With the \`basalt\` CLI —
|
|
986
|
+
scaffold with \`--cli\` — \`pnpm basalt prisma:sync\` does the merge for you.)
|
|
987
|
+
`}
|
|
988
|
+
\`pnpm test\` skips the generated suite when no database is configured.
|
|
989
|
+
`
|
|
990
|
+
: ''}
|
|
458
991
|
## Environment
|
|
459
992
|
|
|
460
993
|
\`src/env.ts\` validates \`process.env\` — nothing loads \`.env\` for you. Copy
|
|
461
994
|
\`.env.example\` to \`.env\` and launch with \`--env-file=.env\` (Node/tsx), or
|
|
462
995
|
export the variables.
|
|
463
996
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
997
|
+
The variables are **app-prefixed**: \`${envPrefix(options.name)}_PORT\`,
|
|
998
|
+
\`${envPrefix(options.name)}_HOST\`, \`${envPrefix(options.name)}_LOG_LEVEL\`${options.auth ? `, \`${envPrefix(options.name)}_APP_SECRET\`` : ''}${options.prisma ? `, \`${envPrefix(options.name)}_DATABASE_URL\`` : ''}. \`NODE_ENV\` is never prefixed.
|
|
999
|
+
|
|
1000
|
+
Why: **\`--env-file\` never overrides a variable that is already exported.** In a
|
|
1001
|
+
shell where another project exported \`DATABASE_URL\` (or \`PORT\`), an app reading
|
|
1002
|
+
the generic name boots against THAT value and only fails on the first request
|
|
1003
|
+
that touches it. \`src/env.ts\` therefore passes \`prefix: '${envPrefix(options.name)}'\` to
|
|
1004
|
+
\`defineEnv\`: each variable is read as \`${envPrefix(options.name)}_<NAME>\` first and
|
|
1005
|
+
**falls back** to the bare \`<NAME>\`, so a deployment that already exports the
|
|
1006
|
+
generic names keeps booting while a stray one in your shell loses.${options.prisma
|
|
1007
|
+
? ` That is
|
|
1008
|
+
exactly how \`DATABASE_URL\` is read here (\`${envPrefix(options.name)}_DATABASE_URL\` first), and
|
|
1009
|
+
\`prisma.config.ts\` applies the same precedence — so \`pnpm db:migrate\` and the
|
|
1010
|
+
running app can never mean two different databases.`
|
|
1011
|
+
: ` Add a
|
|
1012
|
+
database and \`DATABASE_URL\` is read as \`${envPrefix(options.name)}_DATABASE_URL\`.`}
|
|
1013
|
+
|
|
1014
|
+
To drop the fallback entirely (prefixed names only), write
|
|
1015
|
+
\`prefix: { value: '${envPrefix(options.name)}', fallback: false }\`. When in doubt about what
|
|
1016
|
+
your shell exports: \`env | grep DATABASE_URL\`.
|
|
469
1017
|
${options.cli
|
|
470
1018
|
? `
|
|
471
1019
|
## The \`basalt\` CLI
|
|
@@ -572,14 +1120,18 @@ export function dockerignore() {
|
|
|
572
1120
|
**/.DS_Store
|
|
573
1121
|
`;
|
|
574
1122
|
}
|
|
575
|
-
export function gitignore() {
|
|
1123
|
+
export function gitignore(options) {
|
|
576
1124
|
return `node_modules/
|
|
577
1125
|
dist/
|
|
578
1126
|
.env
|
|
579
1127
|
.env.*
|
|
580
1128
|
!.env.example
|
|
581
1129
|
*.log
|
|
582
|
-
|
|
1130
|
+
${options.prisma
|
|
1131
|
+
? `# Generated by \`prisma generate\` (runs on install) — never edit or commit it.
|
|
1132
|
+
src/generated/
|
|
1133
|
+
`
|
|
1134
|
+
: ''}`;
|
|
583
1135
|
}
|
|
584
1136
|
/**
|
|
585
1137
|
* pnpm settings: esbuild's build script is required by tsx;
|