@beignet/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +409 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +131 -0
- package/dist/config.js.map +1 -0
- package/dist/create-bin.d.ts +3 -0
- package/dist/create-bin.d.ts.map +1 -0
- package/dist/create-bin.js +9 -0
- package/dist/create-bin.js.map +1 -0
- package/dist/create.d.ts +20 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +99 -0
- package/dist/create.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +735 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +54 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +1240 -0
- package/dist/inspect.js.map +1 -0
- package/dist/lint.d.ts +21 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +576 -0
- package/dist/lint.js.map +1 -0
- package/dist/make.d.ts +115 -0
- package/dist/make.d.ts.map +1 -0
- package/dist/make.js +2719 -0
- package/dist/make.js.map +1 -0
- package/dist/templates.d.ts +22 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +2236 -0
- package/dist/templates.js.map +1 -0
- package/package.json +73 -0
- package/src/config.ts +214 -0
- package/src/create-bin.ts +11 -0
- package/src/create.ts +164 -0
- package/src/index.ts +992 -0
- package/src/inspect.ts +1951 -0
- package/src/lint.ts +785 -0
- package/src/make.ts +3931 -0
- package/src/templates.ts +2460 -0
package/src/templates.ts
ADDED
|
@@ -0,0 +1,2460 @@
|
|
|
1
|
+
export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
|
|
2
|
+
export type PresetName = "minimal" | "standard";
|
|
3
|
+
export type TemplateName = "next";
|
|
4
|
+
export type FeatureName = "client" | "react-query" | "forms" | "openapi";
|
|
5
|
+
export type IntegrationName =
|
|
6
|
+
| "better-auth"
|
|
7
|
+
| "drizzle-turso"
|
|
8
|
+
| "inngest"
|
|
9
|
+
| "pino"
|
|
10
|
+
| "resend"
|
|
11
|
+
| "upstash-rate-limit";
|
|
12
|
+
|
|
13
|
+
export type TemplateFile = {
|
|
14
|
+
path: string;
|
|
15
|
+
content: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TemplateContext = {
|
|
19
|
+
name: string;
|
|
20
|
+
packageManager: PackageManager;
|
|
21
|
+
beignetVersion: string;
|
|
22
|
+
preset: PresetName;
|
|
23
|
+
features: FeatureName[];
|
|
24
|
+
integrations: IntegrationName[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const externalVersions = {
|
|
28
|
+
next: "^16.2.4",
|
|
29
|
+
react: "^19.2.5",
|
|
30
|
+
reactDom: "^19.2.5",
|
|
31
|
+
typescript: "^5.3.0",
|
|
32
|
+
typesBun: "^1.3.13",
|
|
33
|
+
typesNode: "^20.10.0",
|
|
34
|
+
typesReact: "^19.0.0",
|
|
35
|
+
typesReactDom: "^19.0.0",
|
|
36
|
+
zod: "^4.0.0",
|
|
37
|
+
tanstackReactQuery: "^5.100.7",
|
|
38
|
+
hookformResolvers: "^5.0.0",
|
|
39
|
+
reactHookForm: "^7.74.0",
|
|
40
|
+
betterAuth: "^1.3.26",
|
|
41
|
+
drizzleKit: "^0.30.0",
|
|
42
|
+
drizzleOrm: "^0.38.0",
|
|
43
|
+
inngest: "^3.0.0",
|
|
44
|
+
libsqlClient: "^0.14.0",
|
|
45
|
+
pino: "^9.7.0",
|
|
46
|
+
resend: "^4.0.1",
|
|
47
|
+
upstashRateLimit: "^2.0.0",
|
|
48
|
+
upstashRedis: "^1.0.0",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function isStandardPreset(ctx: TemplateContext): boolean {
|
|
52
|
+
return ctx.preset === "standard";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasDrizzleTurso(ctx: TemplateContext): boolean {
|
|
56
|
+
return hasIntegration(ctx, "drizzle-turso");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function packageRunner(ctx: TemplateContext): string {
|
|
60
|
+
switch (ctx.packageManager) {
|
|
61
|
+
case "npm":
|
|
62
|
+
return "npx -p @beignet/cli beignet";
|
|
63
|
+
case "pnpm":
|
|
64
|
+
return "pnpm dlx @beignet/cli beignet";
|
|
65
|
+
case "yarn":
|
|
66
|
+
return "yarn dlx @beignet/cli beignet";
|
|
67
|
+
default:
|
|
68
|
+
return "bunx -p @beignet/cli beignet";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const featureChoices = [
|
|
73
|
+
"client",
|
|
74
|
+
"react-query",
|
|
75
|
+
"forms",
|
|
76
|
+
"openapi",
|
|
77
|
+
] as const satisfies readonly FeatureName[];
|
|
78
|
+
|
|
79
|
+
export const presetChoices = [
|
|
80
|
+
"minimal",
|
|
81
|
+
"standard",
|
|
82
|
+
] as const satisfies readonly PresetName[];
|
|
83
|
+
|
|
84
|
+
export const integrationChoices = [
|
|
85
|
+
"better-auth",
|
|
86
|
+
"drizzle-turso",
|
|
87
|
+
"inngest",
|
|
88
|
+
"pino",
|
|
89
|
+
"resend",
|
|
90
|
+
"upstash-rate-limit",
|
|
91
|
+
] as const satisfies readonly IntegrationName[];
|
|
92
|
+
|
|
93
|
+
const integrationDeps: Record<IntegrationName, Record<string, string>> = {
|
|
94
|
+
"better-auth": {
|
|
95
|
+
"@beignet/provider-auth-better-auth": "",
|
|
96
|
+
"better-auth": externalVersions.betterAuth,
|
|
97
|
+
},
|
|
98
|
+
"drizzle-turso": {
|
|
99
|
+
"@beignet/provider-drizzle-turso": "",
|
|
100
|
+
"@libsql/client": externalVersions.libsqlClient,
|
|
101
|
+
"drizzle-orm": externalVersions.drizzleOrm,
|
|
102
|
+
},
|
|
103
|
+
inngest: {
|
|
104
|
+
"@beignet/provider-inngest": "",
|
|
105
|
+
inngest: externalVersions.inngest,
|
|
106
|
+
},
|
|
107
|
+
pino: {
|
|
108
|
+
"@beignet/provider-logger-pino": "",
|
|
109
|
+
pino: externalVersions.pino,
|
|
110
|
+
},
|
|
111
|
+
resend: {
|
|
112
|
+
"@beignet/provider-mail-resend": "",
|
|
113
|
+
resend: externalVersions.resend,
|
|
114
|
+
},
|
|
115
|
+
"upstash-rate-limit": {
|
|
116
|
+
"@beignet/provider-rate-limit-upstash": "",
|
|
117
|
+
"@upstash/ratelimit": externalVersions.upstashRateLimit,
|
|
118
|
+
"@upstash/redis": externalVersions.upstashRedis,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const integrationDevDeps: Partial<
|
|
123
|
+
Record<IntegrationName, Record<string, string>>
|
|
124
|
+
> = {
|
|
125
|
+
"drizzle-turso": {
|
|
126
|
+
"drizzle-kit": externalVersions.drizzleKit,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const featureDeps: Record<FeatureName, Record<string, string>> = {
|
|
131
|
+
client: {},
|
|
132
|
+
"react-query": {
|
|
133
|
+
"@beignet/react-query": "",
|
|
134
|
+
"@tanstack/react-query": externalVersions.tanstackReactQuery,
|
|
135
|
+
},
|
|
136
|
+
forms: {
|
|
137
|
+
"@beignet/react-hook-form": "",
|
|
138
|
+
"@hookform/resolvers": externalVersions.hookformResolvers,
|
|
139
|
+
"react-hook-form": externalVersions.reactHookForm,
|
|
140
|
+
},
|
|
141
|
+
openapi: {
|
|
142
|
+
"@beignet/core": "",
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function hasFeature(ctx: TemplateContext, feature: FeatureName): boolean {
|
|
147
|
+
return ctx.features.includes(feature);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasIntegration(
|
|
151
|
+
ctx: TemplateContext,
|
|
152
|
+
integration: IntegrationName,
|
|
153
|
+
): boolean {
|
|
154
|
+
return ctx.integrations.includes(integration);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function json(value: unknown): string {
|
|
158
|
+
return `${JSON.stringify(value, null, "\t")}\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function gitignore(ctx: TemplateContext): string {
|
|
162
|
+
const lines = [files.gitignore.trimEnd()];
|
|
163
|
+
|
|
164
|
+
if (isStandardPreset(ctx)) {
|
|
165
|
+
lines.push("/storage/");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (hasDrizzleTurso(ctx)) {
|
|
169
|
+
lines.push("local.db", "local.db-*");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return `${lines.join("\n")}\n`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function packageJson(ctx: TemplateContext): string {
|
|
176
|
+
const dependencies: Record<string, string> = {
|
|
177
|
+
"@beignet/core": ctx.beignetVersion,
|
|
178
|
+
"@beignet/next": ctx.beignetVersion,
|
|
179
|
+
next: externalVersions.next,
|
|
180
|
+
react: externalVersions.react,
|
|
181
|
+
"react-dom": externalVersions.reactDom,
|
|
182
|
+
zod: externalVersions.zod,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (isStandardPreset(ctx)) {
|
|
186
|
+
dependencies["@beignet/devtools"] = ctx.beignetVersion;
|
|
187
|
+
dependencies["@beignet/provider-storage-local"] = ctx.beignetVersion;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const integration of ctx.integrations) {
|
|
191
|
+
for (const [name, version] of Object.entries(
|
|
192
|
+
integrationDeps[integration],
|
|
193
|
+
)) {
|
|
194
|
+
dependencies[name] = version || ctx.beignetVersion;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const feature of ctx.features) {
|
|
199
|
+
for (const [name, version] of Object.entries(featureDeps[feature])) {
|
|
200
|
+
dependencies[name] = version || ctx.beignetVersion;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const devDependencies: Record<string, string> = {
|
|
205
|
+
"@types/bun": externalVersions.typesBun,
|
|
206
|
+
"@types/node": externalVersions.typesNode,
|
|
207
|
+
"@types/react": externalVersions.typesReact,
|
|
208
|
+
"@types/react-dom": externalVersions.typesReactDom,
|
|
209
|
+
typescript: externalVersions.typescript,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
for (const integration of ctx.integrations) {
|
|
213
|
+
for (const [name, version] of Object.entries(
|
|
214
|
+
integrationDevDeps[integration] ?? {},
|
|
215
|
+
)) {
|
|
216
|
+
devDependencies[name] = version;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const scripts: Record<string, string> = {
|
|
221
|
+
dev: "next dev",
|
|
222
|
+
build: "next build",
|
|
223
|
+
start: "next start",
|
|
224
|
+
test: "bun test",
|
|
225
|
+
typecheck: "tsc --noEmit",
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if (hasDrizzleTurso(ctx)) {
|
|
229
|
+
scripts["db:generate"] = "drizzle-kit generate";
|
|
230
|
+
scripts["db:migrate"] = "drizzle-kit migrate";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return json({
|
|
234
|
+
name: ctx.name,
|
|
235
|
+
version: "0.1.0",
|
|
236
|
+
private: true,
|
|
237
|
+
type: "module",
|
|
238
|
+
scripts,
|
|
239
|
+
dependencies,
|
|
240
|
+
devDependencies,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readme(ctx: TemplateContext): string {
|
|
245
|
+
if (isStandardPreset(ctx)) {
|
|
246
|
+
return standardReadme(ctx);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const install = `${ctx.packageManager} install`;
|
|
250
|
+
const run =
|
|
251
|
+
ctx.packageManager === "npm"
|
|
252
|
+
? "npm run dev"
|
|
253
|
+
: `${ctx.packageManager} run dev`;
|
|
254
|
+
const featureList =
|
|
255
|
+
ctx.features.length > 0
|
|
256
|
+
? ctx.features.map((feature) => `- \`${feature}\``).join("\n")
|
|
257
|
+
: "- API routes only";
|
|
258
|
+
const integrationList =
|
|
259
|
+
ctx.integrations.length > 0
|
|
260
|
+
? ctx.integrations.map((integration) => `- \`${integration}\``).join("\n")
|
|
261
|
+
: "- None";
|
|
262
|
+
|
|
263
|
+
return `# ${ctx.name}
|
|
264
|
+
|
|
265
|
+
Beignet app scaffolded with \`@beignet/cli\`.
|
|
266
|
+
|
|
267
|
+
## Getting started
|
|
268
|
+
|
|
269
|
+
\`\`\`bash
|
|
270
|
+
${install}
|
|
271
|
+
${run}
|
|
272
|
+
\`\`\`
|
|
273
|
+
|
|
274
|
+
Open http://localhost:3000 and start from the todos workflow.
|
|
275
|
+
|
|
276
|
+
## Files to know
|
|
277
|
+
|
|
278
|
+
- \`features/todos/contracts.ts\` defines the API contract.
|
|
279
|
+
- \`features/todos/use-cases.ts\` contains application logic with input and output validation.
|
|
280
|
+
- \`ports/index.ts\` defines the app's external dependencies.
|
|
281
|
+
- \`server/index.ts\` wires contracts to handlers.
|
|
282
|
+
- \`app/api/[[...path]]/route.ts\` exposes contract-backed API routes to Next.js.
|
|
283
|
+
${hasFeature(ctx, "react-query") ? "- `features/todos/components/todo-app.tsx` contains the starter UI.\n" : ""}
|
|
284
|
+
|
|
285
|
+
## Selected preset
|
|
286
|
+
|
|
287
|
+
\`${ctx.preset}\`
|
|
288
|
+
|
|
289
|
+
## Selected features
|
|
290
|
+
|
|
291
|
+
${featureList}
|
|
292
|
+
|
|
293
|
+
## Selected integrations
|
|
294
|
+
|
|
295
|
+
${integrationList}
|
|
296
|
+
${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function standardReadme(ctx: TemplateContext): string {
|
|
301
|
+
const install = `${ctx.packageManager} install`;
|
|
302
|
+
const run =
|
|
303
|
+
ctx.packageManager === "npm"
|
|
304
|
+
? "npm run dev"
|
|
305
|
+
: `${ctx.packageManager} run dev`;
|
|
306
|
+
const typecheck =
|
|
307
|
+
ctx.packageManager === "npm"
|
|
308
|
+
? "npm run typecheck"
|
|
309
|
+
: `${ctx.packageManager} run typecheck`;
|
|
310
|
+
const cli = packageRunner(ctx);
|
|
311
|
+
const beforeDeploy = hasDrizzleTurso(ctx)
|
|
312
|
+
? [
|
|
313
|
+
"- Create a Turso database or keep `TURSO_DB_URL=file:local.db` for local libSQL development.",
|
|
314
|
+
`- Run \`${ctx.packageManager} run db:generate\` and \`${ctx.packageManager} run db:migrate\` after changing the Drizzle schema.`,
|
|
315
|
+
].join("\n")
|
|
316
|
+
: "- Replace the in-memory todo repository with a durable adapter.";
|
|
317
|
+
|
|
318
|
+
return `# ${ctx.name}
|
|
319
|
+
|
|
320
|
+
Beignet app scaffolded with \`@beignet/cli\`.
|
|
321
|
+
|
|
322
|
+
## Getting started
|
|
323
|
+
|
|
324
|
+
\`\`\`bash
|
|
325
|
+
${install}
|
|
326
|
+
cp .env.example .env.local
|
|
327
|
+
${run}
|
|
328
|
+
\`\`\`
|
|
329
|
+
|
|
330
|
+
Open http://localhost:3000 for the todo workflow, http://localhost:3000/api/health for health checks, and http://localhost:3000/api/devtools while developing.
|
|
331
|
+
|
|
332
|
+
## First checks
|
|
333
|
+
|
|
334
|
+
\`\`\`bash
|
|
335
|
+
# in another terminal
|
|
336
|
+
${cli} routes
|
|
337
|
+
${cli} lint
|
|
338
|
+
${cli} doctor
|
|
339
|
+
${typecheck}
|
|
340
|
+
\`\`\`
|
|
341
|
+
|
|
342
|
+
\`routes\` shows the contracts Beignet can inspect. \`lint\` checks dependency direction. \`doctor\` catches route, OpenAPI, and resource drift.
|
|
343
|
+
|
|
344
|
+
## App map
|
|
345
|
+
|
|
346
|
+
- \`features/todos/contracts.ts\` owns the HTTP contract and reuses use case schemas.
|
|
347
|
+
- \`features/todos/use-cases/\` owns application behavior and validation.
|
|
348
|
+
- \`ports/\` defines app-owned dependencies.
|
|
349
|
+
- \`infra/\` implements ports for the selected runtime.
|
|
350
|
+
- \`server/routes.ts\` keeps the central route registry and OpenAPI contract list.
|
|
351
|
+
- \`server/\` wires context, providers, routes, hooks, and error handling.
|
|
352
|
+
- \`features/shared/errors.ts\` keeps application errors and route-owned error schemas together.
|
|
353
|
+
- \`app/storage/[...key]/route.ts\` serves public local storage objects.
|
|
354
|
+
- \`lib/env.ts\` validates deployment configuration at startup.
|
|
355
|
+
- \`lib/auth.ts\` exposes \`requireUser(ctx)\` for protected use cases.
|
|
356
|
+
- \`features/todos/policy.ts\` defines authorization rules registered with \`createGate(...)\`.
|
|
357
|
+
${hasDrizzleTurso(ctx) ? "- `infra/db/schema/` contains the Drizzle schema, `infra/db/repositories.ts` creates app repositories, and `infra/todos/` contains the durable todo repository adapter.\n" : ""}
|
|
358
|
+
|
|
359
|
+
## Before deploying
|
|
360
|
+
|
|
361
|
+
${beforeDeploy}
|
|
362
|
+
- Keep \`/api/devtools\` development-only unless you add authentication and stricter redaction.
|
|
363
|
+
- Set \`APP_URL\`, \`LOG_LEVEL\`, and service-specific integration variables in your hosting environment.
|
|
364
|
+
- Replace anonymous auth and add use-case authorization before exposing user-owned data.
|
|
365
|
+
${ctx.integrations.length > 0 ? "\nSee `docs/integrations.md` for setup notes.\n" : ""}
|
|
366
|
+
`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function integrationsDoc(ctx: TemplateContext): string {
|
|
370
|
+
const lines = [
|
|
371
|
+
"# Integrations",
|
|
372
|
+
"",
|
|
373
|
+
"The starter app added dependencies for the integrations you selected. Runtime providers are wired in `server/providers.ts` when Beignet can do so safely; integrations that need app-owned setup list their follow-up steps below.",
|
|
374
|
+
"",
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
for (const integration of ctx.integrations) {
|
|
378
|
+
if (integration === "better-auth") {
|
|
379
|
+
lines.push(
|
|
380
|
+
"## Better Auth",
|
|
381
|
+
"",
|
|
382
|
+
"- Package: `@beignet/provider-auth-better-auth`",
|
|
383
|
+
"- Peer dependency: `better-auth`",
|
|
384
|
+
"- The starter uses an anonymous auth adapter. Replace `createAnonymousAuth()` in `infra/app-ports.ts` with an adapter backed by your Better Auth session lookup.",
|
|
385
|
+
"- Keep auth behind the shared `AuthPort`, then call `requireUser(ctx)` in use cases that need a signed-in user.",
|
|
386
|
+
"- Put repeated ownership or role rules in policies registered with `createGate(...)`.",
|
|
387
|
+
"- Add the Better Auth provider only after your app has a database adapter and session setup.",
|
|
388
|
+
"",
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (integration === "drizzle-turso") {
|
|
393
|
+
lines.push(
|
|
394
|
+
"## Drizzle + Turso",
|
|
395
|
+
"",
|
|
396
|
+
"- Package: `@beignet/provider-drizzle-turso`",
|
|
397
|
+
"- Peer dependencies: `@libsql/client` and `drizzle-orm`",
|
|
398
|
+
"- Dev dependency: `drizzle-kit`",
|
|
399
|
+
"- Set `TURSO_DB_URL` and optional `TURSO_DB_AUTH_TOKEN`.",
|
|
400
|
+
"- The scaffold wires a typed Drizzle repository behind the `TodoRepository` port and a transaction-backed Unit of Work.",
|
|
401
|
+
`- Run \`${ctx.packageManager} run db:generate\` and \`${ctx.packageManager} run db:migrate\` after changing \`infra/db/schema.ts\`.`,
|
|
402
|
+
"",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (integration === "inngest") {
|
|
407
|
+
lines.push(
|
|
408
|
+
"## Inngest",
|
|
409
|
+
"",
|
|
410
|
+
"- Package: `@beignet/core` (`@beignet/core/events`, `@beignet/core/jobs`)",
|
|
411
|
+
"- Package: `@beignet/provider-inngest`",
|
|
412
|
+
"- Peer dependency: `inngest`",
|
|
413
|
+
"- The standard preset wires `inngestProvider` in `server/providers.ts` and adds `jobs: JobDispatcherPort` to `AppPorts`.",
|
|
414
|
+
"- Define jobs with `createJobHandlers(...).defineJob(...)` and dispatch them through `ctx.ports.jobs.dispatch(...)`.",
|
|
415
|
+
"- Keep direct Inngest client usage inside infrastructure code unless you intentionally add an app-owned escape-hatch port.",
|
|
416
|
+
"",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (integration === "pino") {
|
|
421
|
+
lines.push(
|
|
422
|
+
"## Pino logger",
|
|
423
|
+
"",
|
|
424
|
+
"- Package: `@beignet/provider-logger-pino`",
|
|
425
|
+
"- Peer dependency: `pino`",
|
|
426
|
+
"- Use this provider when you want structured request and handler logging.",
|
|
427
|
+
"",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (integration === "resend") {
|
|
432
|
+
lines.push(
|
|
433
|
+
"## Resend mail",
|
|
434
|
+
"",
|
|
435
|
+
"- Package: `@beignet/provider-mail-resend`",
|
|
436
|
+
"- Peer dependency: `resend`",
|
|
437
|
+
"- The standard preset wires `mailResendProvider` in `server/providers.ts` and adds `mailer: MailerPort` to `AppPorts`.",
|
|
438
|
+
"- Set `RESEND_API_KEY` and `RESEND_FROM` before starting the app.",
|
|
439
|
+
"",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (integration === "upstash-rate-limit") {
|
|
444
|
+
lines.push(
|
|
445
|
+
"## Upstash rate limit",
|
|
446
|
+
"",
|
|
447
|
+
"- Package: `@beignet/provider-rate-limit-upstash`",
|
|
448
|
+
"- Peer dependencies: `@upstash/ratelimit` and `@upstash/redis`",
|
|
449
|
+
"- The standard preset wires `upstashRateLimitProvider` in `server/providers.ts` and adds `rateLimit: RateLimitPort` to `AppPorts`.",
|
|
450
|
+
"- Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` before starting the app.",
|
|
451
|
+
"",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return `${lines.join("\n")}\n`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function envExample(ctx: TemplateContext): string {
|
|
460
|
+
const lines = [
|
|
461
|
+
"# Copy to .env.local and fill in values for your app.",
|
|
462
|
+
"NODE_ENV=development",
|
|
463
|
+
"APP_URL=http://localhost:3000",
|
|
464
|
+
"CRON_SECRET=",
|
|
465
|
+
"DEVTOOLS_ENABLED=true",
|
|
466
|
+
"",
|
|
467
|
+
];
|
|
468
|
+
|
|
469
|
+
if (isStandardPreset(ctx)) {
|
|
470
|
+
lines.push(
|
|
471
|
+
"STORAGE_ROOT=storage/app",
|
|
472
|
+
"# STORAGE_PUBLIC_BASE_URL=/storage",
|
|
473
|
+
"",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (hasIntegration(ctx, "pino")) {
|
|
478
|
+
lines.push(
|
|
479
|
+
"LOG_LEVEL=info",
|
|
480
|
+
"LOG_FORMAT=json",
|
|
481
|
+
`LOG_SERVICE=${ctx.name}`,
|
|
482
|
+
"",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (hasIntegration(ctx, "inngest")) {
|
|
487
|
+
lines.push("INNGEST_APP_NAME=beignet-app", "# INNGEST_EVENT_KEY=", "");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (hasIntegration(ctx, "drizzle-turso")) {
|
|
491
|
+
lines.push(
|
|
492
|
+
"# Use file:local.db for local libSQL or libsql://... for Turso cloud.",
|
|
493
|
+
"TURSO_DB_URL=file:local.db",
|
|
494
|
+
"# TURSO_DB_AUTH_TOKEN=",
|
|
495
|
+
"",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (hasIntegration(ctx, "resend")) {
|
|
500
|
+
lines.push("RESEND_API_KEY=", "RESEND_FROM=", "");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
504
|
+
lines.push(
|
|
505
|
+
"UPSTASH_REDIS_REST_URL=",
|
|
506
|
+
"UPSTASH_REDIS_REST_TOKEN=",
|
|
507
|
+
"UPSTASH_PREFIX=ck:ratelimit",
|
|
508
|
+
"",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (hasIntegration(ctx, "better-auth")) {
|
|
513
|
+
lines.push(
|
|
514
|
+
"# Better Auth needs an app-owned database adapter and a shared AuthPort adapter before it can be wired.",
|
|
515
|
+
"BETTER_AUTH_SECRET=",
|
|
516
|
+
"BETTER_AUTH_URL=http://localhost:3000",
|
|
517
|
+
"",
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return `${lines.join("\n")}\n`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function layout(ctx: TemplateContext): string {
|
|
525
|
+
if (!hasFeature(ctx, "react-query")) {
|
|
526
|
+
return `import "./globals.css";
|
|
527
|
+
import type { Metadata } from "next";
|
|
528
|
+
import type { ReactNode } from "react";
|
|
529
|
+
|
|
530
|
+
export const metadata: Metadata = {
|
|
531
|
+
title: "Beignet app",
|
|
532
|
+
description: "A contract-first Next.js app.",
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
536
|
+
return (
|
|
537
|
+
<html lang="en">
|
|
538
|
+
<body>{children}</body>
|
|
539
|
+
</html>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return `import "./globals.css";
|
|
546
|
+
import type { Metadata } from "next";
|
|
547
|
+
import type { ReactNode } from "react";
|
|
548
|
+
import { Providers } from "./providers";
|
|
549
|
+
|
|
550
|
+
export const metadata: Metadata = {
|
|
551
|
+
title: "Beignet app",
|
|
552
|
+
description: "A contract-first Next.js app.",
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
556
|
+
return (
|
|
557
|
+
<html lang="en">
|
|
558
|
+
<body>
|
|
559
|
+
<Providers>{children}</Providers>
|
|
560
|
+
</body>
|
|
561
|
+
</html>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function page(ctx: TemplateContext): string {
|
|
568
|
+
if (!hasFeature(ctx, "react-query")) {
|
|
569
|
+
return `export default function HomePage() {
|
|
570
|
+
return (
|
|
571
|
+
<main className="page">
|
|
572
|
+
<div className="shell">
|
|
573
|
+
<h1>Beignet app</h1>
|
|
574
|
+
<p className="muted">
|
|
575
|
+
This app starts with one contract-backed todos API route.
|
|
576
|
+
</p>
|
|
577
|
+
|
|
578
|
+
<section className="panel">
|
|
579
|
+
<h2>Try the API</h2>
|
|
580
|
+
<pre className="code">GET /api/todos</pre>
|
|
581
|
+
<p>
|
|
582
|
+
<a href="/api/todos">Open todos API</a>
|
|
583
|
+
</p>
|
|
584
|
+
</section>
|
|
585
|
+
</div>
|
|
586
|
+
</main>
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return `import { TodoApp } from "@/features/todos/components/todo-app";
|
|
593
|
+
|
|
594
|
+
export default function HomePage() {
|
|
595
|
+
return (
|
|
596
|
+
<main className="page">
|
|
597
|
+
<div className="shell">
|
|
598
|
+
<header className="hero">
|
|
599
|
+
<p className="eyebrow">Beignet starter</p>
|
|
600
|
+
<h1>Type-safe full-stack workflows without codegen.</h1>
|
|
601
|
+
<p className="muted">
|
|
602
|
+
Contracts, use cases, ports, server routes, a typed client, React Query,
|
|
603
|
+
and form validation are wired together so you can start changing product
|
|
604
|
+
code immediately.
|
|
605
|
+
</p>
|
|
606
|
+
</header>
|
|
607
|
+
|
|
608
|
+
<TodoApp />
|
|
609
|
+
</div>
|
|
610
|
+
</main>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function serverProviders(ctx: TemplateContext): string {
|
|
617
|
+
const imports: string[] = [];
|
|
618
|
+
const entries: string[] = [];
|
|
619
|
+
|
|
620
|
+
if (hasIntegration(ctx, "pino")) {
|
|
621
|
+
imports.push(
|
|
622
|
+
'import { loggerPinoProvider } from "@beignet/provider-logger-pino";',
|
|
623
|
+
);
|
|
624
|
+
entries.push("loggerPinoProvider");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (hasIntegration(ctx, "inngest")) {
|
|
628
|
+
imports.push(
|
|
629
|
+
'import { inngestProvider } from "@beignet/provider-inngest";',
|
|
630
|
+
);
|
|
631
|
+
entries.push("inngestProvider");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (hasIntegration(ctx, "resend")) {
|
|
635
|
+
imports.push(
|
|
636
|
+
'import { mailResendProvider } from "@beignet/provider-mail-resend";',
|
|
637
|
+
);
|
|
638
|
+
entries.push("mailResendProvider");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
642
|
+
imports.push(
|
|
643
|
+
'import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";',
|
|
644
|
+
);
|
|
645
|
+
entries.push("upstashRateLimitProvider");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return `${imports.join("\n")}
|
|
649
|
+
export const providers = [
|
|
650
|
+
${entries.join(",\n\t")},
|
|
651
|
+
];
|
|
652
|
+
`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function server(ctx: TemplateContext): string {
|
|
656
|
+
const hasProviders = ctx.integrations.some((integration) =>
|
|
657
|
+
["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
return `import { createNextServer } from "@beignet/next";
|
|
661
|
+
${hasProviders ? 'import { providers } from "./providers";\n' : ""}import { routes } from "./routes";
|
|
662
|
+
import { appPorts, type AppPorts } from "@/ports";
|
|
663
|
+
|
|
664
|
+
export type AppContext = {
|
|
665
|
+
requestId: string;
|
|
666
|
+
ports: AppPorts;
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
export const server = await createNextServer<AppContext, AppPorts>({
|
|
670
|
+
ports: appPorts,
|
|
671
|
+
${hasProviders ? "\tproviders,\n" : ""} createContext: async ({ ports }) => ({
|
|
672
|
+
requestId: crypto.randomUUID(),
|
|
673
|
+
ports,
|
|
674
|
+
}),
|
|
675
|
+
routes,
|
|
676
|
+
mapUnhandledError: ({ err, ctx }) => {
|
|
677
|
+
const ports = ctx?.ports as
|
|
678
|
+
| {
|
|
679
|
+
logger?: {
|
|
680
|
+
error: (
|
|
681
|
+
message: string,
|
|
682
|
+
meta?: Record<string, unknown>,
|
|
683
|
+
) => void;
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
| undefined;
|
|
687
|
+
|
|
688
|
+
if (ports?.logger) {
|
|
689
|
+
const logger = ports.logger as {
|
|
690
|
+
error: (message: string, meta?: Record<string, unknown>) => void;
|
|
691
|
+
};
|
|
692
|
+
logger.error("Unhandled API error", {
|
|
693
|
+
error: err,
|
|
694
|
+
requestId: ctx?.requestId,
|
|
695
|
+
});
|
|
696
|
+
} else {
|
|
697
|
+
console.error("Unhandled API error", {
|
|
698
|
+
error: err,
|
|
699
|
+
requestId: ctx?.requestId,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
status: 500,
|
|
705
|
+
body: {
|
|
706
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
707
|
+
message: "Internal server error",
|
|
708
|
+
requestId: ctx?.requestId,
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
`;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function todoApp(ctx: TemplateContext): string {
|
|
717
|
+
const listTodosQueryOptions =
|
|
718
|
+
ctx.preset === "standard" ? "{ query: {} }" : "";
|
|
719
|
+
|
|
720
|
+
if (hasFeature(ctx, "forms")) {
|
|
721
|
+
return `"use client";
|
|
722
|
+
|
|
723
|
+
import { createReactHookForm } from "@beignet/react-hook-form";
|
|
724
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
725
|
+
import { createTodo, listTodos } from "@/features/todos/contracts";
|
|
726
|
+
import { rq } from "@/client/rq";
|
|
727
|
+
|
|
728
|
+
const rhf = createReactHookForm();
|
|
729
|
+
const createTodoForm = rhf(createTodo);
|
|
730
|
+
|
|
731
|
+
export function TodoApp() {
|
|
732
|
+
const queryClient = useQueryClient();
|
|
733
|
+
const todosQuery = useQuery(rq(listTodos).queryOptions(${listTodosQueryOptions}));
|
|
734
|
+
const form = createTodoForm.useForm({
|
|
735
|
+
defaultValues: { title: "" },
|
|
736
|
+
});
|
|
737
|
+
const createTodoMutation = useMutation(
|
|
738
|
+
rq(createTodo).mutationOptions({
|
|
739
|
+
onSuccess: async () => {
|
|
740
|
+
form.reset();
|
|
741
|
+
await queryClient.invalidateQueries({
|
|
742
|
+
queryKey: rq(listTodos).key(),
|
|
743
|
+
});
|
|
744
|
+
},
|
|
745
|
+
}),
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const onSubmit = form.handleSubmit((body) => {
|
|
749
|
+
createTodoMutation.mutate({ body });
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
return (
|
|
753
|
+
<section className="workspace">
|
|
754
|
+
<form className="composer" onSubmit={onSubmit}>
|
|
755
|
+
<label htmlFor="todo-title">New todo</label>
|
|
756
|
+
<div className="composer-row">
|
|
757
|
+
<input
|
|
758
|
+
id="todo-title"
|
|
759
|
+
placeholder="Ship something type-safe"
|
|
760
|
+
{...form.register("title")}
|
|
761
|
+
/>
|
|
762
|
+
<button type="submit" disabled={createTodoMutation.isPending}>
|
|
763
|
+
{createTodoMutation.isPending ? "Adding" : "Add"}
|
|
764
|
+
</button>
|
|
765
|
+
</div>
|
|
766
|
+
{form.formState.errors.title ? (
|
|
767
|
+
<p className="error">{form.formState.errors.title.message}</p>
|
|
768
|
+
) : null}
|
|
769
|
+
</form>
|
|
770
|
+
|
|
771
|
+
<div className="todos-panel">
|
|
772
|
+
<div className="panel-heading">
|
|
773
|
+
<h2>Todos</h2>
|
|
774
|
+
<span>{todosQuery.data?.total ?? 0} total</span>
|
|
775
|
+
</div>
|
|
776
|
+
{todosQuery.isLoading ? <p className="muted">Loading todos...</p> : null}
|
|
777
|
+
{todosQuery.isError ? (
|
|
778
|
+
<p className="error">Could not load todos.</p>
|
|
779
|
+
) : null}
|
|
780
|
+
<ul className="todo-list">
|
|
781
|
+
{todosQuery.data?.todos.map((todo) => (
|
|
782
|
+
<li key={todo.id}>
|
|
783
|
+
<span>{todo.title}</span>
|
|
784
|
+
<small>{todo.completed ? "Done" : "Open"}</small>
|
|
785
|
+
</li>
|
|
786
|
+
))}
|
|
787
|
+
</ul>
|
|
788
|
+
</div>
|
|
789
|
+
</section>
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
`;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return `"use client";
|
|
796
|
+
|
|
797
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
798
|
+
import { useState } from "react";
|
|
799
|
+
import { createTodo, listTodos } from "@/features/todos/contracts";
|
|
800
|
+
import { rq } from "@/client/rq";
|
|
801
|
+
|
|
802
|
+
export function TodoApp() {
|
|
803
|
+
const [title, setTitle] = useState("");
|
|
804
|
+
const queryClient = useQueryClient();
|
|
805
|
+
const todosQuery = useQuery(rq(listTodos).queryOptions(${listTodosQueryOptions}));
|
|
806
|
+
const createTodoMutation = useMutation(
|
|
807
|
+
rq(createTodo).mutationOptions({
|
|
808
|
+
onSuccess: async () => {
|
|
809
|
+
setTitle("");
|
|
810
|
+
await queryClient.invalidateQueries({
|
|
811
|
+
queryKey: rq(listTodos).key(),
|
|
812
|
+
});
|
|
813
|
+
},
|
|
814
|
+
}),
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
return (
|
|
818
|
+
<section className="workspace">
|
|
819
|
+
<form
|
|
820
|
+
className="composer"
|
|
821
|
+
onSubmit={(event) => {
|
|
822
|
+
event.preventDefault();
|
|
823
|
+
createTodoMutation.mutate({ body: { title } });
|
|
824
|
+
}}
|
|
825
|
+
>
|
|
826
|
+
<label htmlFor="todo-title">New todo</label>
|
|
827
|
+
<div className="composer-row">
|
|
828
|
+
<input
|
|
829
|
+
id="todo-title"
|
|
830
|
+
value={title}
|
|
831
|
+
onChange={(event) => setTitle(event.currentTarget.value)}
|
|
832
|
+
placeholder="Ship something type-safe"
|
|
833
|
+
/>
|
|
834
|
+
<button type="submit" disabled={!title || createTodoMutation.isPending}>
|
|
835
|
+
{createTodoMutation.isPending ? "Adding" : "Add"}
|
|
836
|
+
</button>
|
|
837
|
+
</div>
|
|
838
|
+
</form>
|
|
839
|
+
|
|
840
|
+
<div className="todos-panel">
|
|
841
|
+
<div className="panel-heading">
|
|
842
|
+
<h2>Todos</h2>
|
|
843
|
+
<span>{todosQuery.data?.total ?? 0} total</span>
|
|
844
|
+
</div>
|
|
845
|
+
<ul className="todo-list">
|
|
846
|
+
{todosQuery.data?.todos.map((todo) => (
|
|
847
|
+
<li key={todo.id}>
|
|
848
|
+
<span>{todo.title}</span>
|
|
849
|
+
<small>{todo.completed ? "Done" : "Open"}</small>
|
|
850
|
+
</li>
|
|
851
|
+
))}
|
|
852
|
+
</ul>
|
|
853
|
+
</div>
|
|
854
|
+
</section>
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const files = {
|
|
861
|
+
gitignore: `.next
|
|
862
|
+
node_modules
|
|
863
|
+
dist
|
|
864
|
+
coverage
|
|
865
|
+
.env
|
|
866
|
+
.env.local
|
|
867
|
+
`,
|
|
868
|
+
nextEnv: `/// <reference types="next" />
|
|
869
|
+
/// <reference types="next/image-types/global" />
|
|
870
|
+
|
|
871
|
+
// This file should not be edited.
|
|
872
|
+
`,
|
|
873
|
+
nextConfig: `/** @type {import("next").NextConfig} */
|
|
874
|
+
const nextConfig = {};
|
|
875
|
+
|
|
876
|
+
export default nextConfig;
|
|
877
|
+
`,
|
|
878
|
+
tsconfig: json({
|
|
879
|
+
compilerOptions: {
|
|
880
|
+
target: "ES2017",
|
|
881
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
882
|
+
allowJs: true,
|
|
883
|
+
skipLibCheck: true,
|
|
884
|
+
strict: true,
|
|
885
|
+
noEmit: true,
|
|
886
|
+
esModuleInterop: true,
|
|
887
|
+
module: "esnext",
|
|
888
|
+
moduleResolution: "bundler",
|
|
889
|
+
resolveJsonModule: true,
|
|
890
|
+
isolatedModules: true,
|
|
891
|
+
jsx: "react-jsx",
|
|
892
|
+
incremental: true,
|
|
893
|
+
plugins: [{ name: "next" }],
|
|
894
|
+
paths: {
|
|
895
|
+
"@/*": ["./*"],
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
899
|
+
exclude: ["node_modules"],
|
|
900
|
+
}),
|
|
901
|
+
globals: `:root {
|
|
902
|
+
color-scheme: light;
|
|
903
|
+
background: #f8fafc;
|
|
904
|
+
color: #172033;
|
|
905
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
* {
|
|
909
|
+
box-sizing: border-box;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
body {
|
|
913
|
+
margin: 0;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
a {
|
|
917
|
+
color: #4f46e5;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
button,
|
|
921
|
+
input {
|
|
922
|
+
font: inherit;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
.page {
|
|
926
|
+
min-height: 100vh;
|
|
927
|
+
padding: 48px 24px 64px;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
.shell {
|
|
931
|
+
max-width: 880px;
|
|
932
|
+
margin: 0 auto;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
.hero {
|
|
936
|
+
margin-bottom: 28px;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.hero h1 {
|
|
940
|
+
max-width: 720px;
|
|
941
|
+
margin: 0 0 12px;
|
|
942
|
+
font-size: 40px;
|
|
943
|
+
line-height: 1.05;
|
|
944
|
+
letter-spacing: 0;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
.eyebrow {
|
|
948
|
+
margin: 0 0 12px;
|
|
949
|
+
color: #4f46e5;
|
|
950
|
+
font-size: 13px;
|
|
951
|
+
font-weight: 700;
|
|
952
|
+
text-transform: uppercase;
|
|
953
|
+
letter-spacing: 0.12em;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
.muted {
|
|
957
|
+
color: #475569;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.panel {
|
|
961
|
+
margin-top: 24px;
|
|
962
|
+
padding: 20px;
|
|
963
|
+
border: 1px solid #e2e8f0;
|
|
964
|
+
border-radius: 8px;
|
|
965
|
+
background: white;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
.code {
|
|
969
|
+
padding: 16px;
|
|
970
|
+
overflow-x: auto;
|
|
971
|
+
border-radius: 8px;
|
|
972
|
+
background: #111827;
|
|
973
|
+
color: #f8fafc;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.workspace {
|
|
977
|
+
display: grid;
|
|
978
|
+
gap: 18px;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.composer,
|
|
982
|
+
.todos-panel {
|
|
983
|
+
padding: 18px;
|
|
984
|
+
border: 1px solid #dbe3ef;
|
|
985
|
+
border-radius: 8px;
|
|
986
|
+
background: #ffffff;
|
|
987
|
+
box-shadow: 0 1px 2px rgb(15 23 42 / 0.05);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.composer label,
|
|
991
|
+
.panel-heading h2 {
|
|
992
|
+
margin: 0;
|
|
993
|
+
color: #172033;
|
|
994
|
+
font-size: 15px;
|
|
995
|
+
font-weight: 700;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.composer-row {
|
|
999
|
+
display: flex;
|
|
1000
|
+
gap: 10px;
|
|
1001
|
+
margin-top: 10px;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.composer input {
|
|
1005
|
+
width: 100%;
|
|
1006
|
+
min-width: 0;
|
|
1007
|
+
border: 1px solid #cbd5e1;
|
|
1008
|
+
border-radius: 8px;
|
|
1009
|
+
padding: 10px 12px;
|
|
1010
|
+
color: #172033;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.composer button {
|
|
1014
|
+
border: 0;
|
|
1015
|
+
border-radius: 8px;
|
|
1016
|
+
padding: 10px 14px;
|
|
1017
|
+
background: #4f46e5;
|
|
1018
|
+
color: #ffffff;
|
|
1019
|
+
font-weight: 700;
|
|
1020
|
+
cursor: pointer;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
.composer button:disabled {
|
|
1024
|
+
cursor: not-allowed;
|
|
1025
|
+
opacity: 0.6;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
.panel-heading {
|
|
1029
|
+
display: flex;
|
|
1030
|
+
align-items: center;
|
|
1031
|
+
justify-content: space-between;
|
|
1032
|
+
gap: 16px;
|
|
1033
|
+
margin-bottom: 12px;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.panel-heading span {
|
|
1037
|
+
color: #64748b;
|
|
1038
|
+
font-size: 13px;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
.todo-list {
|
|
1042
|
+
display: grid;
|
|
1043
|
+
gap: 8px;
|
|
1044
|
+
margin: 0;
|
|
1045
|
+
padding: 0;
|
|
1046
|
+
list-style: none;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.todo-list li {
|
|
1050
|
+
display: flex;
|
|
1051
|
+
align-items: center;
|
|
1052
|
+
justify-content: space-between;
|
|
1053
|
+
gap: 16px;
|
|
1054
|
+
padding: 12px;
|
|
1055
|
+
border: 1px solid #e2e8f0;
|
|
1056
|
+
border-radius: 8px;
|
|
1057
|
+
background: #f8fafc;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
.todo-list small {
|
|
1061
|
+
color: #64748b;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
.error {
|
|
1065
|
+
margin: 10px 0 0;
|
|
1066
|
+
color: #dc2626;
|
|
1067
|
+
font-size: 14px;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
@media (max-width: 640px) {
|
|
1071
|
+
.page {
|
|
1072
|
+
padding: 32px 16px 48px;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
.hero h1 {
|
|
1076
|
+
font-size: 32px;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.composer-row {
|
|
1080
|
+
flex-direction: column;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
`,
|
|
1084
|
+
appProviders: `"use client";
|
|
1085
|
+
|
|
1086
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
1087
|
+
import { type ReactNode, useState } from "react";
|
|
1088
|
+
|
|
1089
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
1090
|
+
const [queryClient] = useState(() => new QueryClient());
|
|
1091
|
+
|
|
1092
|
+
return (
|
|
1093
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
`,
|
|
1097
|
+
apiClient: `import { createNextClient } from "@beignet/next";
|
|
1098
|
+
|
|
1099
|
+
export const apiClient = createNextClient({
|
|
1100
|
+
validate: true,
|
|
1101
|
+
});
|
|
1102
|
+
`,
|
|
1103
|
+
rq: `import { createReactQuery } from "@beignet/react-query";
|
|
1104
|
+
import { apiClient } from "./api-client";
|
|
1105
|
+
|
|
1106
|
+
export const rq = createReactQuery(apiClient);
|
|
1107
|
+
`,
|
|
1108
|
+
contractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
|
|
1109
|
+
import { z } from "zod";
|
|
1110
|
+
|
|
1111
|
+
const todos = createContractGroup().namespace("todos");
|
|
1112
|
+
|
|
1113
|
+
export const TodoSchema = z.object({
|
|
1114
|
+
id: z.string(),
|
|
1115
|
+
title: z.string().min(1),
|
|
1116
|
+
completed: z.boolean(),
|
|
1117
|
+
createdAt: z.string(),
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
export const CreateTodoSchema = z.object({
|
|
1121
|
+
title: z.string().min(1),
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
export const listTodos = todos.get("/api/todos").responses({
|
|
1125
|
+
200: z.object({
|
|
1126
|
+
todos: z.array(TodoSchema),
|
|
1127
|
+
total: z.number(),
|
|
1128
|
+
}),
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
export const createTodo = todos
|
|
1132
|
+
.post("/api/todos")
|
|
1133
|
+
.body(CreateTodoSchema)
|
|
1134
|
+
.responses({
|
|
1135
|
+
201: TodoSchema,
|
|
1136
|
+
});
|
|
1137
|
+
`,
|
|
1138
|
+
ports: `import { definePorts } from "@beignet/core/ports";
|
|
1139
|
+
import type { z } from "zod";
|
|
1140
|
+
import type { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
|
|
1141
|
+
|
|
1142
|
+
export type Todo = z.infer<typeof TodoSchema>;
|
|
1143
|
+
export type CreateTodoInput = z.infer<typeof CreateTodoSchema>;
|
|
1144
|
+
|
|
1145
|
+
const todos = new Map<string, Todo>();
|
|
1146
|
+
|
|
1147
|
+
function createTodo(input: CreateTodoInput): Todo {
|
|
1148
|
+
const todo = {
|
|
1149
|
+
id: crypto.randomUUID(),
|
|
1150
|
+
title: input.title,
|
|
1151
|
+
completed: false,
|
|
1152
|
+
createdAt: new Date().toISOString(),
|
|
1153
|
+
};
|
|
1154
|
+
todos.set(todo.id, todo);
|
|
1155
|
+
return todo;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
createTodo({ title: "Read the generated contract" });
|
|
1159
|
+
createTodo({ title: "Add your first use case" });
|
|
1160
|
+
|
|
1161
|
+
export const appPorts = definePorts({
|
|
1162
|
+
todos: {
|
|
1163
|
+
list: async () => Array.from(todos.values()),
|
|
1164
|
+
create: async (input: CreateTodoInput) => createTodo(input),
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
export type AppPorts = typeof appPorts;
|
|
1169
|
+
`,
|
|
1170
|
+
useCasesTodos: `import { createUseCase } from "@beignet/core/application";
|
|
1171
|
+
import { z } from "zod";
|
|
1172
|
+
import { CreateTodoSchema, TodoSchema } from "@/features/todos/contracts";
|
|
1173
|
+
import type { AppPorts } from "@/ports";
|
|
1174
|
+
|
|
1175
|
+
type AppContext = {
|
|
1176
|
+
requestId: string;
|
|
1177
|
+
ports: AppPorts;
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
const useCase = createUseCase<AppContext>();
|
|
1181
|
+
|
|
1182
|
+
export const ListTodosOutputSchema = z.object({
|
|
1183
|
+
todos: z.array(TodoSchema),
|
|
1184
|
+
total: z.number(),
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
export const listTodos = useCase
|
|
1188
|
+
.query("listTodos")
|
|
1189
|
+
.input(z.void())
|
|
1190
|
+
.output(ListTodosOutputSchema)
|
|
1191
|
+
.run(async ({ ctx }) => {
|
|
1192
|
+
const todos = await ctx.ports.todos.list();
|
|
1193
|
+
return { todos, total: todos.length };
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
export const createTodo = useCase
|
|
1197
|
+
.command("createTodo")
|
|
1198
|
+
.input(CreateTodoSchema)
|
|
1199
|
+
.output(TodoSchema)
|
|
1200
|
+
.run(async ({ ctx, input }) => ctx.ports.todos.create(input));
|
|
1201
|
+
`,
|
|
1202
|
+
todoRoutes: `import { defineRouteGroup } from "@beignet/next";
|
|
1203
|
+
import type { AppContext } from "@/server";
|
|
1204
|
+
import { createTodo, listTodos } from "@/features/todos/use-cases";
|
|
1205
|
+
import * as contracts from "@/features/todos/contracts";
|
|
1206
|
+
|
|
1207
|
+
export const todoRoutes = defineRouteGroup<AppContext>({
|
|
1208
|
+
name: "todos",
|
|
1209
|
+
routes: [
|
|
1210
|
+
{
|
|
1211
|
+
contract: contracts.listTodos,
|
|
1212
|
+
handle: async ({ ctx }) => ({
|
|
1213
|
+
status: 200,
|
|
1214
|
+
body: await listTodos.run({ ctx, input: undefined }),
|
|
1215
|
+
}),
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
contract: contracts.createTodo,
|
|
1219
|
+
handle: async ({ ctx, body }) => ({
|
|
1220
|
+
status: 201,
|
|
1221
|
+
body: await createTodo.run({ ctx, input: body }),
|
|
1222
|
+
}),
|
|
1223
|
+
},
|
|
1224
|
+
],
|
|
1225
|
+
});
|
|
1226
|
+
`,
|
|
1227
|
+
serverRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
|
|
1228
|
+
import type { AppContext } from "@/server";
|
|
1229
|
+
import { todoRoutes } from "@/features/todos/routes";
|
|
1230
|
+
|
|
1231
|
+
export const routes = defineRoutes<AppContext>([
|
|
1232
|
+
todoRoutes,
|
|
1233
|
+
]);
|
|
1234
|
+
export const contracts = contractsFromRoutes(routes);
|
|
1235
|
+
`,
|
|
1236
|
+
apiCatchAllRoute: `import { server } from "@/server";
|
|
1237
|
+
|
|
1238
|
+
export const GET = server.api;
|
|
1239
|
+
export const HEAD = server.api;
|
|
1240
|
+
export const OPTIONS = server.api;
|
|
1241
|
+
export const PATCH = server.api;
|
|
1242
|
+
export const POST = server.api;
|
|
1243
|
+
export const PUT = server.api;
|
|
1244
|
+
export const DELETE = server.api;
|
|
1245
|
+
`,
|
|
1246
|
+
apiOpenApiRoute: `import { createOpenAPIHandler } from "@beignet/next";
|
|
1247
|
+
import { server } from "@/server";
|
|
1248
|
+
|
|
1249
|
+
export const GET = createOpenAPIHandler(server.contracts, {
|
|
1250
|
+
title: "Beignet starter API",
|
|
1251
|
+
version: "0.1.0",
|
|
1252
|
+
});
|
|
1253
|
+
`,
|
|
1254
|
+
productionEnv: `import { createEnv } from "@beignet/core/config";
|
|
1255
|
+
import { z } from "zod";
|
|
1256
|
+
|
|
1257
|
+
const BooleanEnv = z.enum(["true", "false"]).transform((value) => value === "true");
|
|
1258
|
+
|
|
1259
|
+
export const env = createEnv({
|
|
1260
|
+
server: {
|
|
1261
|
+
NODE_ENV: z
|
|
1262
|
+
.enum(["development", "test", "production"])
|
|
1263
|
+
.default("development"),
|
|
1264
|
+
APP_URL: z.string().url().default("http://localhost:3000"),
|
|
1265
|
+
CRON_SECRET: z.string().min(1).optional(),
|
|
1266
|
+
DEVTOOLS_ENABLED: BooleanEnv.optional(),
|
|
1267
|
+
LOG_LEVEL: z
|
|
1268
|
+
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
|
1269
|
+
.default("info"),
|
|
1270
|
+
LOG_FORMAT: z.enum(["pretty", "json"]).default("json"),
|
|
1271
|
+
LOG_SERVICE: z.string().default("beignet-app"),
|
|
1272
|
+
},
|
|
1273
|
+
runtimeEnv: process.env,
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
export const isProduction = env.NODE_ENV === "production";
|
|
1277
|
+
`,
|
|
1278
|
+
productionAppContext: `import type { DevtoolsPort, DevtoolsTraceContext } from "@beignet/devtools";
|
|
1279
|
+
import type { ActivityActor, ActivityTenant, StoragePort } from "@beignet/core/ports";
|
|
1280
|
+
import type { AppGate, AppPorts } from "@/ports";
|
|
1281
|
+
import type { AuthSession } from "@/ports/auth";
|
|
1282
|
+
|
|
1283
|
+
export type AppContext = {
|
|
1284
|
+
requestId: string;
|
|
1285
|
+
actor: ActivityActor;
|
|
1286
|
+
auth: AuthSession | null;
|
|
1287
|
+
gate: AppGate;
|
|
1288
|
+
ports: AppPorts & {
|
|
1289
|
+
devtools: DevtoolsPort;
|
|
1290
|
+
storage: StoragePort;
|
|
1291
|
+
};
|
|
1292
|
+
tenant?: ActivityTenant;
|
|
1293
|
+
} & Partial<DevtoolsTraceContext>;
|
|
1294
|
+
`,
|
|
1295
|
+
productimapUnhandledErrors: `import { createAppError, defineErrors } from "@beignet/core/errors";
|
|
1296
|
+
|
|
1297
|
+
export const errors = defineErrors({
|
|
1298
|
+
Unauthorized: {
|
|
1299
|
+
code: "UNAUTHORIZED",
|
|
1300
|
+
status: 401,
|
|
1301
|
+
message: "Authentication required",
|
|
1302
|
+
},
|
|
1303
|
+
Forbidden: {
|
|
1304
|
+
code: "FORBIDDEN",
|
|
1305
|
+
status: 403,
|
|
1306
|
+
message: "Forbidden",
|
|
1307
|
+
},
|
|
1308
|
+
TodoNotFound: {
|
|
1309
|
+
code: "TODO_NOT_FOUND",
|
|
1310
|
+
status: 404,
|
|
1311
|
+
message: "Todo not found",
|
|
1312
|
+
},
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
export const appError = createAppError(errors);
|
|
1316
|
+
`,
|
|
1317
|
+
productionTodoSchemas: `import { z } from "zod";
|
|
1318
|
+
|
|
1319
|
+
export const TodoSchema = z.object({
|
|
1320
|
+
id: z.string().uuid(),
|
|
1321
|
+
title: z.string().min(1),
|
|
1322
|
+
completed: z.boolean(),
|
|
1323
|
+
createdAt: z.string().datetime(),
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
export const ListTodosInputSchema = z.object({
|
|
1327
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
1328
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
export const ListTodosOutputSchema = z.object({
|
|
1332
|
+
todos: z.array(TodoSchema),
|
|
1333
|
+
total: z.number().int().min(0),
|
|
1334
|
+
limit: z.number().int().min(1),
|
|
1335
|
+
offset: z.number().int().min(0),
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
export const GetTodoInputSchema = z.object({
|
|
1339
|
+
id: z.string().uuid(),
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
export const CreateTodoInputSchema = z.object({
|
|
1343
|
+
title: z.string().min(1).max(120),
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
export type Todo = z.infer<typeof TodoSchema>;
|
|
1347
|
+
export type CreateTodoInput = z.infer<typeof CreateTodoInputSchema>;
|
|
1348
|
+
export type ListTodosInput = z.infer<typeof ListTodosInputSchema>;
|
|
1349
|
+
`,
|
|
1350
|
+
productionUseCaseBuilder: `import { createUseCase } from "@beignet/core/application";
|
|
1351
|
+
import { createDevtoolsUseCaseObserver } from "@beignet/devtools";
|
|
1352
|
+
import type { AppContext } from "@/app-context";
|
|
1353
|
+
|
|
1354
|
+
export const useCase = createUseCase<AppContext>({
|
|
1355
|
+
onRun: createDevtoolsUseCaseObserver<AppContext>(),
|
|
1356
|
+
});
|
|
1357
|
+
`,
|
|
1358
|
+
productionAuthHelpers: `import type { AppContext } from "@/app-context";
|
|
1359
|
+
import type { AuthSession, AuthUser } from "@/ports/auth";
|
|
1360
|
+
import { appError } from "@/features/shared/errors";
|
|
1361
|
+
|
|
1362
|
+
export function requireSession(ctx: AppContext): AuthSession {
|
|
1363
|
+
if (!ctx.auth) {
|
|
1364
|
+
throw appError("Unauthorized");
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
return ctx.auth;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
export function requireUser(ctx: AppContext): AuthUser {
|
|
1371
|
+
return requireSession(ctx).user;
|
|
1372
|
+
}
|
|
1373
|
+
`,
|
|
1374
|
+
productionListTodosUseCase: `import { useCase } from "@/lib/use-case";
|
|
1375
|
+
import { ListTodosInputSchema, ListTodosOutputSchema } from "./schemas";
|
|
1376
|
+
|
|
1377
|
+
export const listTodosUseCase = useCase
|
|
1378
|
+
.query("todos.list")
|
|
1379
|
+
.input(ListTodosInputSchema)
|
|
1380
|
+
.output(ListTodosOutputSchema)
|
|
1381
|
+
.run(async ({ ctx, input }) => {
|
|
1382
|
+
const result = await ctx.ports.todos.list(input);
|
|
1383
|
+
return {
|
|
1384
|
+
todos: result.todos,
|
|
1385
|
+
total: result.total,
|
|
1386
|
+
limit: input.limit,
|
|
1387
|
+
offset: input.offset,
|
|
1388
|
+
};
|
|
1389
|
+
});
|
|
1390
|
+
`,
|
|
1391
|
+
productionGetTodoUseCase: `import { appError } from "@/features/shared/errors";
|
|
1392
|
+
import { useCase } from "@/lib/use-case";
|
|
1393
|
+
import { GetTodoInputSchema, TodoSchema } from "./schemas";
|
|
1394
|
+
|
|
1395
|
+
export const getTodoUseCase = useCase
|
|
1396
|
+
.query("todos.get")
|
|
1397
|
+
.input(GetTodoInputSchema)
|
|
1398
|
+
.output(TodoSchema)
|
|
1399
|
+
.run(async ({ ctx, input }) => {
|
|
1400
|
+
const todo = await ctx.ports.todos.findById(input.id);
|
|
1401
|
+
if (!todo) {
|
|
1402
|
+
throw appError("TodoNotFound", { details: { id: input.id } });
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return todo;
|
|
1406
|
+
});
|
|
1407
|
+
`,
|
|
1408
|
+
productionCreateTodoUseCase: `import { useCase } from "@/lib/use-case";
|
|
1409
|
+
import { CreateTodoInputSchema, TodoSchema } from "./schemas";
|
|
1410
|
+
|
|
1411
|
+
export const createTodoUseCase = useCase
|
|
1412
|
+
.command("todos.create")
|
|
1413
|
+
.input(CreateTodoInputSchema)
|
|
1414
|
+
.output(TodoSchema)
|
|
1415
|
+
.run(async ({ ctx, input }) => {
|
|
1416
|
+
await ctx.gate.authorize("todos.create");
|
|
1417
|
+
return ctx.ports.uow.transaction((tx) => tx.todos.create(input));
|
|
1418
|
+
});
|
|
1419
|
+
`,
|
|
1420
|
+
productionUseCasesIndex: `export { createTodoUseCase } from "./create-todo";
|
|
1421
|
+
export { getTodoUseCase } from "./get-todo";
|
|
1422
|
+
export { listTodosUseCase } from "./list-todos";
|
|
1423
|
+
export {
|
|
1424
|
+
CreateTodoInputSchema,
|
|
1425
|
+
GetTodoInputSchema,
|
|
1426
|
+
ListTodosInputSchema,
|
|
1427
|
+
ListTodosOutputSchema,
|
|
1428
|
+
TodoSchema,
|
|
1429
|
+
type CreateTodoInput,
|
|
1430
|
+
type ListTodosInput,
|
|
1431
|
+
type Todo,
|
|
1432
|
+
} from "./schemas";
|
|
1433
|
+
`,
|
|
1434
|
+
productionTodoRepositoryPort: `import type {
|
|
1435
|
+
CreateTodoInput,
|
|
1436
|
+
ListTodosInput,
|
|
1437
|
+
Todo,
|
|
1438
|
+
} from "@/features/todos/use-cases/schemas";
|
|
1439
|
+
|
|
1440
|
+
export type ListTodosResult = {
|
|
1441
|
+
todos: Todo[];
|
|
1442
|
+
total: number;
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
export interface TodoRepository {
|
|
1446
|
+
list(input: ListTodosInput): Promise<ListTodosResult>;
|
|
1447
|
+
findById(id: string): Promise<Todo | null>;
|
|
1448
|
+
create(input: CreateTodoInput): Promise<Todo>;
|
|
1449
|
+
}
|
|
1450
|
+
`,
|
|
1451
|
+
productionAuthPort: `import type {
|
|
1452
|
+
AuthPort as BeignetAuthPort,
|
|
1453
|
+
AuthRequestLike,
|
|
1454
|
+
AuthSession as BeignetAuthSession,
|
|
1455
|
+
} from "@beignet/core/ports";
|
|
1456
|
+
|
|
1457
|
+
export type AuthRequest = AuthRequestLike;
|
|
1458
|
+
|
|
1459
|
+
export type AuthUser = {
|
|
1460
|
+
id: string;
|
|
1461
|
+
email?: string;
|
|
1462
|
+
name?: string;
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
export type AuthSessionMetadata = {
|
|
1466
|
+
id?: string;
|
|
1467
|
+
};
|
|
1468
|
+
|
|
1469
|
+
export type AuthSession = BeignetAuthSession<
|
|
1470
|
+
AuthUser,
|
|
1471
|
+
AuthSessionMetadata
|
|
1472
|
+
>;
|
|
1473
|
+
|
|
1474
|
+
export type AuthPort = BeignetAuthPort<
|
|
1475
|
+
AuthUser,
|
|
1476
|
+
AuthSessionMetadata,
|
|
1477
|
+
AuthRequest
|
|
1478
|
+
>;
|
|
1479
|
+
`,
|
|
1480
|
+
productionLogger: `import type { LoggerPort } from "@beignet/core/ports";
|
|
1481
|
+
|
|
1482
|
+
export const fallbackLogger: LoggerPort = {
|
|
1483
|
+
trace: (message, meta) => console.trace(message, meta),
|
|
1484
|
+
debug: (message, meta) => console.debug(message, meta),
|
|
1485
|
+
info: (message, meta) => console.info(message, meta),
|
|
1486
|
+
warn: (message, meta) => console.warn(message, meta),
|
|
1487
|
+
error: (message, meta) => console.error(message, meta),
|
|
1488
|
+
fatal: (message, meta) => console.error(message, meta),
|
|
1489
|
+
child: () => fallbackLogger,
|
|
1490
|
+
};
|
|
1491
|
+
`,
|
|
1492
|
+
productionAnonymousAuth: `import { createAnonymousAuth as createAnonymousAuthPort } from "@beignet/core/ports";
|
|
1493
|
+
import type {
|
|
1494
|
+
AuthPort,
|
|
1495
|
+
AuthRequest,
|
|
1496
|
+
AuthSessionMetadata,
|
|
1497
|
+
AuthUser,
|
|
1498
|
+
} from "@/ports/auth";
|
|
1499
|
+
|
|
1500
|
+
export function createAnonymousAuth(): AuthPort {
|
|
1501
|
+
return createAnonymousAuthPort<AuthUser, AuthSessionMetadata, AuthRequest>();
|
|
1502
|
+
}
|
|
1503
|
+
`,
|
|
1504
|
+
productionTodoPolicy: `import { type ActivityActor, definePolicy, deny } from "@beignet/core/ports";
|
|
1505
|
+
import type { AuthSession } from "@/ports/auth";
|
|
1506
|
+
|
|
1507
|
+
export type AuthorizationContext = {
|
|
1508
|
+
actor: ActivityActor;
|
|
1509
|
+
auth: AuthSession | null;
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
export const todoPolicy = definePolicy({
|
|
1513
|
+
"todos.create": (ctx: AuthorizationContext) => {
|
|
1514
|
+
if (ctx.actor.type === "user") return true;
|
|
1515
|
+
return deny("You must be signed in to create todos.");
|
|
1516
|
+
},
|
|
1517
|
+
});
|
|
1518
|
+
`,
|
|
1519
|
+
productionInMemoryTodoRepository: `import type {
|
|
1520
|
+
CreateTodoInput,
|
|
1521
|
+
ListTodosInput,
|
|
1522
|
+
Todo,
|
|
1523
|
+
} from "@/features/todos/use-cases/schemas";
|
|
1524
|
+
import type { TodoRepository } from "@/features/todos/ports";
|
|
1525
|
+
|
|
1526
|
+
export function createInMemoryTodoRepository(
|
|
1527
|
+
seed: Todo[] = [],
|
|
1528
|
+
): TodoRepository {
|
|
1529
|
+
const todos = new Map(seed.map((todo) => [todo.id, todo]));
|
|
1530
|
+
|
|
1531
|
+
return {
|
|
1532
|
+
async list(input: ListTodosInput) {
|
|
1533
|
+
const allTodos = Array.from(todos.values()).sort((left, right) =>
|
|
1534
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1537
|
+
return {
|
|
1538
|
+
todos: allTodos.slice(input.offset, input.offset + input.limit),
|
|
1539
|
+
total: allTodos.length,
|
|
1540
|
+
};
|
|
1541
|
+
},
|
|
1542
|
+
async findById(id: string) {
|
|
1543
|
+
return todos.get(id) ?? null;
|
|
1544
|
+
},
|
|
1545
|
+
async create(input: CreateTodoInput) {
|
|
1546
|
+
const todo: Todo = {
|
|
1547
|
+
id: crypto.randomUUID(),
|
|
1548
|
+
title: input.title,
|
|
1549
|
+
completed: false,
|
|
1550
|
+
createdAt: new Date().toISOString(),
|
|
1551
|
+
};
|
|
1552
|
+
todos.set(todo.id, todo);
|
|
1553
|
+
return todo;
|
|
1554
|
+
},
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
`,
|
|
1558
|
+
productionInfrastructurePorts: `import {
|
|
1559
|
+
createGate,
|
|
1560
|
+
createNoopUnitOfWork,
|
|
1561
|
+
definePorts,
|
|
1562
|
+
} from "@beignet/core/ports";
|
|
1563
|
+
import { todoPolicy } from "@/features/todos/policy";
|
|
1564
|
+
import { appError } from "@/features/shared/errors";
|
|
1565
|
+
import { createAnonymousAuth } from "./auth/anonymous-auth";
|
|
1566
|
+
import { fallbackLogger } from "./logger";
|
|
1567
|
+
import { createInMemoryTodoRepository } from "./todos/in-memory-todo-repository";
|
|
1568
|
+
|
|
1569
|
+
const todos = createInMemoryTodoRepository([
|
|
1570
|
+
{
|
|
1571
|
+
id: "00000000-0000-4000-8000-000000000001",
|
|
1572
|
+
title: "Review the starter boundaries",
|
|
1573
|
+
completed: false,
|
|
1574
|
+
createdAt: new Date().toISOString(),
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
id: "00000000-0000-4000-8000-000000000002",
|
|
1578
|
+
title: "Replace the repository with durable persistence",
|
|
1579
|
+
completed: false,
|
|
1580
|
+
createdAt: new Date().toISOString(),
|
|
1581
|
+
},
|
|
1582
|
+
]);
|
|
1583
|
+
|
|
1584
|
+
const gate = createGate({
|
|
1585
|
+
policies: [todoPolicy],
|
|
1586
|
+
onDeny(decision) {
|
|
1587
|
+
return appError("Forbidden", {
|
|
1588
|
+
message: decision.reason ?? "Forbidden",
|
|
1589
|
+
details: decision.details,
|
|
1590
|
+
});
|
|
1591
|
+
},
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
export const appPorts = definePorts({
|
|
1595
|
+
auth: createAnonymousAuth(),
|
|
1596
|
+
gate,
|
|
1597
|
+
todos,
|
|
1598
|
+
logger: fallbackLogger,
|
|
1599
|
+
uow: createNoopUnitOfWork(() => ({
|
|
1600
|
+
todos,
|
|
1601
|
+
})),
|
|
1602
|
+
});
|
|
1603
|
+
`,
|
|
1604
|
+
productionInfrastructurePortsWithDrizzleTurso: `import { createGate, definePorts } from "@beignet/core/ports";
|
|
1605
|
+
import { todoPolicy } from "@/features/todos/policy";
|
|
1606
|
+
import { appError } from "@/features/shared/errors";
|
|
1607
|
+
import { createAnonymousAuth } from "./auth/anonymous-auth";
|
|
1608
|
+
import { fallbackLogger } from "./logger";
|
|
1609
|
+
|
|
1610
|
+
const gate = createGate({
|
|
1611
|
+
policies: [todoPolicy],
|
|
1612
|
+
onDeny(decision) {
|
|
1613
|
+
return appError("Forbidden", {
|
|
1614
|
+
message: decision.reason ?? "Forbidden",
|
|
1615
|
+
details: decision.details,
|
|
1616
|
+
});
|
|
1617
|
+
},
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
export const appPorts = definePorts({
|
|
1621
|
+
auth: createAnonymousAuth(),
|
|
1622
|
+
gate,
|
|
1623
|
+
logger: fallbackLogger,
|
|
1624
|
+
});
|
|
1625
|
+
`,
|
|
1626
|
+
productionDrizzleConfig: `export default {
|
|
1627
|
+
schema: "./infra/db/schema/index.ts",
|
|
1628
|
+
out: "./drizzle",
|
|
1629
|
+
dialect: "sqlite",
|
|
1630
|
+
dbCredentials: {
|
|
1631
|
+
url: process.env.TURSO_DB_URL ?? "file:local.db",
|
|
1632
|
+
authToken: process.env.TURSO_DB_AUTH_TOKEN,
|
|
1633
|
+
},
|
|
1634
|
+
};
|
|
1635
|
+
`,
|
|
1636
|
+
productionDbSchema: `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
1637
|
+
|
|
1638
|
+
export const todos = sqliteTable("todos", {
|
|
1639
|
+
id: text("id").primaryKey(),
|
|
1640
|
+
title: text("title").notNull(),
|
|
1641
|
+
completed: integer("completed", { mode: "boolean" }).notNull().default(false),
|
|
1642
|
+
createdAt: text("created_at").notNull(),
|
|
1643
|
+
});
|
|
1644
|
+
`,
|
|
1645
|
+
productionDrizzleTodoRepository: `import { count, desc, eq } from "drizzle-orm";
|
|
1646
|
+
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1647
|
+
import type { TodoRepository } from "@/features/todos/ports";
|
|
1648
|
+
import type {
|
|
1649
|
+
CreateTodoInput,
|
|
1650
|
+
ListTodosInput,
|
|
1651
|
+
Todo,
|
|
1652
|
+
} from "@/features/todos/use-cases/schemas";
|
|
1653
|
+
import * as schema from "@/infra/db/schema";
|
|
1654
|
+
|
|
1655
|
+
type TodoRow = typeof schema.todos.$inferSelect;
|
|
1656
|
+
|
|
1657
|
+
function toTodo(row: TodoRow): Todo {
|
|
1658
|
+
return {
|
|
1659
|
+
id: row.id,
|
|
1660
|
+
title: row.title,
|
|
1661
|
+
completed: row.completed,
|
|
1662
|
+
createdAt: row.createdAt,
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export function createDrizzleTodoRepository(
|
|
1667
|
+
db: DrizzleTursoDatabase<typeof schema>,
|
|
1668
|
+
): TodoRepository {
|
|
1669
|
+
return {
|
|
1670
|
+
async list(input: ListTodosInput) {
|
|
1671
|
+
const rows = await db
|
|
1672
|
+
.select()
|
|
1673
|
+
.from(schema.todos)
|
|
1674
|
+
.orderBy(desc(schema.todos.createdAt))
|
|
1675
|
+
.limit(input.limit)
|
|
1676
|
+
.offset(input.offset);
|
|
1677
|
+
const [{ total }] = await db.select({ total: count() }).from(schema.todos);
|
|
1678
|
+
|
|
1679
|
+
return {
|
|
1680
|
+
todos: rows.map(toTodo),
|
|
1681
|
+
total,
|
|
1682
|
+
};
|
|
1683
|
+
},
|
|
1684
|
+
async findById(id: string) {
|
|
1685
|
+
const [row] = await db
|
|
1686
|
+
.select()
|
|
1687
|
+
.from(schema.todos)
|
|
1688
|
+
.where(eq(schema.todos.id, id))
|
|
1689
|
+
.limit(1);
|
|
1690
|
+
|
|
1691
|
+
return row ? toTodo(row) : null;
|
|
1692
|
+
},
|
|
1693
|
+
async create(input: CreateTodoInput) {
|
|
1694
|
+
const todo = {
|
|
1695
|
+
id: crypto.randomUUID(),
|
|
1696
|
+
title: input.title,
|
|
1697
|
+
completed: false,
|
|
1698
|
+
createdAt: new Date().toISOString(),
|
|
1699
|
+
};
|
|
1700
|
+
const [row] = await db.insert(schema.todos).values(todo).returning();
|
|
1701
|
+
|
|
1702
|
+
if (!row) {
|
|
1703
|
+
throw new Error("Failed to create todo");
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
return toTodo(row);
|
|
1707
|
+
},
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
`,
|
|
1711
|
+
productionDbRepositories: `import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
|
|
1712
|
+
import { createDrizzleTodoRepository } from "@/infra/todos/drizzle-todo-repository";
|
|
1713
|
+
import type { AppTransactionPorts } from "@/ports";
|
|
1714
|
+
import * as schema from "./schema";
|
|
1715
|
+
|
|
1716
|
+
export function createRepositories(
|
|
1717
|
+
db: DrizzleTursoDatabase<typeof schema>,
|
|
1718
|
+
): Omit<AppTransactionPorts, "events"> {
|
|
1719
|
+
return {
|
|
1720
|
+
todos: createDrizzleTodoRepository(db),
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
`,
|
|
1724
|
+
productionContractsTodos: `import { createContractGroup } from "@beignet/core/contracts";
|
|
1725
|
+
import { z } from "zod";
|
|
1726
|
+
import { errors } from "@/features/shared/errors";
|
|
1727
|
+
import {
|
|
1728
|
+
createTodoUseCase,
|
|
1729
|
+
getTodoUseCase,
|
|
1730
|
+
listTodosUseCase,
|
|
1731
|
+
} from "@/features/todos/use-cases";
|
|
1732
|
+
|
|
1733
|
+
const ErrorResponseSchema = z.object({
|
|
1734
|
+
code: z.string(),
|
|
1735
|
+
message: z.string(),
|
|
1736
|
+
requestId: z.string().optional(),
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
const todos = createContractGroup()
|
|
1740
|
+
.namespace("todos")
|
|
1741
|
+
.errors({ Unauthorized: errors.Unauthorized })
|
|
1742
|
+
.responses({
|
|
1743
|
+
500: ErrorResponseSchema,
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
export const listTodos = todos
|
|
1747
|
+
.get("/api/todos")
|
|
1748
|
+
.query(listTodosUseCase.inputSchema)
|
|
1749
|
+
.responses({
|
|
1750
|
+
200: listTodosUseCase.outputSchema,
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
export const createTodo = todos
|
|
1754
|
+
.post("/api/todos")
|
|
1755
|
+
.body(createTodoUseCase.inputSchema)
|
|
1756
|
+
.errors({ Forbidden: errors.Forbidden })
|
|
1757
|
+
.responses({
|
|
1758
|
+
201: createTodoUseCase.outputSchema,
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
export const getTodo = todos
|
|
1762
|
+
.get("/api/todos/:id")
|
|
1763
|
+
.pathParams(getTodoUseCase.inputSchema)
|
|
1764
|
+
.errors({ TodoNotFound: errors.TodoNotFound })
|
|
1765
|
+
.responses({
|
|
1766
|
+
200: getTodoUseCase.outputSchema,
|
|
1767
|
+
});
|
|
1768
|
+
`,
|
|
1769
|
+
productionTodoRoutes: `import { defineRouteGroup } from "@beignet/next";
|
|
1770
|
+
import type { AppContext } from "@/app-context";
|
|
1771
|
+
import { createTodo, getTodo, listTodos } from "@/features/todos/contracts";
|
|
1772
|
+
import {
|
|
1773
|
+
createTodoUseCase,
|
|
1774
|
+
getTodoUseCase,
|
|
1775
|
+
listTodosUseCase,
|
|
1776
|
+
} from "@/features/todos/use-cases";
|
|
1777
|
+
|
|
1778
|
+
export const todoRoutes = defineRouteGroup<AppContext>({
|
|
1779
|
+
name: "todos",
|
|
1780
|
+
routes: [
|
|
1781
|
+
{
|
|
1782
|
+
contract: listTodos,
|
|
1783
|
+
handle: async ({ ctx, query }) => ({
|
|
1784
|
+
status: 200,
|
|
1785
|
+
body: await listTodosUseCase.run({ ctx, input: query }),
|
|
1786
|
+
}),
|
|
1787
|
+
},
|
|
1788
|
+
{
|
|
1789
|
+
contract: createTodo,
|
|
1790
|
+
handle: async ({ ctx, body }) => ({
|
|
1791
|
+
status: 201,
|
|
1792
|
+
body: await createTodoUseCase.run({ ctx, input: body }),
|
|
1793
|
+
}),
|
|
1794
|
+
},
|
|
1795
|
+
{
|
|
1796
|
+
contract: getTodo,
|
|
1797
|
+
handle: async ({ ctx, path }) => ({
|
|
1798
|
+
status: 200,
|
|
1799
|
+
body: await getTodoUseCase.run({ ctx, input: path }),
|
|
1800
|
+
}),
|
|
1801
|
+
},
|
|
1802
|
+
],
|
|
1803
|
+
});
|
|
1804
|
+
`,
|
|
1805
|
+
productionServerRoutes: `import { contractsFromRoutes, defineRoutes } from "@beignet/next";
|
|
1806
|
+
import type { AppContext } from "@/app-context";
|
|
1807
|
+
import { todoRoutes } from "@/features/todos/routes";
|
|
1808
|
+
|
|
1809
|
+
export const routes = defineRoutes<AppContext>([
|
|
1810
|
+
todoRoutes,
|
|
1811
|
+
]);
|
|
1812
|
+
export const contracts = contractsFromRoutes(routes);
|
|
1813
|
+
`,
|
|
1814
|
+
productionServer: `import { createDevtoolsHooks } from "@beignet/devtools";
|
|
1815
|
+
import { createNextServer } from "@beignet/next";
|
|
1816
|
+
import {
|
|
1817
|
+
createAnonymousActor,
|
|
1818
|
+
createTenant,
|
|
1819
|
+
createUserActor,
|
|
1820
|
+
} from "@beignet/core/ports";
|
|
1821
|
+
import type { AppContext } from "@/app-context";
|
|
1822
|
+
import { appPorts } from "@/infra/app-ports";
|
|
1823
|
+
import { env } from "@/lib/env";
|
|
1824
|
+
import { providers } from "./providers";
|
|
1825
|
+
import { routes } from "./routes";
|
|
1826
|
+
|
|
1827
|
+
export const server = await createNextServer({
|
|
1828
|
+
ports: appPorts,
|
|
1829
|
+
providers,
|
|
1830
|
+
hooks: [
|
|
1831
|
+
createDevtoolsHooks<AppContext>({
|
|
1832
|
+
requestIdHeader: "x-request-id",
|
|
1833
|
+
}),
|
|
1834
|
+
],
|
|
1835
|
+
createContext: async ({ req, ports }) => {
|
|
1836
|
+
const auth = await ports.auth.getSession(req);
|
|
1837
|
+
const tenantId = req.headers.get("x-tenant-id") || undefined;
|
|
1838
|
+
const context = {
|
|
1839
|
+
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
|
|
1840
|
+
actor: auth
|
|
1841
|
+
? createUserActor(auth.user.id, { displayName: auth.user.name })
|
|
1842
|
+
: createAnonymousActor(),
|
|
1843
|
+
auth,
|
|
1844
|
+
ports,
|
|
1845
|
+
tenant: tenantId ? createTenant(tenantId) : undefined,
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
return {
|
|
1849
|
+
...context,
|
|
1850
|
+
gate: ports.gate.bind(context),
|
|
1851
|
+
};
|
|
1852
|
+
},
|
|
1853
|
+
routes,
|
|
1854
|
+
mapUnhandledError: ({ err, ctx }) => {
|
|
1855
|
+
ctx?.ports.logger.error("Unhandled API error", {
|
|
1856
|
+
error: err,
|
|
1857
|
+
requestId: ctx?.requestId,
|
|
1858
|
+
environment: env.NODE_ENV,
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
return {
|
|
1862
|
+
status: 500,
|
|
1863
|
+
body: {
|
|
1864
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
1865
|
+
message: "Internal server error",
|
|
1866
|
+
requestId: ctx?.requestId,
|
|
1867
|
+
},
|
|
1868
|
+
};
|
|
1869
|
+
},
|
|
1870
|
+
});
|
|
1871
|
+
`,
|
|
1872
|
+
productionServerWithDrizzleTurso: `import { createDevtoolsHooks } from "@beignet/devtools";
|
|
1873
|
+
import { createNextServer } from "@beignet/next";
|
|
1874
|
+
import {
|
|
1875
|
+
createAnonymousActor,
|
|
1876
|
+
createTenant,
|
|
1877
|
+
createUserActor,
|
|
1878
|
+
} from "@beignet/core/ports";
|
|
1879
|
+
import { createDrizzleTursoUnitOfWork } from "@beignet/provider-drizzle-turso";
|
|
1880
|
+
import type { AppContext } from "@/app-context";
|
|
1881
|
+
import { appPorts } from "@/infra/app-ports";
|
|
1882
|
+
import * as schema from "@/infra/db/schema";
|
|
1883
|
+
import { createRepositories } from "@/infra/db/repositories";
|
|
1884
|
+
import { env } from "@/lib/env";
|
|
1885
|
+
import type { AppTransactionPorts } from "@/ports";
|
|
1886
|
+
import { providers } from "./providers";
|
|
1887
|
+
import { routes } from "./routes";
|
|
1888
|
+
|
|
1889
|
+
export const server = await createNextServer({
|
|
1890
|
+
ports: appPorts,
|
|
1891
|
+
providers,
|
|
1892
|
+
hooks: [
|
|
1893
|
+
createDevtoolsHooks<AppContext>({
|
|
1894
|
+
requestIdHeader: "x-request-id",
|
|
1895
|
+
}),
|
|
1896
|
+
],
|
|
1897
|
+
createContext: async ({ req, ports }) => {
|
|
1898
|
+
const auth = await ports.auth.getSession(req);
|
|
1899
|
+
const tenantId = req.headers.get("x-tenant-id") || undefined;
|
|
1900
|
+
const repositories = createRepositories(ports.db.db);
|
|
1901
|
+
|
|
1902
|
+
const context = {
|
|
1903
|
+
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
|
|
1904
|
+
actor: auth
|
|
1905
|
+
? createUserActor(auth.user.id, { displayName: auth.user.name })
|
|
1906
|
+
: createAnonymousActor(),
|
|
1907
|
+
auth,
|
|
1908
|
+
ports: {
|
|
1909
|
+
...ports,
|
|
1910
|
+
...repositories,
|
|
1911
|
+
uow: createDrizzleTursoUnitOfWork<typeof schema, AppTransactionPorts>({
|
|
1912
|
+
db: ports.db.db,
|
|
1913
|
+
createTransactionPorts: (tx) => ({
|
|
1914
|
+
...createRepositories(tx),
|
|
1915
|
+
}),
|
|
1916
|
+
}),
|
|
1917
|
+
},
|
|
1918
|
+
tenant: tenantId ? createTenant(tenantId) : undefined,
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
return {
|
|
1922
|
+
...context,
|
|
1923
|
+
gate: ports.gate.bind(context),
|
|
1924
|
+
};
|
|
1925
|
+
},
|
|
1926
|
+
routes,
|
|
1927
|
+
mapUnhandledError: ({ err, ctx }) => {
|
|
1928
|
+
ctx?.ports.logger.error("Unhandled API error", {
|
|
1929
|
+
error: err,
|
|
1930
|
+
requestId: ctx?.requestId,
|
|
1931
|
+
environment: env.NODE_ENV,
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
return {
|
|
1935
|
+
status: 500,
|
|
1936
|
+
body: {
|
|
1937
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
1938
|
+
message: "Internal server error",
|
|
1939
|
+
requestId: ctx?.requestId,
|
|
1940
|
+
},
|
|
1941
|
+
};
|
|
1942
|
+
},
|
|
1943
|
+
});
|
|
1944
|
+
`,
|
|
1945
|
+
productionApiCatchAllRoute: `import { server } from "@/server";
|
|
1946
|
+
|
|
1947
|
+
export const GET = server.api;
|
|
1948
|
+
export const HEAD = server.api;
|
|
1949
|
+
export const OPTIONS = server.api;
|
|
1950
|
+
export const PATCH = server.api;
|
|
1951
|
+
export const POST = server.api;
|
|
1952
|
+
export const PUT = server.api;
|
|
1953
|
+
export const DELETE = server.api;
|
|
1954
|
+
`,
|
|
1955
|
+
productionHealthRoute: `import { env } from "@/lib/env";
|
|
1956
|
+
|
|
1957
|
+
export function GET() {
|
|
1958
|
+
return Response.json(
|
|
1959
|
+
{
|
|
1960
|
+
status: "ok",
|
|
1961
|
+
environment: env.NODE_ENV,
|
|
1962
|
+
timestamp: new Date().toISOString(),
|
|
1963
|
+
},
|
|
1964
|
+
{
|
|
1965
|
+
headers: {
|
|
1966
|
+
"cache-control": "no-store",
|
|
1967
|
+
},
|
|
1968
|
+
},
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
`,
|
|
1972
|
+
productionDevtoolsRoute: `import { createDevtoolsRoute } from "@beignet/devtools";
|
|
1973
|
+
import { server } from "@/server";
|
|
1974
|
+
|
|
1975
|
+
export const { GET, POST } = createDevtoolsRoute(server.ports.devtools, {
|
|
1976
|
+
basePath: "/api/devtools",
|
|
1977
|
+
});
|
|
1978
|
+
`,
|
|
1979
|
+
productionStorageRoute: `import { createStorageRoute } from "@beignet/next";
|
|
1980
|
+
import { server } from "@/server";
|
|
1981
|
+
|
|
1982
|
+
export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
|
|
1983
|
+
basePath: "/storage",
|
|
1984
|
+
});
|
|
1985
|
+
`,
|
|
1986
|
+
productionOpenApiRoute: `import { createOpenAPIHandler } from "@beignet/next";
|
|
1987
|
+
import { server } from "@/server";
|
|
1988
|
+
|
|
1989
|
+
export const GET = createOpenAPIHandler(server.contracts, {
|
|
1990
|
+
title: "Beignet starter API",
|
|
1991
|
+
version: "0.1.0",
|
|
1992
|
+
});
|
|
1993
|
+
`,
|
|
1994
|
+
};
|
|
1995
|
+
|
|
1996
|
+
function productionProviderPortImports(ctx: TemplateContext): string[] {
|
|
1997
|
+
const imports: string[] = [];
|
|
1998
|
+
if (hasIntegration(ctx, "inngest")) imports.push("\tJobDispatcherPort,");
|
|
1999
|
+
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
2000
|
+
imports.push("\tRateLimitPort,");
|
|
2001
|
+
}
|
|
2002
|
+
return imports;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
function productionProviderPortFields(ctx: TemplateContext): string[] {
|
|
2006
|
+
const fields: string[] = [];
|
|
2007
|
+
if (hasIntegration(ctx, "inngest")) fields.push("\tjobs: JobDispatcherPort;");
|
|
2008
|
+
if (hasIntegration(ctx, "resend")) fields.push("\tmailer: MailerPort;");
|
|
2009
|
+
if (hasIntegration(ctx, "upstash-rate-limit")) {
|
|
2010
|
+
fields.push("\trateLimit: RateLimitPort;");
|
|
2011
|
+
}
|
|
2012
|
+
return fields;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
function productionPorts(ctx: TemplateContext): string {
|
|
2016
|
+
const mailImport = hasIntegration(ctx, "resend")
|
|
2017
|
+
? 'import type { MailerPort } from "@beignet/core/mail";\n'
|
|
2018
|
+
: "";
|
|
2019
|
+
const imports = [
|
|
2020
|
+
"\tBoundGate,",
|
|
2021
|
+
"\tGatePort,",
|
|
2022
|
+
"\tLoggerPort,",
|
|
2023
|
+
...productionProviderPortImports(ctx),
|
|
2024
|
+
"\tUnitOfWorkPort,",
|
|
2025
|
+
];
|
|
2026
|
+
const providerFields = productionProviderPortFields(ctx);
|
|
2027
|
+
|
|
2028
|
+
return `import type {
|
|
2029
|
+
${imports.join("\n")}
|
|
2030
|
+
} from "@beignet/core/ports";
|
|
2031
|
+
${mailImport}import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
|
|
2032
|
+
import type { AuthPort } from "./auth";
|
|
2033
|
+
import type { TodoRepository } from "@/features/todos/ports";
|
|
2034
|
+
|
|
2035
|
+
export type AppTransactionPorts = {
|
|
2036
|
+
todos: TodoRepository;
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
export type AppGate = BoundGate<[typeof todoPolicy]>;
|
|
2040
|
+
|
|
2041
|
+
export type AppPorts = {
|
|
2042
|
+
auth: AuthPort;
|
|
2043
|
+
gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
|
|
2044
|
+
todos: TodoRepository;
|
|
2045
|
+
logger: LoggerPort;
|
|
2046
|
+
${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
|
|
2047
|
+
};
|
|
2048
|
+
`;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function productionPortsWithDrizzleTurso(ctx: TemplateContext): string {
|
|
2052
|
+
const mailImport = hasIntegration(ctx, "resend")
|
|
2053
|
+
? 'import type { MailerPort } from "@beignet/core/mail";\n'
|
|
2054
|
+
: "";
|
|
2055
|
+
const imports = [
|
|
2056
|
+
"\tBoundGate,",
|
|
2057
|
+
"\tGatePort,",
|
|
2058
|
+
"\tLoggerPort,",
|
|
2059
|
+
...productionProviderPortImports(ctx),
|
|
2060
|
+
"\tUnitOfWorkPort,",
|
|
2061
|
+
];
|
|
2062
|
+
const providerFields = productionProviderPortFields(ctx);
|
|
2063
|
+
|
|
2064
|
+
return `import type {
|
|
2065
|
+
${imports.join("\n")}
|
|
2066
|
+
} from "@beignet/core/ports";
|
|
2067
|
+
${mailImport}import type { DbPort } from "@beignet/provider-drizzle-turso";
|
|
2068
|
+
import * as schema from "@/infra/db/schema";
|
|
2069
|
+
import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
|
|
2070
|
+
import type { AuthPort } from "./auth";
|
|
2071
|
+
import type { TodoRepository } from "@/features/todos/ports";
|
|
2072
|
+
|
|
2073
|
+
export type AppTransactionPorts = {
|
|
2074
|
+
todos: TodoRepository;
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
export type AppGate = BoundGate<[typeof todoPolicy]>;
|
|
2078
|
+
|
|
2079
|
+
export type AppPorts = {
|
|
2080
|
+
auth: AuthPort;
|
|
2081
|
+
db: DbPort<typeof schema>;
|
|
2082
|
+
gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
|
|
2083
|
+
todos: TodoRepository;
|
|
2084
|
+
logger: LoggerPort;
|
|
2085
|
+
${providerFields.length > 0 ? `${providerFields.join("\n")}\n` : ""} uow: UnitOfWorkPort<AppTransactionPorts>;
|
|
2086
|
+
};
|
|
2087
|
+
`;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function productionServerProviders(ctx: TemplateContext): string {
|
|
2091
|
+
const imports = [
|
|
2092
|
+
'import { createDevtoolsProvider } from "@beignet/devtools";',
|
|
2093
|
+
hasDrizzleTurso(ctx)
|
|
2094
|
+
? 'import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";'
|
|
2095
|
+
: undefined,
|
|
2096
|
+
hasIntegration(ctx, "inngest")
|
|
2097
|
+
? 'import { inngestProvider } from "@beignet/provider-inngest";'
|
|
2098
|
+
: undefined,
|
|
2099
|
+
'import { loggerPinoProvider } from "@beignet/provider-logger-pino";',
|
|
2100
|
+
hasIntegration(ctx, "resend")
|
|
2101
|
+
? 'import { mailResendProvider } from "@beignet/provider-mail-resend";'
|
|
2102
|
+
: undefined,
|
|
2103
|
+
'import { localStorageProvider } from "@beignet/provider-storage-local";',
|
|
2104
|
+
hasIntegration(ctx, "upstash-rate-limit")
|
|
2105
|
+
? 'import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";'
|
|
2106
|
+
: undefined,
|
|
2107
|
+
hasDrizzleTurso(ctx)
|
|
2108
|
+
? 'import * as schema from "@/infra/db/schema";'
|
|
2109
|
+
: undefined,
|
|
2110
|
+
].filter((line): line is string => Boolean(line));
|
|
2111
|
+
|
|
2112
|
+
const declarations = hasDrizzleTurso(ctx)
|
|
2113
|
+
? "\nconst drizzleTursoProvider = createDrizzleTursoProvider({ schema });\n"
|
|
2114
|
+
: "";
|
|
2115
|
+
const entries = [
|
|
2116
|
+
"\tcreateDevtoolsProvider(),",
|
|
2117
|
+
"\tlocalStorageProvider,",
|
|
2118
|
+
"\tloggerPinoProvider,",
|
|
2119
|
+
hasDrizzleTurso(ctx) ? "\tdrizzleTursoProvider," : undefined,
|
|
2120
|
+
hasIntegration(ctx, "inngest") ? "\tinngestProvider," : undefined,
|
|
2121
|
+
hasIntegration(ctx, "resend") ? "\tmailResendProvider," : undefined,
|
|
2122
|
+
hasIntegration(ctx, "upstash-rate-limit")
|
|
2123
|
+
? "\tupstashRateLimitProvider,"
|
|
2124
|
+
: undefined,
|
|
2125
|
+
].filter((entry): entry is string => Boolean(entry));
|
|
2126
|
+
|
|
2127
|
+
return `${imports.join("\n")}
|
|
2128
|
+
${declarations}
|
|
2129
|
+
export const providers = [
|
|
2130
|
+
${entries.join("\n")}
|
|
2131
|
+
];
|
|
2132
|
+
`;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function productionTodosTest(ctx: TemplateContext): string {
|
|
2136
|
+
const portImports = [
|
|
2137
|
+
hasIntegration(ctx, "upstash-rate-limit")
|
|
2138
|
+
? "createMemoryRateLimiter"
|
|
2139
|
+
: undefined,
|
|
2140
|
+
"createMemoryStorage",
|
|
2141
|
+
"createNoopUnitOfWork",
|
|
2142
|
+
"createUserActor",
|
|
2143
|
+
].filter((item): item is string => Boolean(item));
|
|
2144
|
+
const extraPorts = [
|
|
2145
|
+
hasDrizzleTurso(ctx)
|
|
2146
|
+
? "\t\t\tdb: { db: undefined as never, client: undefined as never },"
|
|
2147
|
+
: undefined,
|
|
2148
|
+
hasIntegration(ctx, "inngest")
|
|
2149
|
+
? "\t\t\tjobs: { dispatch: async () => undefined },"
|
|
2150
|
+
: undefined,
|
|
2151
|
+
hasIntegration(ctx, "resend")
|
|
2152
|
+
? '\t\t\tmailer: { send: async () => ({ provider: "test" }) },'
|
|
2153
|
+
: undefined,
|
|
2154
|
+
hasIntegration(ctx, "upstash-rate-limit")
|
|
2155
|
+
? "\t\t\trateLimit: createMemoryRateLimiter(),"
|
|
2156
|
+
: undefined,
|
|
2157
|
+
].filter((entry): entry is string => Boolean(entry));
|
|
2158
|
+
|
|
2159
|
+
return `import { describe, expect, it } from "bun:test";
|
|
2160
|
+
import { createUseCaseTester } from "@beignet/core/application";
|
|
2161
|
+
import { createInMemoryDevtools } from "@beignet/devtools";
|
|
2162
|
+
import { ${portImports.join(", ")} } from "@beignet/core/ports";
|
|
2163
|
+
import type { AppContext } from "@/app-context";
|
|
2164
|
+
import { appPorts } from "@/infra/app-ports";
|
|
2165
|
+
import {
|
|
2166
|
+
createTodoUseCase,
|
|
2167
|
+
getTodoUseCase,
|
|
2168
|
+
listTodosUseCase,
|
|
2169
|
+
type CreateTodoInput,
|
|
2170
|
+
type ListTodosInput,
|
|
2171
|
+
type Todo,
|
|
2172
|
+
} from "../use-cases";
|
|
2173
|
+
|
|
2174
|
+
function createTestTodoRepository() {
|
|
2175
|
+
const todos = new Map<string, Todo>();
|
|
2176
|
+
|
|
2177
|
+
return {
|
|
2178
|
+
async list(input: ListTodosInput) {
|
|
2179
|
+
const allTodos = Array.from(todos.values()).sort((left, right) =>
|
|
2180
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
2181
|
+
);
|
|
2182
|
+
|
|
2183
|
+
return {
|
|
2184
|
+
todos: allTodos.slice(input.offset, input.offset + input.limit),
|
|
2185
|
+
total: allTodos.length,
|
|
2186
|
+
};
|
|
2187
|
+
},
|
|
2188
|
+
async findById(id: string) {
|
|
2189
|
+
return todos.get(id) ?? null;
|
|
2190
|
+
},
|
|
2191
|
+
async create(input: CreateTodoInput) {
|
|
2192
|
+
const todo: Todo = {
|
|
2193
|
+
id: crypto.randomUUID(),
|
|
2194
|
+
title: input.title,
|
|
2195
|
+
completed: false,
|
|
2196
|
+
createdAt: new Date().toISOString(),
|
|
2197
|
+
};
|
|
2198
|
+
todos.set(todo.id, todo);
|
|
2199
|
+
return todo;
|
|
2200
|
+
},
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
describe("todos resource", () => {
|
|
2205
|
+
it("creates, gets, and lists todos", async () => {
|
|
2206
|
+
const todos = createTestTodoRepository();
|
|
2207
|
+
const auth = {
|
|
2208
|
+
user: {
|
|
2209
|
+
id: "user_test",
|
|
2210
|
+
email: "test@example.com",
|
|
2211
|
+
name: "Test User",
|
|
2212
|
+
},
|
|
2213
|
+
session: { id: "session_test" },
|
|
2214
|
+
};
|
|
2215
|
+
const actor = createUserActor(auth.user.id, {
|
|
2216
|
+
displayName: auth.user.name,
|
|
2217
|
+
});
|
|
2218
|
+
const testPorts = {
|
|
2219
|
+
...appPorts,
|
|
2220
|
+
todos,
|
|
2221
|
+
${extraPorts.length > 0 ? `${extraPorts.join("\n")}\n` : ""} uow: createNoopUnitOfWork(() => ({ todos })) as AppContext["ports"]["uow"],
|
|
2222
|
+
devtools: createInMemoryDevtools(),
|
|
2223
|
+
storage: createMemoryStorage(),
|
|
2224
|
+
};
|
|
2225
|
+
const tester = createUseCaseTester<AppContext>(() => ({
|
|
2226
|
+
requestId: "test-request",
|
|
2227
|
+
actor,
|
|
2228
|
+
auth,
|
|
2229
|
+
gate: testPorts.gate.bind({ actor, auth }),
|
|
2230
|
+
ports: testPorts,
|
|
2231
|
+
}));
|
|
2232
|
+
|
|
2233
|
+
const ctx = await tester.ctx();
|
|
2234
|
+
const created = await tester.run(
|
|
2235
|
+
createTodoUseCase,
|
|
2236
|
+
{ title: "First todo" },
|
|
2237
|
+
{ ctx },
|
|
2238
|
+
);
|
|
2239
|
+
const found = await tester.run(getTodoUseCase, { id: created.id }, { ctx });
|
|
2240
|
+
const result = await tester.run(
|
|
2241
|
+
listTodosUseCase,
|
|
2242
|
+
{ limit: 20, offset: 0 },
|
|
2243
|
+
{ ctx },
|
|
2244
|
+
);
|
|
2245
|
+
|
|
2246
|
+
expect(created.title).toBe("First todo");
|
|
2247
|
+
expect(found.id).toBe(created.id);
|
|
2248
|
+
expect(result.total).toBe(1);
|
|
2249
|
+
expect(result.todos).toEqual([created]);
|
|
2250
|
+
});
|
|
2251
|
+
});
|
|
2252
|
+
`;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
function getProductionTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
2256
|
+
const usesDrizzleTurso = hasDrizzleTurso(ctx);
|
|
2257
|
+
const templateFiles: TemplateFile[] = [
|
|
2258
|
+
{ path: "package.json", content: packageJson(ctx) },
|
|
2259
|
+
{ path: "README.md", content: readme(ctx) },
|
|
2260
|
+
{ path: ".gitignore", content: gitignore(ctx) },
|
|
2261
|
+
{ path: ".env.example", content: envExample(ctx) },
|
|
2262
|
+
{ path: "next-env.d.ts", content: files.nextEnv },
|
|
2263
|
+
{ path: "next.config.js", content: files.nextConfig },
|
|
2264
|
+
{ path: "tsconfig.json", content: files.tsconfig },
|
|
2265
|
+
{ path: "app/globals.css", content: files.globals },
|
|
2266
|
+
{ path: "app/layout.tsx", content: layout(ctx) },
|
|
2267
|
+
{ path: "app/page.tsx", content: page(ctx) },
|
|
2268
|
+
{ path: "app/providers.tsx", content: files.appProviders },
|
|
2269
|
+
{ path: "app-context.ts", content: files.productionAppContext },
|
|
2270
|
+
{
|
|
2271
|
+
path: "app/api/[[...path]]/route.ts",
|
|
2272
|
+
content: files.productionApiCatchAllRoute,
|
|
2273
|
+
},
|
|
2274
|
+
{ path: "app/api/health/route.ts", content: files.productionHealthRoute },
|
|
2275
|
+
{
|
|
2276
|
+
path: "app/api/devtools/[[...path]]/route.ts",
|
|
2277
|
+
content: files.productionDevtoolsRoute,
|
|
2278
|
+
},
|
|
2279
|
+
{
|
|
2280
|
+
path: "app/storage/[...key]/route.ts",
|
|
2281
|
+
content: files.productionStorageRoute,
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
path: "app/api/openapi/route.ts",
|
|
2285
|
+
content: files.productionOpenApiRoute,
|
|
2286
|
+
},
|
|
2287
|
+
{ path: "client/api-client.ts", content: files.apiClient },
|
|
2288
|
+
{ path: "client/rq.ts", content: files.rq },
|
|
2289
|
+
{
|
|
2290
|
+
path: "features/todos/contracts.ts",
|
|
2291
|
+
content: files.productionContractsTodos,
|
|
2292
|
+
},
|
|
2293
|
+
{ path: "features/todos/routes.ts", content: files.productionTodoRoutes },
|
|
2294
|
+
{ path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) },
|
|
2295
|
+
{ path: "infra/logger.ts", content: files.productionLogger },
|
|
2296
|
+
{
|
|
2297
|
+
path: "infra/app-ports.ts",
|
|
2298
|
+
content: usesDrizzleTurso
|
|
2299
|
+
? files.productionInfrastructurePortsWithDrizzleTurso
|
|
2300
|
+
: files.productionInfrastructurePorts,
|
|
2301
|
+
},
|
|
2302
|
+
{ path: "lib/env.ts", content: files.productionEnv },
|
|
2303
|
+
{ path: "lib/auth.ts", content: files.productionAuthHelpers },
|
|
2304
|
+
{ path: "features/todos/policy.ts", content: files.productionTodoPolicy },
|
|
2305
|
+
{
|
|
2306
|
+
path: "infra/auth/anonymous-auth.ts",
|
|
2307
|
+
content: files.productionAnonymousAuth,
|
|
2308
|
+
},
|
|
2309
|
+
{ path: "ports/auth.ts", content: files.productionAuthPort },
|
|
2310
|
+
{
|
|
2311
|
+
path: "ports/index.ts",
|
|
2312
|
+
content: usesDrizzleTurso
|
|
2313
|
+
? productionPortsWithDrizzleTurso(ctx)
|
|
2314
|
+
: productionPorts(ctx),
|
|
2315
|
+
},
|
|
2316
|
+
{
|
|
2317
|
+
path: "features/todos/ports.ts",
|
|
2318
|
+
content: files.productionTodoRepositoryPort,
|
|
2319
|
+
},
|
|
2320
|
+
{
|
|
2321
|
+
path: "features/shared/errors.ts",
|
|
2322
|
+
content: files.productimapUnhandledErrors,
|
|
2323
|
+
},
|
|
2324
|
+
{
|
|
2325
|
+
path: "server/index.ts",
|
|
2326
|
+
content: usesDrizzleTurso
|
|
2327
|
+
? files.productionServerWithDrizzleTurso
|
|
2328
|
+
: files.productionServer,
|
|
2329
|
+
},
|
|
2330
|
+
{ path: "server/routes.ts", content: files.productionServerRoutes },
|
|
2331
|
+
{
|
|
2332
|
+
path: "server/providers.ts",
|
|
2333
|
+
content: productionServerProviders(ctx),
|
|
2334
|
+
},
|
|
2335
|
+
{ path: "lib/use-case.ts", content: files.productionUseCaseBuilder },
|
|
2336
|
+
{
|
|
2337
|
+
path: "features/todos/use-cases/create-todo.ts",
|
|
2338
|
+
content: files.productionCreateTodoUseCase,
|
|
2339
|
+
},
|
|
2340
|
+
{
|
|
2341
|
+
path: "features/todos/use-cases/get-todo.ts",
|
|
2342
|
+
content: files.productionGetTodoUseCase,
|
|
2343
|
+
},
|
|
2344
|
+
{
|
|
2345
|
+
path: "features/todos/use-cases/list-todos.ts",
|
|
2346
|
+
content: files.productionListTodosUseCase,
|
|
2347
|
+
},
|
|
2348
|
+
{
|
|
2349
|
+
path: "features/todos/use-cases/index.ts",
|
|
2350
|
+
content: files.productionUseCasesIndex,
|
|
2351
|
+
},
|
|
2352
|
+
{
|
|
2353
|
+
path: "features/todos/use-cases/schemas.ts",
|
|
2354
|
+
content: files.productionTodoSchemas,
|
|
2355
|
+
},
|
|
2356
|
+
{
|
|
2357
|
+
path: "features/todos/tests/todos.test.ts",
|
|
2358
|
+
content: productionTodosTest(ctx),
|
|
2359
|
+
},
|
|
2360
|
+
{ path: "docs/integrations.md", content: integrationsDoc(ctx) },
|
|
2361
|
+
];
|
|
2362
|
+
|
|
2363
|
+
if (usesDrizzleTurso) {
|
|
2364
|
+
templateFiles.push(
|
|
2365
|
+
{ path: "drizzle.config.ts", content: files.productionDrizzleConfig },
|
|
2366
|
+
{
|
|
2367
|
+
path: "infra/db/schema/index.ts",
|
|
2368
|
+
content: files.productionDbSchema,
|
|
2369
|
+
},
|
|
2370
|
+
{
|
|
2371
|
+
path: "infra/db/repositories.ts",
|
|
2372
|
+
content: files.productionDbRepositories,
|
|
2373
|
+
},
|
|
2374
|
+
{
|
|
2375
|
+
path: "infra/todos/drizzle-todo-repository.ts",
|
|
2376
|
+
content: files.productionDrizzleTodoRepository,
|
|
2377
|
+
},
|
|
2378
|
+
);
|
|
2379
|
+
} else {
|
|
2380
|
+
templateFiles.push({
|
|
2381
|
+
path: "infra/todos/in-memory-todo-repository.ts",
|
|
2382
|
+
content: files.productionInMemoryTodoRepository,
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
return templateFiles;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
2390
|
+
if (isStandardPreset(ctx)) {
|
|
2391
|
+
return getProductionTemplateFiles(ctx);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
const templateFiles = [
|
|
2395
|
+
{ path: "package.json", content: packageJson(ctx) },
|
|
2396
|
+
{ path: "README.md", content: readme(ctx) },
|
|
2397
|
+
{ path: ".gitignore", content: gitignore(ctx) },
|
|
2398
|
+
{ path: "next-env.d.ts", content: files.nextEnv },
|
|
2399
|
+
{ path: "next.config.js", content: files.nextConfig },
|
|
2400
|
+
{ path: "tsconfig.json", content: files.tsconfig },
|
|
2401
|
+
{ path: "app/globals.css", content: files.globals },
|
|
2402
|
+
{ path: "app/layout.tsx", content: layout(ctx) },
|
|
2403
|
+
{ path: "app/page.tsx", content: page(ctx) },
|
|
2404
|
+
{ path: "app/api/[[...path]]/route.ts", content: files.apiCatchAllRoute },
|
|
2405
|
+
{ path: "features/todos/contracts.ts", content: files.contractsTodos },
|
|
2406
|
+
{ path: "features/todos/routes.ts", content: files.todoRoutes },
|
|
2407
|
+
{ path: "ports/index.ts", content: files.ports },
|
|
2408
|
+
{ path: "server/index.ts", content: server(ctx) },
|
|
2409
|
+
{ path: "server/routes.ts", content: files.serverRoutes },
|
|
2410
|
+
{ path: "features/todos/use-cases.ts", content: files.useCasesTodos },
|
|
2411
|
+
];
|
|
2412
|
+
|
|
2413
|
+
if (hasFeature(ctx, "client")) {
|
|
2414
|
+
templateFiles.push({
|
|
2415
|
+
path: "client/api-client.ts",
|
|
2416
|
+
content: files.apiClient,
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
if (hasFeature(ctx, "react-query")) {
|
|
2421
|
+
templateFiles.push(
|
|
2422
|
+
{ path: "app/providers.tsx", content: files.appProviders },
|
|
2423
|
+
{ path: "client/rq.ts", content: files.rq },
|
|
2424
|
+
{ path: "features/todos/components/todo-app.tsx", content: todoApp(ctx) },
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
if (hasFeature(ctx, "openapi")) {
|
|
2429
|
+
templateFiles.push({
|
|
2430
|
+
path: "app/api/openapi/route.ts",
|
|
2431
|
+
content: files.apiOpenApiRoute,
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
if (
|
|
2436
|
+
ctx.integrations.some((integration) =>
|
|
2437
|
+
["pino", "inngest", "resend", "upstash-rate-limit"].includes(integration),
|
|
2438
|
+
)
|
|
2439
|
+
) {
|
|
2440
|
+
templateFiles.push({
|
|
2441
|
+
path: "server/providers.ts",
|
|
2442
|
+
content: serverProviders(ctx),
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
if (ctx.integrations.length > 0) {
|
|
2447
|
+
templateFiles.push(
|
|
2448
|
+
{
|
|
2449
|
+
path: ".env.example",
|
|
2450
|
+
content: envExample(ctx),
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
path: "docs/integrations.md",
|
|
2454
|
+
content: integrationsDoc(ctx),
|
|
2455
|
+
},
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
return templateFiles;
|
|
2460
|
+
}
|