kavoru 0.8.1 → 0.8.9

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
@@ -47,7 +47,7 @@ During setup you can pick which integrations to scaffold. Core is always include
47
47
  | ID | Feature |
48
48
  | ----------- | ---------------------- |
49
49
  | `auth` | JWT authentication |
50
- | `prisma` | Prisma + PostgreSQL |
50
+ | `postgres` | PostgreSQL + Prisma (includes Docker Postgres) |
51
51
  | `otel` | OpenTelemetry |
52
52
  | `sentry` | Sentry + Spotlight |
53
53
  | `kafka` | Kafka producer/consumer|
@@ -71,7 +71,7 @@ bunx kavoru@latest .
71
71
  bunx kavoru@latest my-api --minimal
72
72
 
73
73
  # Pick specific features
74
- bunx kavoru@latest my-api --features auth,prisma,otel,sentry
74
+ bunx kavoru@latest my-api --features auth,postgres,otel,sentry
75
75
 
76
76
  # Full stack minus Kafka and Docker
77
77
  bunx kavoru@latest my-api --no-features kafka,docker
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kavoru",
3
- "version": "0.8.1",
3
+ "version": "0.8.9",
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
@@ -33,12 +33,13 @@ Options:
33
33
  --no-features <list> Comma-separated features to exclude
34
34
 
35
35
  Features:
36
- auth, prisma, otel, sentry, kafka, websocket, resend, cron, docker
36
+ auth, postgres, otel, sentry, kafka, websocket, resend, cron, docker
37
+ (prisma is accepted as an alias for postgres)
37
38
 
38
39
  Examples:
39
40
  bunx kavoru@latest my-api
40
41
  bunx kavoru@latest my-api --minimal
41
- bunx kavoru@latest my-api --features auth,prisma,otel
42
+ bunx kavoru@latest my-api --features auth,postgres,otel
42
43
  bunx kavoru@latest my-api --no-features kafka,docker,resend
43
44
  `;
44
45
 
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  MINIMAL_FEATURES,
11
11
  applyFeatures,
12
12
  formatFeatureSelection,
13
+ normalizeFeatureSelection,
13
14
  parseFeatureExcludeList,
14
15
  parseFeatureIncludeList,
15
16
  type FeatureSelection,
@@ -72,7 +73,7 @@ export function resolveFeatureSelection(options: CliOptions): FeatureSelection {
72
73
  return parseFeatureExcludeList(options.noFeatures, ALL_FEATURES);
73
74
  }
74
75
 
75
- return { ...ALL_FEATURES };
76
+ return normalizeFeatureSelection({ ...ALL_FEATURES });
76
77
  }
77
78
 
78
79
  async function resolveFeatureSelectionInteractive(
@@ -88,7 +89,7 @@ async function resolveFeatureSelectionInteractive(
88
89
  return fromFlags;
89
90
  }
90
91
 
91
- return promptFeatureSelection(fromFlags);
92
+ return normalizeFeatureSelection(await promptFeatureSelection(fromFlags));
92
93
  }
93
94
 
94
95
  export async function runCli(options: CliOptions): Promise<void> {
package/src/features.ts CHANGED
@@ -4,7 +4,7 @@ import { log } from "./log";
4
4
 
5
5
  export type FeatureId =
6
6
  | "auth"
7
- | "prisma"
7
+ | "postgres"
8
8
  | "otel"
9
9
  | "sentry"
10
10
  | "kafka"
@@ -13,6 +13,10 @@ export type FeatureId =
13
13
  | "cron"
14
14
  | "docker";
15
15
 
16
+ const FEATURE_ALIASES: Record<string, FeatureId> = {
17
+ prisma: "postgres",
18
+ };
19
+
16
20
  export type FeatureSelection = Record<FeatureId, boolean>;
17
21
 
18
22
  export type FeatureDef = {
@@ -28,9 +32,9 @@ export const FEATURES: FeatureDef[] = [
28
32
  description: "Bearer auth, sign-in route, protected routes",
29
33
  },
30
34
  {
31
- id: "prisma",
32
- label: "Prisma + PostgreSQL",
33
- description: "Prisma 7 config, migrations, seed scripts",
35
+ id: "postgres",
36
+ label: "PostgreSQL",
37
+ description: "Docker Postgres, Prisma 7, migrations, and seed",
34
38
  },
35
39
  {
36
40
  id: "otel",
@@ -88,7 +92,7 @@ const FEATURE_PATHS: Record<FeatureId, string[]> = {
88
92
  "src/constants/jwt.ts",
89
93
  "src/models/schemas/signin.ts",
90
94
  ],
91
- prisma: ["prisma.config.ts", "src/infra/prisma"],
95
+ postgres: ["prisma.config.ts", "src/infra/prisma"],
92
96
  otel: ["src/infra/telemetry"],
93
97
  sentry: [
94
98
  "src/infra/sentry",
@@ -115,7 +119,7 @@ const FEATURE_DEPENDENCIES: Partial<
115
119
  Record<FeatureId, { dependencies?: string[]; devDependencies?: string[] }>
116
120
  > = {
117
121
  auth: { dependencies: ["@elysiajs/bearer", "@elysiajs/jwt"] },
118
- prisma: {
122
+ postgres: {
119
123
  dependencies: ["@prisma/adapter-pg", "@prisma/client"],
120
124
  devDependencies: ["prisma"],
121
125
  },
@@ -136,9 +140,42 @@ const FEATURE_DEPENDENCIES: Partial<
136
140
  const FEATURE_SCRIPTS: Partial<Record<FeatureId, string[]>> = {
137
141
  otel: ["otel:view", "otel:tui"],
138
142
  sentry: ["sentry:spotlight"],
139
- prisma: ["seed"],
143
+ postgres: ["seed"],
140
144
  };
141
145
 
146
+ function resolveFeatureId(raw: string): FeatureId | null {
147
+ const id = FEATURE_ALIASES[raw] ?? raw;
148
+ return FEATURE_IDS.includes(id as FeatureId) ? (id as FeatureId) : null;
149
+ }
150
+
151
+ export function toPostgresName(packageName: string): string {
152
+ const normalized = packageName
153
+ .replace(/-/g, "_")
154
+ .replace(/[^a-z0-9_]/gi, "_")
155
+ .replace(/_+/g, "_")
156
+ .replace(/^_|_$/g, "");
157
+ return normalized || "app";
158
+ }
159
+
160
+ export function buildDatabaseUrl(
161
+ packageName: string,
162
+ host: string,
163
+ port = 5432,
164
+ ): string {
165
+ const name = toPostgresName(packageName);
166
+ return `postgresql://${name}:${name}@${host}:${port}/${name}`;
167
+ }
168
+
169
+ export function normalizeFeatureSelection(
170
+ selection: FeatureSelection,
171
+ ): FeatureSelection {
172
+ const next = { ...selection };
173
+ if (next.postgres) {
174
+ next.docker = true;
175
+ }
176
+ return next;
177
+ }
178
+
142
179
  function enabledFeatures(selection: FeatureSelection): FeatureId[] {
143
180
  return FEATURE_IDS.filter((id) => selection[id]);
144
181
  }
@@ -159,9 +196,7 @@ export function parseFeatureIncludeList(input: string): FeatureSelection {
159
196
  .map((part) => part.trim())
160
197
  .filter(Boolean);
161
198
 
162
- const unknown = requested.filter(
163
- (id) => !FEATURE_IDS.includes(id as FeatureId),
164
- );
199
+ const unknown = requested.filter((part) => resolveFeatureId(part) === null);
165
200
  if (unknown.length > 0) {
166
201
  throw new Error(
167
202
  `Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
@@ -169,8 +204,12 @@ export function parseFeatureIncludeList(input: string): FeatureSelection {
169
204
  }
170
205
 
171
206
  const selection = { ...MINIMAL_FEATURES };
172
- for (const id of requested) {
173
- selection[id as FeatureId] = true;
207
+ for (const part of requested) {
208
+ const id = resolveFeatureId(part);
209
+ if (id) selection[id] = true;
210
+ }
211
+ if (selection.postgres) {
212
+ selection.docker = true;
174
213
  }
175
214
  return selection;
176
215
  }
@@ -183,13 +222,14 @@ export function parseFeatureExcludeList(
183
222
  const unknown: string[] = [];
184
223
 
185
224
  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);
225
+ const part = raw.trim().toLowerCase();
226
+ if (!part) continue;
227
+ const id = resolveFeatureId(part);
228
+ if (!id) {
229
+ unknown.push(part);
190
230
  continue;
191
231
  }
192
- selection[id as FeatureId] = false;
232
+ selection[id] = false;
193
233
  }
194
234
 
195
235
  if (unknown.length > 0) {
@@ -198,6 +238,10 @@ export function parseFeatureExcludeList(
198
238
  );
199
239
  }
200
240
 
241
+ if (!selection.docker) {
242
+ selection.postgres = false;
243
+ }
244
+
201
245
  return selection;
202
246
  }
203
247
 
@@ -349,11 +393,17 @@ export function buildEntryIndex(selection: FeatureSelection): string {
349
393
  return [...imports, ...body].join("\n");
350
394
  }
351
395
 
352
- async function patchEntryIndex(projectDir: string, selection: FeatureSelection) {
396
+ async function patchEntryIndex(
397
+ projectDir: string,
398
+ selection: FeatureSelection,
399
+ ) {
353
400
  await writeText(projectDir, "src/index.ts", buildEntryIndex(selection));
354
401
  }
355
402
 
356
- async function patchServerIndex(projectDir: string, selection: FeatureSelection) {
403
+ async function patchServerIndex(
404
+ projectDir: string,
405
+ selection: FeatureSelection,
406
+ ) {
357
407
  const relativePath = "src/server/index.ts";
358
408
  const current = await readText(projectDir, relativePath);
359
409
  if (!current) return;
@@ -422,7 +472,7 @@ async function patchPackageJson(
422
472
  }
423
473
  }
424
474
 
425
- if (!selection.prisma && pkg.scripts) {
475
+ if (!selection.postgres && pkg.scripts) {
426
476
  pkg.scripts.start = "bun run src/index.ts";
427
477
  }
428
478
 
@@ -435,9 +485,11 @@ export function buildEnvExample(
435
485
  ): string {
436
486
  const lines = ["NODE_ENV=development", "PORT=3131", ""];
437
487
 
438
- if (selection.prisma) {
488
+ if (selection.postgres) {
439
489
  lines.push(
440
- "DATABASE_URL=postgresql://user:password@localhost:5432/your_database",
490
+ "# Start database: docker compose up -d postgres",
491
+ "# Host dev uses published port; Docker app overrides host in docker/app/.env",
492
+ `DATABASE_URL=${buildDatabaseUrl(packageName, "localhost")}`,
441
493
  "",
442
494
  );
443
495
  }
@@ -507,7 +559,10 @@ async function patchEnvExample(
507
559
  await writeText(projectDir, ".env", content);
508
560
  }
509
561
 
510
- async function patchDockerfile(projectDir: string, selection: FeatureSelection) {
562
+ async function patchDockerfile(
563
+ projectDir: string,
564
+ selection: FeatureSelection,
565
+ ) {
511
566
  if (!selection.docker) return;
512
567
 
513
568
  const relativePath = "docker/app/Dockerfile";
@@ -515,14 +570,19 @@ async function patchDockerfile(projectDir: string, selection: FeatureSelection)
515
570
  if (!current) return;
516
571
 
517
572
  let content = current;
518
- if (!selection.prisma) {
573
+ if (!selection.postgres) {
519
574
  content = content.replace(/^\s*COPY prisma\.config\.ts \.\/.*\n/m, "");
575
+ content = content.replace(/^\s*RUN bunx prisma generate\n/m, "");
576
+ content = content.replace(
577
+ /^COPY docker\/app\/docker-entrypoint\.sh .*$\n/m,
578
+ "",
579
+ );
520
580
  content = content.replace(
521
- /^\s*# generate only needs the schema on disk, not a live database\n/m,
581
+ /^RUN sed -i 's\/\\r\$\/\/' \/app\/docker-entrypoint\.sh && chmod \+x \/app\/docker-entrypoint\.sh\n/m,
522
582
  "",
523
583
  );
524
584
  content = content.replace(
525
- /^\s*RUN if \[ -f src\/infra\/prisma\/schemas\/schema\.prisma \]; then bunx prisma generate; fi\n/m,
585
+ /^ENTRYPOINT \["\/bin\/sh", "\/app\/docker-entrypoint\.sh"\]\n/m,
526
586
  "",
527
587
  );
528
588
  }
@@ -530,11 +590,47 @@ async function patchDockerfile(projectDir: string, selection: FeatureSelection)
530
590
  await writeText(projectDir, relativePath, content);
531
591
  }
532
592
 
533
- function buildDockerAppEnv(selection: FeatureSelection): string {
593
+ const DOCKER_KAFKA_ENV = `# KRaft broker config (Confluent cp-kafka 7.6.1)
594
+ CLUSTER_ID=MkU3OEVBNTcwNTJENDM2Qk
595
+ KAFKA_NODE_ID=0
596
+ KAFKA_PROCESS_ROLES=broker,controller
597
+ KAFKA_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
598
+ KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
599
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
600
+ KAFKA_CONTROLLER_QUORUM_VOTERS=0@kafka:9093
601
+ KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER
602
+ KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT
603
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
604
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1
605
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1
606
+ KAFKA_LOG_DIRS=/tmp/kraft-combined-logs
607
+ `;
608
+
609
+ const DOCKER_OTEL_ENV =
610
+ "# otel-dev runs with CLI flags in Dockerfile; add overrides here if needed.\n";
611
+
612
+ const DOCKER_SPOTLIGHT_ENV =
613
+ "# Official Spotlight image; add overrides here if needed.\n";
614
+
615
+ function buildDockerPostgresEnv(packageName: string): string {
616
+ const name = toPostgresName(packageName);
617
+ return `POSTGRES_USER=${name}
618
+ POSTGRES_PASSWORD=${name}
619
+ POSTGRES_DB=${name}
620
+ `;
621
+ }
622
+
623
+ function buildDockerAppEnv(
624
+ selection: FeatureSelection,
625
+ packageName: string,
626
+ ): string {
534
627
  const lines = [
535
628
  "# Docker-only overrides (loaded after root .env)",
536
629
  "NODE_ENV=production",
537
630
  ];
631
+ if (selection.postgres) {
632
+ lines.push(`DATABASE_URL=${buildDatabaseUrl(packageName, "postgres")}`);
633
+ }
538
634
  if (selection.kafka) {
539
635
  lines.push("KAFKA_BROKERS=kafka:9092");
540
636
  }
@@ -547,11 +643,45 @@ function buildDockerAppEnv(selection: FeatureSelection): string {
547
643
  return `${lines.join("\n")}\n`;
548
644
  }
549
645
 
646
+ function buildAppDependsOn(selection: FeatureSelection): string {
647
+ const deps: string[] = [];
648
+ if (selection.postgres) {
649
+ deps.push(` postgres:
650
+ condition: service_healthy`);
651
+ }
652
+ if (selection.kafka) {
653
+ deps.push(` kafka:
654
+ condition: service_started`);
655
+ }
656
+ if (deps.length === 0) return "";
657
+ return ` depends_on:
658
+ ${deps.join("\n")}
659
+ `;
660
+ }
661
+
550
662
  function generateDockerCompose(selection: FeatureSelection): string {
551
- const appDependsOn = selection.kafka
552
- ? ` depends_on:
553
- kafka:
554
- condition: service_started
663
+ const appDependsOn = buildAppDependsOn(selection);
664
+
665
+ const postgresService = selection.postgres
666
+ ? `
667
+ postgres:
668
+ build:
669
+ context: docker/postgres
670
+ hostname: postgres
671
+ ports:
672
+ - "\${POSTGRES_PORT:-5432}:5432"
673
+ env_file:
674
+ - docker/postgres/.env
675
+ volumes:
676
+ - postgres_data:/var/lib/postgresql/data
677
+ networks:
678
+ - app_network
679
+ healthcheck:
680
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
681
+ interval: 5s
682
+ timeout: 5s
683
+ retries: 5
684
+ restart: unless-stopped
555
685
  `
556
686
  : "";
557
687
 
@@ -609,7 +739,7 @@ function generateDockerCompose(selection: FeatureSelection): string {
609
739
  target: build
610
740
  args:
611
741
  PORT: \${PORT:-3131}
612
- command: /app/server
742
+ command: ./server
613
743
  volumes:
614
744
  - ./src:/app/src
615
745
  networks:
@@ -622,27 +752,35 @@ function generateDockerCompose(selection: FeatureSelection): string {
622
752
  - "\${PORT:-3131}:\${PORT:-3131}"
623
753
  restart: unless-stopped
624
754
  env_file:
625
- - .env
755
+ - path: .env
756
+ required: false
626
757
  - docker/app/.env
627
758
  ${appDependsOn} healthcheck:
628
- test: ["CMD", "curl", "-f", "http://localhost:\${PORT}/healthz"]
759
+ test: ["CMD", "curl", "-f", "http://localhost:\${PORT:-3131}/healthz"]
629
760
  interval: 600s
630
761
  timeout: 300s
631
762
  retries: 1
632
- start_period: 40s
633
- ${kafkaService}${otelService}${spotlightService}
763
+ start_period: 90s
764
+ ${postgresService}${kafkaService}${otelService}${spotlightService}
634
765
  networks:
635
766
  app_network:
636
767
  driver: bridge
637
- `;
768
+ ${selection.postgres ? "\nvolumes:\n postgres_data:\n" : ""}`;
638
769
  }
639
770
 
640
771
  async function patchDockerCompose(
641
772
  projectDir: string,
642
773
  selection: FeatureSelection,
774
+ packageName: string,
643
775
  ) {
644
776
  if (!selection.docker) return;
645
777
 
778
+ if (!selection.postgres) {
779
+ await removePaths(projectDir, [
780
+ "docker/postgres",
781
+ "docker/app/docker-entrypoint.sh",
782
+ ]);
783
+ }
646
784
  if (!selection.kafka) {
647
785
  await removePaths(projectDir, ["docker/kafka"]);
648
786
  }
@@ -656,9 +794,29 @@ async function patchDockerCompose(
656
794
  await writeText(
657
795
  projectDir,
658
796
  "docker/app/.env",
659
- buildDockerAppEnv(selection),
797
+ buildDockerAppEnv(selection, packageName),
798
+ );
799
+ if (selection.postgres) {
800
+ await writeText(
801
+ projectDir,
802
+ "docker/postgres/.env",
803
+ buildDockerPostgresEnv(packageName),
804
+ );
805
+ }
806
+ if (selection.kafka) {
807
+ await writeText(projectDir, "docker/kafka/.env", DOCKER_KAFKA_ENV);
808
+ }
809
+ if (selection.otel) {
810
+ await writeText(projectDir, "docker/otel/.env", DOCKER_OTEL_ENV);
811
+ }
812
+ if (selection.sentry) {
813
+ await writeText(projectDir, "docker/spotlight/.env", DOCKER_SPOTLIGHT_ENV);
814
+ }
815
+ await writeText(
816
+ projectDir,
817
+ "docker-compose.yaml",
818
+ generateDockerCompose(selection),
660
819
  );
661
- await writeText(projectDir, "docker-compose.yaml", generateDockerCompose(selection));
662
820
  }
663
821
 
664
822
  export async function applyFeatures(
@@ -679,7 +837,7 @@ export async function applyFeatures(
679
837
  await patchPackageJson(projectDir, selection);
680
838
  await patchEnvExample(projectDir, selection, packageName);
681
839
  await patchDockerfile(projectDir, selection);
682
- await patchDockerCompose(projectDir, selection);
840
+ await patchDockerCompose(projectDir, selection, packageName);
683
841
 
684
842
  if (disabled.length > 0) {
685
843
  log.success("Feature selection applied");
package/src/prompts.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  ALL_FEATURES,
4
4
  FEATURES,
5
5
  MINIMAL_FEATURES,
6
+ normalizeFeatureSelection,
6
7
  type FeatureId,
7
8
  type FeatureSelection,
8
9
  formatFeatureSelection,
@@ -154,6 +155,12 @@ export async function promptFeatureSelection(
154
155
  const feature = FEATURES[activeIndex];
155
156
  if (!feature) break;
156
157
  selection[feature.id as FeatureId] = !selection[feature.id as FeatureId];
158
+ if (feature.id === "postgres" && selection.postgres) {
159
+ selection.docker = true;
160
+ }
161
+ if (feature.id === "docker" && !selection.docker) {
162
+ selection.postgres = false;
163
+ }
157
164
  lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
158
165
  break;
159
166
  }
@@ -166,6 +173,7 @@ export async function promptFeatureSelection(
166
173
  lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
167
174
  break;
168
175
  case "confirm":
176
+ Object.assign(selection, normalizeFeatureSelection(selection));
169
177
  restoreTerminal(onData);
170
178
  stdout.write("\n");
171
179
  resolve(selection);