kavoru 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,24 +16,54 @@ Equivalent to `bunx --bun kavoru` (Bun runs the `kavoru` binary from the npm pac
16
16
 
17
17
  ### Options
18
18
 
19
- | Flag | Description |
20
- | --- | --- |
21
- | `-h, --help` | Show help |
22
- | `-V, --version` | Show CLI version |
23
- | `-f, --force` | Scaffold into a non-empty directory |
24
- | `--no-install` | Skip `bun install` |
19
+ | Flag | Description |
20
+ | ------------------- | -------------------------------------------------------- |
21
+ | `-h, --help` | Show help |
22
+ | `-V, --version` | Show CLI version |
23
+ | `-f, --force` | Scaffold into a non-empty directory |
24
+ | `--no-install` | Skip `bun install` |
25
25
  | `--repo owner/name` | Override template repo (default: `mertthesamael/Kavoru`) |
26
- | `--branch name` | Template branch (default: `master`) |
26
+ | `--branch name` | Template branch (default: `master`) |
27
+ | `--minimal` | Core only — health, OpenAPI, response envelope |
28
+ | `--features list` | Comma-separated features to include |
29
+ | `--no-features list`| Comma-separated features to exclude (default: all on) |
30
+
31
+ ### Optional features
32
+
33
+ During setup you can pick which integrations to scaffold. Core is always included: health routes, OpenAPI at `/help`, CORS, and the JSON response envelope.
34
+
35
+ | ID | Feature |
36
+ | ----------- | ---------------------- |
37
+ | `auth` | JWT authentication |
38
+ | `prisma` | Prisma + PostgreSQL |
39
+ | `otel` | OpenTelemetry |
40
+ | `sentry` | Sentry + Spotlight |
41
+ | `kafka` | Kafka producer/consumer|
42
+ | `websocket` | WebSocket realtime |
43
+ | `resend` | Resend email |
44
+ | `cron` | Cron jobs |
45
+ | `docker` | Dockerfile + Compose |
46
+
47
+ Interactive mode (TTY) shows a checkbox menu (↑↓ move, Space toggle, Enter confirm). Non-interactive runs use the full stack unless you pass flags.
27
48
 
28
49
  ### Examples
29
50
 
30
51
  ```bash
31
- # Interactive (prompts for project name)
52
+ # Interactive (prompts for project name + feature toggles)
32
53
  bunx kavoru
33
54
 
34
55
  # Current directory
35
56
  bunx kavoru .
36
57
 
58
+ # Minimal API skeleton
59
+ bunx kavoru my-api --minimal
60
+
61
+ # Pick specific features
62
+ bunx kavoru my-api --features auth,prisma,otel,sentry
63
+
64
+ # Full stack minus Kafka and Docker
65
+ bunx kavoru my-api --no-features kafka,docker
66
+
37
67
  # Custom template fork (local dev)
38
68
  bunx kavoru demo --repo your-user/Kavoru --no-install
39
69
  ```
@@ -52,31 +82,6 @@ bun link
52
82
  bunx kavoru my-test-app
53
83
  ```
54
84
 
55
- ## Publish to npm
56
-
57
- 1. Ensure the [Kavoru](https://github.com/mertthesamael/Kavoru) template repo is public on `master`.
58
- 2. Create a **Granular Access Token** at [npm → Access Tokens](https://www.npmjs.com/settings/~/tokens) with:
59
- - **Bypass two-factor authentication** — checked (required for first publish without 2FA)
60
- - **Packages** — All packages, **Read and write**
61
- 3. Configure auth (do not commit the token):
62
-
63
- ```bash
64
- echo "//registry.npmjs.org/:_authToken=YOUR_TOKEN" > ~/.npmrc
65
- unset NPM_CONFIG_TOKEN
66
- npm whoami
67
- ```
68
-
69
- 4. Publish: `npm publish` (or `bun publish`)
70
-
71
- The `bin/kavoru.js` shim uses `#!/usr/bin/env bun` so `bunx kavoru` runs with Bun.
72
-
73
- ## What the CLI does
74
-
75
- 1. Shallow-clones the GitHub template (or downloads a zip if `git` is missing)
76
- 2. Removes `.git` so the new project starts fresh
77
- 3. Sets `package.json` `name`, copies `.env` from `.env.example`, and adjusts default service IDs
78
- 4. Runs `bun install` (unless `--no-install`)
79
-
80
85
  ## License
81
86
 
82
87
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kavoru",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a new Kavoru (Elysia + Bun) backend from the official template",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.ts CHANGED
@@ -8,6 +8,9 @@ export type CliOptions = {
8
8
  force: boolean;
9
9
  repo: string;
10
10
  branch: string;
11
+ minimal: boolean;
12
+ features: string | undefined;
13
+ noFeatures: string[];
11
14
  };
12
15
 
13
16
  const HELP = `\
@@ -25,10 +28,18 @@ Options:
25
28
  --no-install Skip "bun install" after scaffolding
26
29
  --repo <owner/name> GitHub template repo (default: mertthesamael/Kavoru)
27
30
  --branch <name> Template branch (default: master)
31
+ --minimal Core only (health, OpenAPI, response envelope)
32
+ --features <list> Comma-separated features to include (default: all)
33
+ --no-features <list> Comma-separated features to exclude
34
+
35
+ Features:
36
+ auth, prisma, otel, sentry, kafka, websocket, resend, cron, docker
28
37
 
29
38
  Examples:
30
39
  bunx kavoru my-api
31
- bunx kavoru .
40
+ bunx kavoru my-api --minimal
41
+ bunx kavoru my-api --features auth,prisma,otel
42
+ bunx kavoru my-api --no-features kafka,docker,resend
32
43
  `;
33
44
 
34
45
  export function parseArgs(argv: string[]): CliOptions {
@@ -40,6 +51,9 @@ export function parseArgs(argv: string[]): CliOptions {
40
51
  force: false,
41
52
  repo: "mertthesamael/Kavoru",
42
53
  branch: "master",
54
+ minimal: false,
55
+ features: undefined,
56
+ noFeatures: [],
43
57
  };
44
58
 
45
59
  const positional: string[] = [];
@@ -64,6 +78,25 @@ export function parseArgs(argv: string[]): CliOptions {
64
78
  case "--no-install":
65
79
  options.install = false;
66
80
  break;
81
+ case "--minimal":
82
+ options.minimal = true;
83
+ break;
84
+ case "--features": {
85
+ const value = argv[++i];
86
+ if (!value) throw new Error("--features requires a comma-separated list.");
87
+ options.features = value;
88
+ break;
89
+ }
90
+ case "--no-features": {
91
+ const value = argv[++i];
92
+ if (!value) {
93
+ throw new Error("--no-features requires a comma-separated list.");
94
+ }
95
+ options.noFeatures.push(
96
+ ...value.split(",").map((part) => part.trim()).filter(Boolean),
97
+ );
98
+ break;
99
+ }
67
100
  case "--repo": {
68
101
  const value = argv[++i];
69
102
  if (!value) throw new Error("--repo requires a value (owner/name).");
@@ -88,6 +121,10 @@ export function parseArgs(argv: string[]): CliOptions {
88
121
  options.targetDir = positional[0];
89
122
  }
90
123
 
124
+ if (options.minimal && options.features) {
125
+ throw new Error("Use either --minimal or --features, not both.");
126
+ }
127
+
91
128
  return options;
92
129
  }
93
130
 
package/src/cli.ts CHANGED
@@ -5,7 +5,17 @@ import path from "node:path";
5
5
  import * as readline from "node:readline/promises";
6
6
  import { stdin as input, stdout as output } from "node:process";
7
7
  import type { CliOptions } from "./args";
8
+ import {
9
+ ALL_FEATURES,
10
+ MINIMAL_FEATURES,
11
+ applyFeatures,
12
+ formatFeatureSelection,
13
+ parseFeatureExcludeList,
14
+ parseFeatureIncludeList,
15
+ type FeatureSelection,
16
+ } from "./features";
8
17
  import { log } from "./log";
18
+ import { promptFeatureSelection } from "./prompts";
9
19
  import {
10
20
  customizeProject,
11
21
  fetchTemplate,
@@ -49,6 +59,38 @@ async function copyTemplateIntoTarget(
49
59
  }
50
60
  }
51
61
 
62
+ export function resolveFeatureSelection(options: CliOptions): FeatureSelection {
63
+ if (options.minimal) {
64
+ return { ...MINIMAL_FEATURES };
65
+ }
66
+
67
+ if (options.features) {
68
+ return parseFeatureIncludeList(options.features);
69
+ }
70
+
71
+ if (options.noFeatures.length > 0) {
72
+ return parseFeatureExcludeList(options.noFeatures, ALL_FEATURES);
73
+ }
74
+
75
+ return { ...ALL_FEATURES };
76
+ }
77
+
78
+ async function resolveFeatureSelectionInteractive(
79
+ options: CliOptions,
80
+ ): Promise<FeatureSelection> {
81
+ const fromFlags = resolveFeatureSelection(options);
82
+ const hasExplicitFlags =
83
+ options.minimal ||
84
+ options.features !== undefined ||
85
+ options.noFeatures.length > 0;
86
+
87
+ if (hasExplicitFlags || !process.stdin.isTTY || !process.stdout.isTTY) {
88
+ return fromFlags;
89
+ }
90
+
91
+ return promptFeatureSelection(fromFlags);
92
+ }
93
+
52
94
  export async function runCli(options: CliOptions): Promise<void> {
53
95
  let targetArg = options.targetDir;
54
96
 
@@ -77,15 +119,18 @@ export async function runCli(options: CliOptions): Promise<void> {
77
119
  );
78
120
  }
79
121
 
122
+ const featureSelection = await resolveFeatureSelectionInteractive(options);
80
123
  const source = resolveTemplateSource(options.repo, options.branch);
81
124
  const tempDir = path.join(os.tmpdir(), `kavoru-${Date.now()}`);
82
125
 
83
126
  log.info(`Creating Kavoru project "${packageName}"`);
127
+ log.info(`Features: ${formatFeatureSelection(featureSelection)}`);
84
128
 
85
129
  try {
86
130
  await fetchTemplate(source, tempDir);
87
131
  await removeGitMetadata(tempDir);
88
132
  await customizeProject(tempDir, packageName);
133
+ await applyFeatures(tempDir, featureSelection, packageName);
89
134
  await copyTemplateIntoTarget(tempDir, targetDir);
90
135
 
91
136
  if (options.install) {
@@ -0,0 +1,630 @@
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(/^\s*ENV DATABASE_URL=\$\{DATABASE_URL\}\n/m, "");
508
+ content = content.replace(/^\s*RUN bunx prisma db pull\n/m, "");
509
+ content = content.replace(/^\s*RUN bunx prisma generate\n/m, "");
510
+ }
511
+
512
+ await writeText(projectDir, relativePath, content);
513
+ }
514
+
515
+ function generateDockerCompose(selection: FeatureSelection): string {
516
+ const appDependsOn = selection.kafka
517
+ ? ` depends_on:
518
+ kafka:
519
+ condition: service_started
520
+ environment:
521
+ KAFKA_BROKERS: kafka:9092
522
+ `
523
+ : "";
524
+
525
+ const kafkaService = selection.kafka
526
+ ? `
527
+ kafka:
528
+ image: bitnami/kafka:3.9
529
+ ports:
530
+ - "9094:9094"
531
+ environment:
532
+ KAFKA_CFG_NODE_ID: "0"
533
+ KAFKA_CFG_PROCESS_ROLES: controller,broker
534
+ KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
535
+ KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
536
+ KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
537
+ KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
538
+ KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
539
+ KAFKA_CFG_INTER_BROKER_LISTENER_NAME: PLAINTEXT
540
+ ALLOW_PLAINTEXT_LISTENER: "yes"
541
+ networks:
542
+ - app_network
543
+ restart: unless-stopped
544
+ `
545
+ : "";
546
+
547
+ const jaegerService = selection.otel
548
+ ? `
549
+ jaeger:
550
+ image: jaegertracing/all-in-one:1.62
551
+ ports:
552
+ - "16686:16686"
553
+ - "4318:4318"
554
+ environment:
555
+ COLLECTOR_OTLP_ENABLED: "true"
556
+ networks:
557
+ - app_network
558
+ restart: unless-stopped
559
+ `
560
+ : "";
561
+
562
+ return `services:
563
+ app:
564
+ build:
565
+ context: .
566
+ target: build
567
+ args:
568
+ PORT: \${PORT:-3131}
569
+ command: /app/server
570
+ volumes:
571
+ - ./src:/app/src
572
+ networks:
573
+ app_network:
574
+ aliases:
575
+ - app
576
+ extra_hosts:
577
+ - "host.docker.internal:host-gateway"
578
+ expose:
579
+ - "\${PORT}"
580
+ restart: unless-stopped
581
+ env_file:
582
+ - .env
583
+ ${appDependsOn} healthcheck:
584
+ test: ["CMD", "curl", "-f", "http://localhost:\${PORT}/healthz"]
585
+ interval: 600s
586
+ timeout: 300s
587
+ retries: 1
588
+ start_period: 40s
589
+ ${kafkaService}${jaegerService}
590
+ networks:
591
+ app_network:
592
+ name: app_network
593
+ driver: bridge
594
+
595
+ version: "3.8"
596
+ `;
597
+ }
598
+
599
+ async function patchDockerCompose(
600
+ projectDir: string,
601
+ selection: FeatureSelection,
602
+ ) {
603
+ if (!selection.docker) return;
604
+ await writeText(projectDir, "docker-compose.yaml", generateDockerCompose(selection));
605
+ }
606
+
607
+ export async function applyFeatures(
608
+ projectDir: string,
609
+ selection: FeatureSelection,
610
+ packageName = "kavoru",
611
+ ): Promise<void> {
612
+ const disabled = disabledFeatures(selection);
613
+ log.step(`Applying feature selection (${formatFeatureSelection(selection)})`);
614
+
615
+ for (const featureId of disabled) {
616
+ await removePaths(projectDir, FEATURE_PATHS[featureId]);
617
+ }
618
+
619
+ await patchModulesIndex(projectDir, selection);
620
+ await patchEntryIndex(projectDir, selection);
621
+ await patchServerIndex(projectDir, selection);
622
+ await patchPackageJson(projectDir, selection);
623
+ await patchEnvExample(projectDir, selection, packageName);
624
+ await patchDockerfile(projectDir, selection);
625
+ await patchDockerCompose(projectDir, selection);
626
+
627
+ if (disabled.length > 0) {
628
+ log.success("Feature selection applied");
629
+ }
630
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { stdin, stdout } from "node:process";
2
+ import {
3
+ ALL_FEATURES,
4
+ FEATURES,
5
+ MINIMAL_FEATURES,
6
+ type FeatureId,
7
+ type FeatureSelection,
8
+ formatFeatureSelection,
9
+ } from "./features";
10
+
11
+ const ESC = "\x1b";
12
+ const dim = `${ESC}[2m`;
13
+ const reset = `${ESC}[0m`;
14
+ const cyan = `${ESC}[36m`;
15
+
16
+ function cloneSelection(selection: FeatureSelection): FeatureSelection {
17
+ return { ...selection };
18
+ }
19
+
20
+ type KeyAction =
21
+ | "up"
22
+ | "down"
23
+ | "toggle"
24
+ | "confirm"
25
+ | "all"
26
+ | "minimal"
27
+ | "interrupt";
28
+
29
+ const KEY_UP = ESC + "[A";
30
+ const KEY_DOWN = ESC + "[B";
31
+ const KEY_UP_ALT = ESC + "OA";
32
+ const KEY_DOWN_ALT = ESC + "OB";
33
+
34
+ function parseKeyInput(data: string): KeyAction | null {
35
+ if (data === "\u0003") return "interrupt";
36
+ if (data === "\r" || data === "\n") return "confirm";
37
+ if (data === " ") return "toggle";
38
+ if (data === "a" || data === "A") return "all";
39
+ if (data === "m" || data === "M") return "minimal";
40
+ if (data === KEY_UP || data === KEY_UP_ALT) return "up";
41
+ if (data === KEY_DOWN || data === KEY_DOWN_ALT) return "down";
42
+ return null;
43
+ }
44
+
45
+ function createKeyReader(onKey: (action: KeyAction) => void) {
46
+ let pending = "";
47
+
48
+ const onData = (chunk: string) => {
49
+ pending += chunk;
50
+
51
+ while (pending.length > 0) {
52
+ if (pending === ESC) return;
53
+
54
+ if (pending.startsWith(ESC)) {
55
+ if (pending.length < 3) return;
56
+
57
+ const action = parseKeyInput(pending.slice(0, 3));
58
+ if (action) {
59
+ pending = pending.slice(3);
60
+ onKey(action);
61
+ continue;
62
+ }
63
+
64
+ if (pending.startsWith(ESC + "O") && pending.length < 3) return;
65
+
66
+ pending = pending.slice(1);
67
+ continue;
68
+ }
69
+
70
+ const action = parseKeyInput(pending[0] ?? "");
71
+ pending = pending.slice(1);
72
+ if (action) onKey(action);
73
+ }
74
+ };
75
+
76
+ return onData;
77
+ }
78
+
79
+ function renderCheckboxMenu(
80
+ selection: FeatureSelection,
81
+ activeIndex: number,
82
+ lineCount: number,
83
+ ): number {
84
+ const lines: string[] = [
85
+ `${cyan}◆${reset} Select optional features ${dim}(↑↓ move · Space toggle · Enter confirm)${reset}`,
86
+ `${dim} a = all · m = minimal${reset}`,
87
+ "",
88
+ ];
89
+
90
+ FEATURES.forEach((feature, index) => {
91
+ const isActive = index === activeIndex;
92
+ const pointer = isActive ? `${cyan}❯${reset}` : " ";
93
+ const mark = selection[feature.id] ? "x" : " ";
94
+ const label = isActive ? `${cyan}${feature.label}${reset}` : feature.label;
95
+ lines.push(
96
+ ` ${pointer} [${mark}] ${label.padEnd(22)} ${dim}${feature.description}${reset}`,
97
+ );
98
+ });
99
+
100
+ lines.push("", ` ${dim}Selected: ${formatFeatureSelection(selection)}${reset}`);
101
+
102
+ if (lineCount > 0) {
103
+ stdout.write(`${ESC}[${lineCount}A`);
104
+ }
105
+
106
+ for (const line of lines) {
107
+ stdout.write(`${ESC}[2K${ESC}[0G${line}\n`);
108
+ }
109
+
110
+ return lines.length;
111
+ }
112
+
113
+ function restoreTerminal(onData: (chunk: string) => void) {
114
+ stdin.off("data", onData);
115
+ if (stdin.isTTY) {
116
+ stdin.setRawMode(false);
117
+ }
118
+ stdin.pause();
119
+ stdout.write(`${ESC}[?25h`);
120
+ }
121
+
122
+ export async function promptFeatureSelection(
123
+ initial: FeatureSelection = ALL_FEATURES,
124
+ ): Promise<FeatureSelection> {
125
+ if (!stdin.isTTY || !stdout.isTTY) {
126
+ return cloneSelection(initial);
127
+ }
128
+
129
+ const selection = cloneSelection(initial);
130
+ let activeIndex = 0;
131
+ let lineCount = 0;
132
+
133
+ stdout.write(`${ESC}[?25l`);
134
+
135
+ if (stdin.isTTY) {
136
+ stdin.setRawMode(true);
137
+ }
138
+ stdin.resume();
139
+ stdin.setEncoding("utf8");
140
+
141
+ return new Promise((resolve, reject) => {
142
+ const onKey = (action: KeyAction) => {
143
+ switch (action) {
144
+ case "up":
145
+ activeIndex =
146
+ (activeIndex - 1 + FEATURES.length) % FEATURES.length;
147
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
148
+ break;
149
+ case "down":
150
+ activeIndex = (activeIndex + 1) % FEATURES.length;
151
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
152
+ break;
153
+ case "toggle": {
154
+ const feature = FEATURES[activeIndex];
155
+ if (!feature) break;
156
+ selection[feature.id as FeatureId] = !selection[feature.id as FeatureId];
157
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
158
+ break;
159
+ }
160
+ case "all":
161
+ Object.assign(selection, ALL_FEATURES);
162
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
163
+ break;
164
+ case "minimal":
165
+ Object.assign(selection, MINIMAL_FEATURES);
166
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
167
+ break;
168
+ case "confirm":
169
+ restoreTerminal(onData);
170
+ stdout.write("\n");
171
+ resolve(selection);
172
+ break;
173
+ case "interrupt":
174
+ restoreTerminal(onData);
175
+ stdout.write("\n");
176
+ reject(new Error("Feature selection cancelled."));
177
+ break;
178
+ }
179
+ };
180
+
181
+ const onData = createKeyReader(onKey);
182
+ stdin.on("data", onData);
183
+ lineCount = renderCheckboxMenu(selection, activeIndex, 0);
184
+ });
185
+ }
package/src/template.ts CHANGED
@@ -130,21 +130,6 @@ export async function customizeProject(
130
130
  pkg.name = packageName;
131
131
  await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
132
132
 
133
- const envExamplePath = path.join(projectDir, ".env.example");
134
- const envExample = Bun.file(envExamplePath);
135
- if (await envExample.exists()) {
136
- let envText = await envExample.text();
137
- envText = envText
138
- .replace(/^OTEL_SERVICE_NAME=kavoru$/m, `OTEL_SERVICE_NAME=${packageName}`)
139
- .replace(/^KAFKA_CLIENT_ID=kavoru$/m, `KAFKA_CLIENT_ID=${packageName}`)
140
- .replace(
141
- /^KAFKA_GROUP_ID=kavoru-consumer$/m,
142
- `KAFKA_GROUP_ID=${packageName}-consumer`,
143
- );
144
- await Bun.write(envExamplePath, envText);
145
- await Bun.write(path.join(projectDir, ".env"), envText);
146
- }
147
-
148
133
  const modulesIndex = path.join(projectDir, "src", "modules", "index.ts");
149
134
  const modulesFile = Bun.file(modulesIndex);
150
135
  if (await modulesFile.exists()) {