kavoru 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/features.ts +663 -647
package/package.json
CHANGED
package/src/features.ts
CHANGED
|
@@ -1,647 +1,663 @@
|
|
|
1
|
-
import { rm } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { log } from "./log";
|
|
4
|
-
|
|
5
|
-
export type FeatureId =
|
|
6
|
-
| "auth"
|
|
7
|
-
| "prisma"
|
|
8
|
-
| "otel"
|
|
9
|
-
| "sentry"
|
|
10
|
-
| "kafka"
|
|
11
|
-
| "websocket"
|
|
12
|
-
| "resend"
|
|
13
|
-
| "cron"
|
|
14
|
-
| "docker";
|
|
15
|
-
|
|
16
|
-
export type FeatureSelection = Record<FeatureId, boolean>;
|
|
17
|
-
|
|
18
|
-
export type FeatureDef = {
|
|
19
|
-
id: FeatureId;
|
|
20
|
-
label: string;
|
|
21
|
-
description: string;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export const FEATURES: FeatureDef[] = [
|
|
25
|
-
{
|
|
26
|
-
id: "auth",
|
|
27
|
-
label: "JWT Authentication",
|
|
28
|
-
description: "Bearer auth, sign-in route, protected routes",
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
id: "prisma",
|
|
32
|
-
label: "Prisma + PostgreSQL",
|
|
33
|
-
description: "Prisma 7 config, migrations, seed scripts",
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: "otel",
|
|
37
|
-
label: "OpenTelemetry",
|
|
38
|
-
description: "OTLP tracing with Bun-compatible exporter",
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
id: "sentry",
|
|
42
|
-
label: "Sentry + Spotlight",
|
|
43
|
-
description: "Error monitoring and local Spotlight UI",
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: "kafka",
|
|
47
|
-
label: "Kafka",
|
|
48
|
-
description: "Producer, consumer, and example HTTP endpoints",
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
id: "websocket",
|
|
52
|
-
label: "WebSockets",
|
|
53
|
-
description: "Validated real-time connections with rooms",
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
id: "resend",
|
|
57
|
-
label: "Resend Email",
|
|
58
|
-
description: "Transactional email via sendEmail()",
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
id: "cron",
|
|
62
|
-
label: "Cron Jobs",
|
|
63
|
-
description: "Scheduled tasks via @elysiajs/cron",
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
id: "docker",
|
|
67
|
-
label: "Docker",
|
|
68
|
-
description: "Dockerfile and Docker Compose stack",
|
|
69
|
-
},
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
export const FEATURE_IDS = FEATURES.map((feature) => feature.id);
|
|
73
|
-
|
|
74
|
-
export const ALL_FEATURES: FeatureSelection = Object.fromEntries(
|
|
75
|
-
FEATURE_IDS.map((id) => [id, true]),
|
|
76
|
-
) as FeatureSelection;
|
|
77
|
-
|
|
78
|
-
export const MINIMAL_FEATURES: FeatureSelection = Object.fromEntries(
|
|
79
|
-
FEATURE_IDS.map((id) => [id, false]),
|
|
80
|
-
) as FeatureSelection;
|
|
81
|
-
|
|
82
|
-
const FEATURE_PATHS: Record<FeatureId, string[]> = {
|
|
83
|
-
auth: [
|
|
84
|
-
"src/modules/signin",
|
|
85
|
-
"src/modules/protected",
|
|
86
|
-
"src/middleware/auth.ts",
|
|
87
|
-
"src/infra/auth",
|
|
88
|
-
"src/constants/jwt.ts",
|
|
89
|
-
"src/models/schemas/signin.ts",
|
|
90
|
-
],
|
|
91
|
-
prisma: ["prisma.config.ts", "src/infra/prisma"],
|
|
92
|
-
otel: ["src/infra/telemetry"],
|
|
93
|
-
sentry: [
|
|
94
|
-
"src/infra/sentry",
|
|
95
|
-
"src/constants/sentry.ts",
|
|
96
|
-
"__tests__/sentry.test.ts",
|
|
97
|
-
],
|
|
98
|
-
kafka: [
|
|
99
|
-
"src/modules/kafka",
|
|
100
|
-
"src/infra/kafka",
|
|
101
|
-
"src/models/schemas/kafka.ts",
|
|
102
|
-
"__tests__/kafka.test.ts",
|
|
103
|
-
],
|
|
104
|
-
websocket: [
|
|
105
|
-
"src/modules/realtime",
|
|
106
|
-
"src/models/schemas/realtime.ts",
|
|
107
|
-
"__tests__/realtime.test.ts",
|
|
108
|
-
],
|
|
109
|
-
resend: ["src/infra/resend"],
|
|
110
|
-
cron: ["src/schedules"],
|
|
111
|
-
docker: ["Dockerfile", "docker-compose.yaml"],
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
const FEATURE_DEPENDENCIES: Partial<
|
|
115
|
-
Record<FeatureId, { dependencies?: string[]; devDependencies?: string[] }>
|
|
116
|
-
> = {
|
|
117
|
-
auth: { dependencies: ["@elysiajs/bearer", "@elysiajs/jwt"] },
|
|
118
|
-
prisma: {
|
|
119
|
-
dependencies: ["@prisma/adapter-pg", "@prisma/client"],
|
|
120
|
-
devDependencies: ["prisma"],
|
|
121
|
-
},
|
|
122
|
-
otel: {
|
|
123
|
-
dependencies: [
|
|
124
|
-
"@elysiajs/opentelemetry",
|
|
125
|
-
"@opentelemetry/exporter-trace-otlp-http",
|
|
126
|
-
"@opentelemetry/otlp-transformer",
|
|
127
|
-
"@opentelemetry/sdk-trace-node",
|
|
128
|
-
],
|
|
129
|
-
},
|
|
130
|
-
sentry: { dependencies: ["@sentry/elysia"] },
|
|
131
|
-
kafka: { dependencies: ["kafkajs"] },
|
|
132
|
-
resend: { dependencies: ["resend"] },
|
|
133
|
-
cron: { dependencies: ["@elysiajs/cron"] },
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const FEATURE_SCRIPTS: Partial<Record<FeatureId, string[]>> = {
|
|
137
|
-
otel: ["otel:view", "otel:tui"],
|
|
138
|
-
sentry: ["sentry:spotlight"],
|
|
139
|
-
prisma: ["seed"],
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
function enabledFeatures(selection: FeatureSelection): FeatureId[] {
|
|
143
|
-
return FEATURE_IDS.filter((id) => selection[id]);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function disabledFeatures(selection: FeatureSelection): FeatureId[] {
|
|
147
|
-
return FEATURE_IDS.filter((id) => !selection[id]);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function parseFeatureIncludeList(input: string): FeatureSelection {
|
|
151
|
-
const normalized = input.trim().toLowerCase();
|
|
152
|
-
if (!normalized || normalized === "all") return { ...ALL_FEATURES };
|
|
153
|
-
if (normalized === "minimal" || normalized === "none") {
|
|
154
|
-
return { ...MINIMAL_FEATURES };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const requested = normalized
|
|
158
|
-
.split(",")
|
|
159
|
-
.map((part) => part.trim())
|
|
160
|
-
.filter(Boolean);
|
|
161
|
-
|
|
162
|
-
const unknown = requested.filter(
|
|
163
|
-
(id) => !FEATURE_IDS.includes(id as FeatureId),
|
|
164
|
-
);
|
|
165
|
-
if (unknown.length > 0) {
|
|
166
|
-
throw new Error(
|
|
167
|
-
`Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const selection = { ...MINIMAL_FEATURES };
|
|
172
|
-
for (const id of requested) {
|
|
173
|
-
selection[id as FeatureId] = true;
|
|
174
|
-
}
|
|
175
|
-
return selection;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function parseFeatureExcludeList(
|
|
179
|
-
excluded: string[],
|
|
180
|
-
base: FeatureSelection = ALL_FEATURES,
|
|
181
|
-
): FeatureSelection {
|
|
182
|
-
const selection = { ...base };
|
|
183
|
-
const unknown: string[] = [];
|
|
184
|
-
|
|
185
|
-
for (const raw of excluded) {
|
|
186
|
-
const id = raw.trim().toLowerCase();
|
|
187
|
-
if (!id) continue;
|
|
188
|
-
if (!FEATURE_IDS.includes(id as FeatureId)) {
|
|
189
|
-
unknown.push(id);
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
selection[id as FeatureId] = false;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (unknown.length > 0) {
|
|
196
|
-
throw new Error(
|
|
197
|
-
`Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return selection;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function formatFeatureSelection(selection: FeatureSelection): string {
|
|
205
|
-
const enabled = enabledFeatures(selection);
|
|
206
|
-
if (enabled.length === 0) return "core only";
|
|
207
|
-
return enabled.join(", ");
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async function removePaths(projectDir: string, relativePaths: string[]) {
|
|
211
|
-
for (const relativePath of relativePaths) {
|
|
212
|
-
await rm(path.join(projectDir, relativePath), {
|
|
213
|
-
recursive: true,
|
|
214
|
-
force: true,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async function readText(projectDir: string, relativePath: string) {
|
|
220
|
-
const file = Bun.file(path.join(projectDir, relativePath));
|
|
221
|
-
if (!(await file.exists())) return null;
|
|
222
|
-
return file.text();
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function writeText(
|
|
226
|
-
projectDir: string,
|
|
227
|
-
relativePath: string,
|
|
228
|
-
content: string,
|
|
229
|
-
) {
|
|
230
|
-
await Bun.write(path.join(projectDir, relativePath), content);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function removeImportLines(content: string, modules: string[]) {
|
|
234
|
-
let next = content;
|
|
235
|
-
for (const modulePath of modules) {
|
|
236
|
-
const escaped = modulePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
237
|
-
next = next.replace(
|
|
238
|
-
new RegExp(`^import .* from ["']${escaped}["'];?\\r?\\n`, "gm"),
|
|
239
|
-
"",
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
return next;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function removeUseLines(content: string, identifiers: string[]) {
|
|
246
|
-
let next = content;
|
|
247
|
-
for (const identifier of identifiers) {
|
|
248
|
-
next = next.replace(
|
|
249
|
-
new RegExp(`^\\s*\\.use\\(${identifier}\\)\\r?\\n`, "gm"),
|
|
250
|
-
"",
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
return next;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async function patchModulesIndex(
|
|
257
|
-
projectDir: string,
|
|
258
|
-
selection: FeatureSelection,
|
|
259
|
-
) {
|
|
260
|
-
const relativePath = "src/modules/index.ts";
|
|
261
|
-
const current = await readText(projectDir, relativePath);
|
|
262
|
-
if (!current) return;
|
|
263
|
-
|
|
264
|
-
let content = current;
|
|
265
|
-
if (!selection.sentry) {
|
|
266
|
-
content = removeImportLines(content, ["../infra/sentry"]);
|
|
267
|
-
content = removeUseLines(content, ["withSentry"]);
|
|
268
|
-
}
|
|
269
|
-
if (!selection.otel) {
|
|
270
|
-
content = removeImportLines(content, ["../infra/telemetry"]);
|
|
271
|
-
content = removeUseLines(content, ["withOpenTelemetry"]);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
content = content.replace(/app\s+\.use\(/g, "app\n .use(");
|
|
275
|
-
|
|
276
|
-
await writeText(projectDir, relativePath, content);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async function patchEntryIndex(projectDir: string, selection: FeatureSelection) {
|
|
280
|
-
const imports = [
|
|
281
|
-
selection.sentry
|
|
282
|
-
? 'import { initSentry, flushSentry } from "./infra/sentry";'
|
|
283
|
-
: null,
|
|
284
|
-
selection.kafka
|
|
285
|
-
? 'import { startKafka, stopKafka } from "./infra/kafka";'
|
|
286
|
-
: null,
|
|
287
|
-
'import { HttpServer } from "./server/index";',
|
|
288
|
-
'import { logger } from "./common/logger";',
|
|
289
|
-
selection.kafka ? 'import { InternalServerError } from "elysia";' : null,
|
|
290
|
-
].filter(Boolean) as string[];
|
|
291
|
-
|
|
292
|
-
const body: string[] = [];
|
|
293
|
-
|
|
294
|
-
if (selection.sentry) {
|
|
295
|
-
body.push("", "initSentry();");
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
body.push("", "const server = new HttpServer();", "");
|
|
299
|
-
|
|
300
|
-
if (selection.kafka) {
|
|
301
|
-
body.push(
|
|
302
|
-
"void server.start().then(async () => {",
|
|
303
|
-
" try {",
|
|
304
|
-
" await startKafka();",
|
|
305
|
-
" } catch (error) {",
|
|
306
|
-
' logger.error("Failed to start Kafka", { error });',
|
|
307
|
-
' throw new InternalServerError("Failed to start Kafka");',
|
|
308
|
-
" }",
|
|
309
|
-
"});",
|
|
310
|
-
);
|
|
311
|
-
} else {
|
|
312
|
-
body.push("void server.start();");
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
body.push(
|
|
316
|
-
"",
|
|
317
|
-
"const shutdown = (signal: string) => {",
|
|
318
|
-
" return async () => {",
|
|
319
|
-
" logger.warn(`Received ${signal}, shutting down`);",
|
|
320
|
-
" await server.stop();",
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
if (selection.kafka) {
|
|
324
|
-
body.push(" await stopKafka();");
|
|
325
|
-
}
|
|
326
|
-
if (selection.sentry) {
|
|
327
|
-
body.push(" await flushSentry();");
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
body.push(
|
|
331
|
-
" process.exit(0);",
|
|
332
|
-
" };",
|
|
333
|
-
"};",
|
|
334
|
-
"",
|
|
335
|
-
'process.on("SIGINT", () => void shutdown("SIGINT")());',
|
|
336
|
-
'process.on("SIGTERM", () => void shutdown("SIGTERM")());',
|
|
337
|
-
"",
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
await writeText(projectDir, "src/index.ts", [...imports, ...body].join("\n"));
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async function patchServerIndex(projectDir: string, selection: FeatureSelection) {
|
|
344
|
-
const relativePath = "src/server/index.ts";
|
|
345
|
-
const current = await readText(projectDir, relativePath);
|
|
346
|
-
if (!current) return;
|
|
347
|
-
|
|
348
|
-
let content = current;
|
|
349
|
-
|
|
350
|
-
if (selection.cron) {
|
|
351
|
-
content = content.replace(
|
|
352
|
-
/\/\/import \{ schedules \} from "\.\.\/schedules";.*\n/,
|
|
353
|
-
'import { schedules } from "../schedules";\n',
|
|
354
|
-
);
|
|
355
|
-
content = content.replace(
|
|
356
|
-
/\/\/\.use\(schedules\);.*\n/,
|
|
357
|
-
" .use(schedules);\n",
|
|
358
|
-
);
|
|
359
|
-
} else {
|
|
360
|
-
content = content.replace(
|
|
361
|
-
/^import \{ schedules \} from "\.\.\/schedules";\n/m,
|
|
362
|
-
"",
|
|
363
|
-
);
|
|
364
|
-
content = content.replace(/^\s*\.use\(schedules\);\n/m, "");
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (!selection.websocket) {
|
|
368
|
-
content = content.replace(
|
|
369
|
-
/new Elysia\(\{\s*websocket:\s*\{\s*idleTimeout:\s*120,\s*\},\s*\}\)/,
|
|
370
|
-
"new Elysia()",
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
await writeText(projectDir, relativePath, content);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
async function patchPackageJson(
|
|
378
|
-
projectDir: string,
|
|
379
|
-
selection: FeatureSelection,
|
|
380
|
-
) {
|
|
381
|
-
const pkgPath = path.join(projectDir, "package.json");
|
|
382
|
-
const pkgFile = Bun.file(pkgPath);
|
|
383
|
-
if (!(await pkgFile.exists())) return;
|
|
384
|
-
|
|
385
|
-
const pkg = (await pkgFile.json()) as {
|
|
386
|
-
dependencies?: Record<string, string>;
|
|
387
|
-
devDependencies?: Record<string, string>;
|
|
388
|
-
scripts?: Record<string, string>;
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
for (const featureId of disabledFeatures(selection)) {
|
|
392
|
-
const deps = FEATURE_DEPENDENCIES[featureId];
|
|
393
|
-
if (deps?.dependencies) {
|
|
394
|
-
for (const name of deps.dependencies) {
|
|
395
|
-
delete pkg.dependencies?.[name];
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
if (deps?.devDependencies) {
|
|
399
|
-
for (const name of deps.devDependencies) {
|
|
400
|
-
delete pkg.devDependencies?.[name];
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const scripts = FEATURE_SCRIPTS[featureId];
|
|
405
|
-
if (scripts) {
|
|
406
|
-
for (const scriptName of scripts) {
|
|
407
|
-
delete pkg.scripts?.[scriptName];
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (!selection.prisma && pkg.scripts) {
|
|
413
|
-
pkg.scripts.start = "bun run src/index.ts";
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export function buildEnvExample(
|
|
420
|
-
packageName: string,
|
|
421
|
-
selection: FeatureSelection,
|
|
422
|
-
): string {
|
|
423
|
-
const lines = ["NODE_ENV=development", "PORT=3131", ""];
|
|
424
|
-
|
|
425
|
-
if (selection.prisma) {
|
|
426
|
-
lines.push(
|
|
427
|
-
"DATABASE_URL=postgresql://user:password@localhost:5432/your_database",
|
|
428
|
-
"",
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (selection.otel) {
|
|
433
|
-
lines.push(
|
|
434
|
-
"# OpenTelemetry (optional in production; enabled by default in development)",
|
|
435
|
-
"# Terminal 1: bun run otel:view",
|
|
436
|
-
"# Terminal 2: bun run dev",
|
|
437
|
-
"# Disable locally: OTEL_EXPORTER_OTLP_ENDPOINT=",
|
|
438
|
-
"OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces",
|
|
439
|
-
`OTEL_SERVICE_NAME=${packageName}`,
|
|
440
|
-
"",
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (selection.sentry) {
|
|
445
|
-
lines.push(
|
|
446
|
-
"# Sentry Spotlight (local UI — enabled by default in development)",
|
|
447
|
-
"# Terminal 1: bun run sentry:spotlight",
|
|
448
|
-
"# Terminal 2: bun run dev",
|
|
449
|
-
"# Disable locally: SENTRY_SPOTLIGHT=false",
|
|
450
|
-
"SENTRY_SPOTLIGHT=true",
|
|
451
|
-
"",
|
|
452
|
-
"# Sentry cloud (optional — events also go to sentry.io when set)",
|
|
453
|
-
"# SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0",
|
|
454
|
-
"# SENTRY_TRACES_SAMPLE_RATE=1.0",
|
|
455
|
-
"",
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (selection.kafka) {
|
|
460
|
-
lines.push(
|
|
461
|
-
"# Kafka (enabled by default in development; disabled in test)",
|
|
462
|
-
"# Start broker: docker compose up -d kafka",
|
|
463
|
-
"# Host dev uses EXTERNAL listener on port 9094",
|
|
464
|
-
"# KAFKA_ENABLED=false",
|
|
465
|
-
"KAFKA_BROKERS=localhost:9094",
|
|
466
|
-
`KAFKA_CLIENT_ID=${packageName}`,
|
|
467
|
-
`KAFKA_GROUP_ID=${packageName}-consumer`,
|
|
468
|
-
"KAFKA_TOPIC=elysia.events",
|
|
469
|
-
"",
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (selection.resend) {
|
|
474
|
-
lines.push(
|
|
475
|
-
"# Resend (disabled when RESEND_API_KEY is unset; always disabled in test)",
|
|
476
|
-
"# Create an API key: https://resend.com/api-keys",
|
|
477
|
-
"# RESEND_ENABLED=false",
|
|
478
|
-
"# RESEND_API_KEY=re_xxxxxxxx",
|
|
479
|
-
"# RESEND_FROM=Acme <onboarding@resend.dev>",
|
|
480
|
-
"",
|
|
481
|
-
);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
return `${lines.join("\n").trimEnd()}\n`;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
async function patchEnvExample(
|
|
488
|
-
projectDir: string,
|
|
489
|
-
selection: FeatureSelection,
|
|
490
|
-
packageName = "kavoru",
|
|
491
|
-
) {
|
|
492
|
-
const content = buildEnvExample(packageName, selection);
|
|
493
|
-
await writeText(projectDir, ".env.example", content);
|
|
494
|
-
await writeText(projectDir, ".env", content);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
async function patchDockerfile(projectDir: string, selection: FeatureSelection) {
|
|
498
|
-
if (!selection.docker) return;
|
|
499
|
-
|
|
500
|
-
const relativePath = "Dockerfile";
|
|
501
|
-
const current = await readText(projectDir, relativePath);
|
|
502
|
-
if (!current) return;
|
|
503
|
-
|
|
504
|
-
let content = current;
|
|
505
|
-
if (!selection.prisma) {
|
|
506
|
-
content = content.replace(/^\s*COPY prisma\.config\.ts \.\/.*\n/m, "");
|
|
507
|
-
content = content.replace(
|
|
508
|
-
/^\s*# generate only needs the schema on disk, not a live database\n/m,
|
|
509
|
-
"",
|
|
510
|
-
);
|
|
511
|
-
content = content.replace(
|
|
512
|
-
/^\s*RUN if \[ -f src\/infra\/prisma\/schemas\/schema\.prisma \]; then bunx prisma generate; fi\n/m,
|
|
513
|
-
"",
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
await writeText(projectDir, relativePath, content);
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function buildAppEnvironment(selection: FeatureSelection): string {
|
|
521
|
-
const lines = [" NODE_ENV: production"];
|
|
522
|
-
if (selection.kafka) {
|
|
523
|
-
lines.push(" KAFKA_BROKERS: kafka:9092");
|
|
524
|
-
}
|
|
525
|
-
if (selection.otel) {
|
|
526
|
-
lines.push(" OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318/v1/traces");
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
:
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
:
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
:
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
-
|
|
592
|
-
networks:
|
|
593
|
-
app_network
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
await
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { log } from "./log";
|
|
4
|
+
|
|
5
|
+
export type FeatureId =
|
|
6
|
+
| "auth"
|
|
7
|
+
| "prisma"
|
|
8
|
+
| "otel"
|
|
9
|
+
| "sentry"
|
|
10
|
+
| "kafka"
|
|
11
|
+
| "websocket"
|
|
12
|
+
| "resend"
|
|
13
|
+
| "cron"
|
|
14
|
+
| "docker";
|
|
15
|
+
|
|
16
|
+
export type FeatureSelection = Record<FeatureId, boolean>;
|
|
17
|
+
|
|
18
|
+
export type FeatureDef = {
|
|
19
|
+
id: FeatureId;
|
|
20
|
+
label: string;
|
|
21
|
+
description: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const FEATURES: FeatureDef[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "auth",
|
|
27
|
+
label: "JWT Authentication",
|
|
28
|
+
description: "Bearer auth, sign-in route, protected routes",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "prisma",
|
|
32
|
+
label: "Prisma + PostgreSQL",
|
|
33
|
+
description: "Prisma 7 config, migrations, seed scripts",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "otel",
|
|
37
|
+
label: "OpenTelemetry",
|
|
38
|
+
description: "OTLP tracing with Bun-compatible exporter",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "sentry",
|
|
42
|
+
label: "Sentry + Spotlight",
|
|
43
|
+
description: "Error monitoring and local Spotlight UI",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "kafka",
|
|
47
|
+
label: "Kafka",
|
|
48
|
+
description: "Producer, consumer, and example HTTP endpoints",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "websocket",
|
|
52
|
+
label: "WebSockets",
|
|
53
|
+
description: "Validated real-time connections with rooms",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "resend",
|
|
57
|
+
label: "Resend Email",
|
|
58
|
+
description: "Transactional email via sendEmail()",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "cron",
|
|
62
|
+
label: "Cron Jobs",
|
|
63
|
+
description: "Scheduled tasks via @elysiajs/cron",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "docker",
|
|
67
|
+
label: "Docker",
|
|
68
|
+
description: "Dockerfile and Docker Compose stack",
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const FEATURE_IDS = FEATURES.map((feature) => feature.id);
|
|
73
|
+
|
|
74
|
+
export const ALL_FEATURES: FeatureSelection = Object.fromEntries(
|
|
75
|
+
FEATURE_IDS.map((id) => [id, true]),
|
|
76
|
+
) as FeatureSelection;
|
|
77
|
+
|
|
78
|
+
export const MINIMAL_FEATURES: FeatureSelection = Object.fromEntries(
|
|
79
|
+
FEATURE_IDS.map((id) => [id, false]),
|
|
80
|
+
) as FeatureSelection;
|
|
81
|
+
|
|
82
|
+
const FEATURE_PATHS: Record<FeatureId, string[]> = {
|
|
83
|
+
auth: [
|
|
84
|
+
"src/modules/signin",
|
|
85
|
+
"src/modules/protected",
|
|
86
|
+
"src/middleware/auth.ts",
|
|
87
|
+
"src/infra/auth",
|
|
88
|
+
"src/constants/jwt.ts",
|
|
89
|
+
"src/models/schemas/signin.ts",
|
|
90
|
+
],
|
|
91
|
+
prisma: ["prisma.config.ts", "src/infra/prisma"],
|
|
92
|
+
otel: ["src/infra/telemetry"],
|
|
93
|
+
sentry: [
|
|
94
|
+
"src/infra/sentry",
|
|
95
|
+
"src/constants/sentry.ts",
|
|
96
|
+
"__tests__/sentry.test.ts",
|
|
97
|
+
],
|
|
98
|
+
kafka: [
|
|
99
|
+
"src/modules/kafka",
|
|
100
|
+
"src/infra/kafka",
|
|
101
|
+
"src/models/schemas/kafka.ts",
|
|
102
|
+
"__tests__/kafka.test.ts",
|
|
103
|
+
],
|
|
104
|
+
websocket: [
|
|
105
|
+
"src/modules/realtime",
|
|
106
|
+
"src/models/schemas/realtime.ts",
|
|
107
|
+
"__tests__/realtime.test.ts",
|
|
108
|
+
],
|
|
109
|
+
resend: ["src/infra/resend"],
|
|
110
|
+
cron: ["src/schedules"],
|
|
111
|
+
docker: ["Dockerfile", "docker-compose.yaml"],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const FEATURE_DEPENDENCIES: Partial<
|
|
115
|
+
Record<FeatureId, { dependencies?: string[]; devDependencies?: string[] }>
|
|
116
|
+
> = {
|
|
117
|
+
auth: { dependencies: ["@elysiajs/bearer", "@elysiajs/jwt"] },
|
|
118
|
+
prisma: {
|
|
119
|
+
dependencies: ["@prisma/adapter-pg", "@prisma/client"],
|
|
120
|
+
devDependencies: ["prisma"],
|
|
121
|
+
},
|
|
122
|
+
otel: {
|
|
123
|
+
dependencies: [
|
|
124
|
+
"@elysiajs/opentelemetry",
|
|
125
|
+
"@opentelemetry/exporter-trace-otlp-http",
|
|
126
|
+
"@opentelemetry/otlp-transformer",
|
|
127
|
+
"@opentelemetry/sdk-trace-node",
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
sentry: { dependencies: ["@sentry/elysia"] },
|
|
131
|
+
kafka: { dependencies: ["kafkajs"] },
|
|
132
|
+
resend: { dependencies: ["resend"] },
|
|
133
|
+
cron: { dependencies: ["@elysiajs/cron"] },
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const FEATURE_SCRIPTS: Partial<Record<FeatureId, string[]>> = {
|
|
137
|
+
otel: ["otel:view", "otel:tui"],
|
|
138
|
+
sentry: ["sentry:spotlight"],
|
|
139
|
+
prisma: ["seed"],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function enabledFeatures(selection: FeatureSelection): FeatureId[] {
|
|
143
|
+
return FEATURE_IDS.filter((id) => selection[id]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function disabledFeatures(selection: FeatureSelection): FeatureId[] {
|
|
147
|
+
return FEATURE_IDS.filter((id) => !selection[id]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseFeatureIncludeList(input: string): FeatureSelection {
|
|
151
|
+
const normalized = input.trim().toLowerCase();
|
|
152
|
+
if (!normalized || normalized === "all") return { ...ALL_FEATURES };
|
|
153
|
+
if (normalized === "minimal" || normalized === "none") {
|
|
154
|
+
return { ...MINIMAL_FEATURES };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const requested = normalized
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((part) => part.trim())
|
|
160
|
+
.filter(Boolean);
|
|
161
|
+
|
|
162
|
+
const unknown = requested.filter(
|
|
163
|
+
(id) => !FEATURE_IDS.includes(id as FeatureId),
|
|
164
|
+
);
|
|
165
|
+
if (unknown.length > 0) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const selection = { ...MINIMAL_FEATURES };
|
|
172
|
+
for (const id of requested) {
|
|
173
|
+
selection[id as FeatureId] = true;
|
|
174
|
+
}
|
|
175
|
+
return selection;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function parseFeatureExcludeList(
|
|
179
|
+
excluded: string[],
|
|
180
|
+
base: FeatureSelection = ALL_FEATURES,
|
|
181
|
+
): FeatureSelection {
|
|
182
|
+
const selection = { ...base };
|
|
183
|
+
const unknown: string[] = [];
|
|
184
|
+
|
|
185
|
+
for (const raw of excluded) {
|
|
186
|
+
const id = raw.trim().toLowerCase();
|
|
187
|
+
if (!id) continue;
|
|
188
|
+
if (!FEATURE_IDS.includes(id as FeatureId)) {
|
|
189
|
+
unknown.push(id);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
selection[id as FeatureId] = false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (unknown.length > 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return selection;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function formatFeatureSelection(selection: FeatureSelection): string {
|
|
205
|
+
const enabled = enabledFeatures(selection);
|
|
206
|
+
if (enabled.length === 0) return "core only";
|
|
207
|
+
return enabled.join(", ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function removePaths(projectDir: string, relativePaths: string[]) {
|
|
211
|
+
for (const relativePath of relativePaths) {
|
|
212
|
+
await rm(path.join(projectDir, relativePath), {
|
|
213
|
+
recursive: true,
|
|
214
|
+
force: true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function readText(projectDir: string, relativePath: string) {
|
|
220
|
+
const file = Bun.file(path.join(projectDir, relativePath));
|
|
221
|
+
if (!(await file.exists())) return null;
|
|
222
|
+
return file.text();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function writeText(
|
|
226
|
+
projectDir: string,
|
|
227
|
+
relativePath: string,
|
|
228
|
+
content: string,
|
|
229
|
+
) {
|
|
230
|
+
await Bun.write(path.join(projectDir, relativePath), content);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function removeImportLines(content: string, modules: string[]) {
|
|
234
|
+
let next = content;
|
|
235
|
+
for (const modulePath of modules) {
|
|
236
|
+
const escaped = modulePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
237
|
+
next = next.replace(
|
|
238
|
+
new RegExp(`^import .* from ["']${escaped}["'];?\\r?\\n`, "gm"),
|
|
239
|
+
"",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return next;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function removeUseLines(content: string, identifiers: string[]) {
|
|
246
|
+
let next = content;
|
|
247
|
+
for (const identifier of identifiers) {
|
|
248
|
+
next = next.replace(
|
|
249
|
+
new RegExp(`^\\s*\\.use\\(${identifier}\\)\\r?\\n`, "gm"),
|
|
250
|
+
"",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return next;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function patchModulesIndex(
|
|
257
|
+
projectDir: string,
|
|
258
|
+
selection: FeatureSelection,
|
|
259
|
+
) {
|
|
260
|
+
const relativePath = "src/modules/index.ts";
|
|
261
|
+
const current = await readText(projectDir, relativePath);
|
|
262
|
+
if (!current) return;
|
|
263
|
+
|
|
264
|
+
let content = current;
|
|
265
|
+
if (!selection.sentry) {
|
|
266
|
+
content = removeImportLines(content, ["../infra/sentry"]);
|
|
267
|
+
content = removeUseLines(content, ["withSentry"]);
|
|
268
|
+
}
|
|
269
|
+
if (!selection.otel) {
|
|
270
|
+
content = removeImportLines(content, ["../infra/telemetry"]);
|
|
271
|
+
content = removeUseLines(content, ["withOpenTelemetry"]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
content = content.replace(/app\s+\.use\(/g, "app\n .use(");
|
|
275
|
+
|
|
276
|
+
await writeText(projectDir, relativePath, content);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function patchEntryIndex(projectDir: string, selection: FeatureSelection) {
|
|
280
|
+
const imports = [
|
|
281
|
+
selection.sentry
|
|
282
|
+
? 'import { initSentry, flushSentry } from "./infra/sentry";'
|
|
283
|
+
: null,
|
|
284
|
+
selection.kafka
|
|
285
|
+
? 'import { startKafka, stopKafka } from "./infra/kafka";'
|
|
286
|
+
: null,
|
|
287
|
+
'import { HttpServer } from "./server/index";',
|
|
288
|
+
'import { logger } from "./common/logger";',
|
|
289
|
+
selection.kafka ? 'import { InternalServerError } from "elysia";' : null,
|
|
290
|
+
].filter(Boolean) as string[];
|
|
291
|
+
|
|
292
|
+
const body: string[] = [];
|
|
293
|
+
|
|
294
|
+
if (selection.sentry) {
|
|
295
|
+
body.push("", "initSentry();");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
body.push("", "const server = new HttpServer();", "");
|
|
299
|
+
|
|
300
|
+
if (selection.kafka) {
|
|
301
|
+
body.push(
|
|
302
|
+
"void server.start().then(async () => {",
|
|
303
|
+
" try {",
|
|
304
|
+
" await startKafka();",
|
|
305
|
+
" } catch (error) {",
|
|
306
|
+
' logger.error("Failed to start Kafka", { error });',
|
|
307
|
+
' throw new InternalServerError("Failed to start Kafka");',
|
|
308
|
+
" }",
|
|
309
|
+
"});",
|
|
310
|
+
);
|
|
311
|
+
} else {
|
|
312
|
+
body.push("void server.start();");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
body.push(
|
|
316
|
+
"",
|
|
317
|
+
"const shutdown = (signal: string) => {",
|
|
318
|
+
" return async () => {",
|
|
319
|
+
" logger.warn(`Received ${signal}, shutting down`);",
|
|
320
|
+
" await server.stop();",
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
if (selection.kafka) {
|
|
324
|
+
body.push(" await stopKafka();");
|
|
325
|
+
}
|
|
326
|
+
if (selection.sentry) {
|
|
327
|
+
body.push(" await flushSentry();");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
body.push(
|
|
331
|
+
" process.exit(0);",
|
|
332
|
+
" };",
|
|
333
|
+
"};",
|
|
334
|
+
"",
|
|
335
|
+
'process.on("SIGINT", () => void shutdown("SIGINT")());',
|
|
336
|
+
'process.on("SIGTERM", () => void shutdown("SIGTERM")());',
|
|
337
|
+
"",
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
await writeText(projectDir, "src/index.ts", [...imports, ...body].join("\n"));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function patchServerIndex(projectDir: string, selection: FeatureSelection) {
|
|
344
|
+
const relativePath = "src/server/index.ts";
|
|
345
|
+
const current = await readText(projectDir, relativePath);
|
|
346
|
+
if (!current) return;
|
|
347
|
+
|
|
348
|
+
let content = current;
|
|
349
|
+
|
|
350
|
+
if (selection.cron) {
|
|
351
|
+
content = content.replace(
|
|
352
|
+
/\/\/import \{ schedules \} from "\.\.\/schedules";.*\n/,
|
|
353
|
+
'import { schedules } from "../schedules";\n',
|
|
354
|
+
);
|
|
355
|
+
content = content.replace(
|
|
356
|
+
/\/\/\.use\(schedules\);.*\n/,
|
|
357
|
+
" .use(schedules);\n",
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
content = content.replace(
|
|
361
|
+
/^import \{ schedules \} from "\.\.\/schedules";\n/m,
|
|
362
|
+
"",
|
|
363
|
+
);
|
|
364
|
+
content = content.replace(/^\s*\.use\(schedules\);\n/m, "");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!selection.websocket) {
|
|
368
|
+
content = content.replace(
|
|
369
|
+
/new Elysia\(\{\s*websocket:\s*\{\s*idleTimeout:\s*120,\s*\},\s*\}\)/,
|
|
370
|
+
"new Elysia()",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await writeText(projectDir, relativePath, content);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function patchPackageJson(
|
|
378
|
+
projectDir: string,
|
|
379
|
+
selection: FeatureSelection,
|
|
380
|
+
) {
|
|
381
|
+
const pkgPath = path.join(projectDir, "package.json");
|
|
382
|
+
const pkgFile = Bun.file(pkgPath);
|
|
383
|
+
if (!(await pkgFile.exists())) return;
|
|
384
|
+
|
|
385
|
+
const pkg = (await pkgFile.json()) as {
|
|
386
|
+
dependencies?: Record<string, string>;
|
|
387
|
+
devDependencies?: Record<string, string>;
|
|
388
|
+
scripts?: Record<string, string>;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
for (const featureId of disabledFeatures(selection)) {
|
|
392
|
+
const deps = FEATURE_DEPENDENCIES[featureId];
|
|
393
|
+
if (deps?.dependencies) {
|
|
394
|
+
for (const name of deps.dependencies) {
|
|
395
|
+
delete pkg.dependencies?.[name];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (deps?.devDependencies) {
|
|
399
|
+
for (const name of deps.devDependencies) {
|
|
400
|
+
delete pkg.devDependencies?.[name];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const scripts = FEATURE_SCRIPTS[featureId];
|
|
405
|
+
if (scripts) {
|
|
406
|
+
for (const scriptName of scripts) {
|
|
407
|
+
delete pkg.scripts?.[scriptName];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!selection.prisma && pkg.scripts) {
|
|
413
|
+
pkg.scripts.start = "bun run src/index.ts";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function buildEnvExample(
|
|
420
|
+
packageName: string,
|
|
421
|
+
selection: FeatureSelection,
|
|
422
|
+
): string {
|
|
423
|
+
const lines = ["NODE_ENV=development", "PORT=3131", ""];
|
|
424
|
+
|
|
425
|
+
if (selection.prisma) {
|
|
426
|
+
lines.push(
|
|
427
|
+
"DATABASE_URL=postgresql://user:password@localhost:5432/your_database",
|
|
428
|
+
"",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (selection.otel) {
|
|
433
|
+
lines.push(
|
|
434
|
+
"# OpenTelemetry (optional in production; enabled by default in development)",
|
|
435
|
+
"# Terminal 1: bun run otel:view",
|
|
436
|
+
"# Terminal 2: bun run dev",
|
|
437
|
+
"# Disable locally: OTEL_EXPORTER_OTLP_ENDPOINT=",
|
|
438
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces",
|
|
439
|
+
`OTEL_SERVICE_NAME=${packageName}`,
|
|
440
|
+
"",
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (selection.sentry) {
|
|
445
|
+
lines.push(
|
|
446
|
+
"# Sentry Spotlight (local UI — enabled by default in development)",
|
|
447
|
+
"# Terminal 1: bun run sentry:spotlight",
|
|
448
|
+
"# Terminal 2: bun run dev",
|
|
449
|
+
"# Disable locally: SENTRY_SPOTLIGHT=false",
|
|
450
|
+
"SENTRY_SPOTLIGHT=true",
|
|
451
|
+
"",
|
|
452
|
+
"# Sentry cloud (optional — events also go to sentry.io when set)",
|
|
453
|
+
"# SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0",
|
|
454
|
+
"# SENTRY_TRACES_SAMPLE_RATE=1.0",
|
|
455
|
+
"",
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (selection.kafka) {
|
|
460
|
+
lines.push(
|
|
461
|
+
"# Kafka (enabled by default in development; disabled in test)",
|
|
462
|
+
"# Start broker: docker compose up -d kafka",
|
|
463
|
+
"# Host dev uses EXTERNAL listener on port 9094",
|
|
464
|
+
"# KAFKA_ENABLED=false",
|
|
465
|
+
"KAFKA_BROKERS=localhost:9094",
|
|
466
|
+
`KAFKA_CLIENT_ID=${packageName}`,
|
|
467
|
+
`KAFKA_GROUP_ID=${packageName}-consumer`,
|
|
468
|
+
"KAFKA_TOPIC=elysia.events",
|
|
469
|
+
"",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (selection.resend) {
|
|
474
|
+
lines.push(
|
|
475
|
+
"# Resend (disabled when RESEND_API_KEY is unset; always disabled in test)",
|
|
476
|
+
"# Create an API key: https://resend.com/api-keys",
|
|
477
|
+
"# RESEND_ENABLED=false",
|
|
478
|
+
"# RESEND_API_KEY=re_xxxxxxxx",
|
|
479
|
+
"# RESEND_FROM=Acme <onboarding@resend.dev>",
|
|
480
|
+
"",
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function patchEnvExample(
|
|
488
|
+
projectDir: string,
|
|
489
|
+
selection: FeatureSelection,
|
|
490
|
+
packageName = "kavoru",
|
|
491
|
+
) {
|
|
492
|
+
const content = buildEnvExample(packageName, selection);
|
|
493
|
+
await writeText(projectDir, ".env.example", content);
|
|
494
|
+
await writeText(projectDir, ".env", content);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function patchDockerfile(projectDir: string, selection: FeatureSelection) {
|
|
498
|
+
if (!selection.docker) return;
|
|
499
|
+
|
|
500
|
+
const relativePath = "Dockerfile";
|
|
501
|
+
const current = await readText(projectDir, relativePath);
|
|
502
|
+
if (!current) return;
|
|
503
|
+
|
|
504
|
+
let content = current;
|
|
505
|
+
if (!selection.prisma) {
|
|
506
|
+
content = content.replace(/^\s*COPY prisma\.config\.ts \.\/.*\n/m, "");
|
|
507
|
+
content = content.replace(
|
|
508
|
+
/^\s*# generate only needs the schema on disk, not a live database\n/m,
|
|
509
|
+
"",
|
|
510
|
+
);
|
|
511
|
+
content = content.replace(
|
|
512
|
+
/^\s*RUN if \[ -f src\/infra\/prisma\/schemas\/schema\.prisma \]; then bunx prisma generate; fi\n/m,
|
|
513
|
+
"",
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await writeText(projectDir, relativePath, content);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function buildAppEnvironment(selection: FeatureSelection): string {
|
|
521
|
+
const lines = [" NODE_ENV: production"];
|
|
522
|
+
if (selection.kafka) {
|
|
523
|
+
lines.push(" KAFKA_BROKERS: kafka:9092");
|
|
524
|
+
}
|
|
525
|
+
if (selection.otel) {
|
|
526
|
+
lines.push(" OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318/v1/traces");
|
|
527
|
+
lines.push(" OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-kavoru}");
|
|
528
|
+
}
|
|
529
|
+
if (selection.sentry) {
|
|
530
|
+
lines.push(" SENTRY_SPOTLIGHT: http://spotlight:8969/stream");
|
|
531
|
+
}
|
|
532
|
+
return ` environment:\n${lines.join("\n")}\n`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function generateDockerCompose(selection: FeatureSelection): string {
|
|
536
|
+
const appDependsOn = selection.kafka
|
|
537
|
+
? ` depends_on:
|
|
538
|
+
kafka:
|
|
539
|
+
condition: service_started
|
|
540
|
+
`
|
|
541
|
+
: "";
|
|
542
|
+
const appEnvironment = buildAppEnvironment(selection);
|
|
543
|
+
|
|
544
|
+
const kafkaService = selection.kafka
|
|
545
|
+
? `
|
|
546
|
+
kafka:
|
|
547
|
+
image: confluentinc/cp-kafka:7.6.1
|
|
548
|
+
hostname: kafka
|
|
549
|
+
ports:
|
|
550
|
+
- "9094:9094"
|
|
551
|
+
environment:
|
|
552
|
+
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
|
|
553
|
+
KAFKA_NODE_ID: "0"
|
|
554
|
+
KAFKA_PROCESS_ROLES: broker,controller
|
|
555
|
+
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
|
|
556
|
+
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
|
|
557
|
+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
|
|
558
|
+
KAFKA_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
|
|
559
|
+
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
|
560
|
+
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
|
561
|
+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
|
562
|
+
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
|
563
|
+
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
|
564
|
+
KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
|
|
565
|
+
networks:
|
|
566
|
+
- app_network
|
|
567
|
+
restart: unless-stopped
|
|
568
|
+
`
|
|
569
|
+
: "";
|
|
570
|
+
|
|
571
|
+
const jaegerService = selection.otel
|
|
572
|
+
? `
|
|
573
|
+
jaeger:
|
|
574
|
+
image: jaegertracing/all-in-one:1.62.0
|
|
575
|
+
ports:
|
|
576
|
+
- "16686:16686"
|
|
577
|
+
- "4318:4318"
|
|
578
|
+
environment:
|
|
579
|
+
COLLECTOR_OTLP_ENABLED: "true"
|
|
580
|
+
networks:
|
|
581
|
+
- app_network
|
|
582
|
+
restart: unless-stopped
|
|
583
|
+
`
|
|
584
|
+
: "";
|
|
585
|
+
|
|
586
|
+
const spotlightService = selection.sentry
|
|
587
|
+
? `
|
|
588
|
+
spotlight:
|
|
589
|
+
image: ghcr.io/getsentry/spotlight:latest
|
|
590
|
+
ports:
|
|
591
|
+
- "8969:8969"
|
|
592
|
+
networks:
|
|
593
|
+
- app_network
|
|
594
|
+
restart: unless-stopped
|
|
595
|
+
`
|
|
596
|
+
: "";
|
|
597
|
+
|
|
598
|
+
return `services:
|
|
599
|
+
app:
|
|
600
|
+
build:
|
|
601
|
+
context: .
|
|
602
|
+
target: build
|
|
603
|
+
args:
|
|
604
|
+
PORT: \${PORT:-3131}
|
|
605
|
+
command: /app/server
|
|
606
|
+
volumes:
|
|
607
|
+
- ./src:/app/src
|
|
608
|
+
networks:
|
|
609
|
+
app_network:
|
|
610
|
+
aliases:
|
|
611
|
+
- app
|
|
612
|
+
extra_hosts:
|
|
613
|
+
- "host.docker.internal:host-gateway"
|
|
614
|
+
ports:
|
|
615
|
+
- "\${PORT:-3131}:\${PORT:-3131}"
|
|
616
|
+
restart: unless-stopped
|
|
617
|
+
env_file:
|
|
618
|
+
- .env
|
|
619
|
+
${appDependsOn}${appEnvironment} healthcheck:
|
|
620
|
+
test: ["CMD", "curl", "-f", "http://localhost:\${PORT}/healthz"]
|
|
621
|
+
interval: 600s
|
|
622
|
+
timeout: 300s
|
|
623
|
+
retries: 1
|
|
624
|
+
start_period: 40s
|
|
625
|
+
${kafkaService}${jaegerService}${spotlightService}
|
|
626
|
+
networks:
|
|
627
|
+
app_network:
|
|
628
|
+
driver: bridge
|
|
629
|
+
`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function patchDockerCompose(
|
|
633
|
+
projectDir: string,
|
|
634
|
+
selection: FeatureSelection,
|
|
635
|
+
) {
|
|
636
|
+
if (!selection.docker) return;
|
|
637
|
+
await writeText(projectDir, "docker-compose.yaml", generateDockerCompose(selection));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function applyFeatures(
|
|
641
|
+
projectDir: string,
|
|
642
|
+
selection: FeatureSelection,
|
|
643
|
+
packageName = "kavoru",
|
|
644
|
+
): Promise<void> {
|
|
645
|
+
const disabled = disabledFeatures(selection);
|
|
646
|
+
log.step(`Applying feature selection (${formatFeatureSelection(selection)})`);
|
|
647
|
+
|
|
648
|
+
for (const featureId of disabled) {
|
|
649
|
+
await removePaths(projectDir, FEATURE_PATHS[featureId]);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
await patchModulesIndex(projectDir, selection);
|
|
653
|
+
await patchEntryIndex(projectDir, selection);
|
|
654
|
+
await patchServerIndex(projectDir, selection);
|
|
655
|
+
await patchPackageJson(projectDir, selection);
|
|
656
|
+
await patchEnvExample(projectDir, selection, packageName);
|
|
657
|
+
await patchDockerfile(projectDir, selection);
|
|
658
|
+
await patchDockerCompose(projectDir, selection);
|
|
659
|
+
|
|
660
|
+
if (disabled.length > 0) {
|
|
661
|
+
log.success("Feature selection applied");
|
|
662
|
+
}
|
|
663
|
+
}
|