kavoru 0.8.8 → 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.8",
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
  }
@@ -552,11 +612,25 @@ const DOCKER_OTEL_ENV =
552
612
  const DOCKER_SPOTLIGHT_ENV =
553
613
  "# Official Spotlight image; add overrides here if needed.\n";
554
614
 
555
- function buildDockerAppEnv(selection: FeatureSelection): string {
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 {
556
627
  const lines = [
557
628
  "# Docker-only overrides (loaded after root .env)",
558
629
  "NODE_ENV=production",
559
630
  ];
631
+ if (selection.postgres) {
632
+ lines.push(`DATABASE_URL=${buildDatabaseUrl(packageName, "postgres")}`);
633
+ }
560
634
  if (selection.kafka) {
561
635
  lines.push("KAFKA_BROKERS=kafka:9092");
562
636
  }
@@ -569,11 +643,45 @@ function buildDockerAppEnv(selection: FeatureSelection): string {
569
643
  return `${lines.join("\n")}\n`;
570
644
  }
571
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
+
572
662
  function generateDockerCompose(selection: FeatureSelection): string {
573
- const appDependsOn = selection.kafka
574
- ? ` depends_on:
575
- kafka:
576
- 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
577
685
  `
578
686
  : "";
579
687
 
@@ -631,7 +739,7 @@ function generateDockerCompose(selection: FeatureSelection): string {
631
739
  target: build
632
740
  args:
633
741
  PORT: \${PORT:-3131}
634
- command: /app/server
742
+ command: ./server
635
743
  volumes:
636
744
  - ./src:/app/src
637
745
  networks:
@@ -644,27 +752,35 @@ function generateDockerCompose(selection: FeatureSelection): string {
644
752
  - "\${PORT:-3131}:\${PORT:-3131}"
645
753
  restart: unless-stopped
646
754
  env_file:
647
- - .env
755
+ - path: .env
756
+ required: false
648
757
  - docker/app/.env
649
758
  ${appDependsOn} healthcheck:
650
- test: ["CMD", "curl", "-f", "http://localhost:\${PORT}/healthz"]
759
+ test: ["CMD", "curl", "-f", "http://localhost:\${PORT:-3131}/healthz"]
651
760
  interval: 600s
652
761
  timeout: 300s
653
762
  retries: 1
654
- start_period: 40s
655
- ${kafkaService}${otelService}${spotlightService}
763
+ start_period: 90s
764
+ ${postgresService}${kafkaService}${otelService}${spotlightService}
656
765
  networks:
657
766
  app_network:
658
767
  driver: bridge
659
- `;
768
+ ${selection.postgres ? "\nvolumes:\n postgres_data:\n" : ""}`;
660
769
  }
661
770
 
662
771
  async function patchDockerCompose(
663
772
  projectDir: string,
664
773
  selection: FeatureSelection,
774
+ packageName: string,
665
775
  ) {
666
776
  if (!selection.docker) return;
667
777
 
778
+ if (!selection.postgres) {
779
+ await removePaths(projectDir, [
780
+ "docker/postgres",
781
+ "docker/app/docker-entrypoint.sh",
782
+ ]);
783
+ }
668
784
  if (!selection.kafka) {
669
785
  await removePaths(projectDir, ["docker/kafka"]);
670
786
  }
@@ -678,8 +794,15 @@ async function patchDockerCompose(
678
794
  await writeText(
679
795
  projectDir,
680
796
  "docker/app/.env",
681
- buildDockerAppEnv(selection),
797
+ buildDockerAppEnv(selection, packageName),
682
798
  );
799
+ if (selection.postgres) {
800
+ await writeText(
801
+ projectDir,
802
+ "docker/postgres/.env",
803
+ buildDockerPostgresEnv(packageName),
804
+ );
805
+ }
683
806
  if (selection.kafka) {
684
807
  await writeText(projectDir, "docker/kafka/.env", DOCKER_KAFKA_ENV);
685
808
  }
@@ -689,7 +812,11 @@ async function patchDockerCompose(
689
812
  if (selection.sentry) {
690
813
  await writeText(projectDir, "docker/spotlight/.env", DOCKER_SPOTLIGHT_ENV);
691
814
  }
692
- await writeText(projectDir, "docker-compose.yaml", generateDockerCompose(selection));
815
+ await writeText(
816
+ projectDir,
817
+ "docker-compose.yaml",
818
+ generateDockerCompose(selection),
819
+ );
693
820
  }
694
821
 
695
822
  export async function applyFeatures(
@@ -710,7 +837,7 @@ export async function applyFeatures(
710
837
  await patchPackageJson(projectDir, selection);
711
838
  await patchEnvExample(projectDir, selection, packageName);
712
839
  await patchDockerfile(projectDir, selection);
713
- await patchDockerCompose(projectDir, selection);
840
+ await patchDockerCompose(projectDir, selection, packageName);
714
841
 
715
842
  if (disabled.length > 0) {
716
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);