kavoru 0.9.0 → 0.9.2

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
@@ -51,6 +51,7 @@ During setup you can pick which integrations to scaffold. Core is always include
51
51
  | `otel` | OpenTelemetry |
52
52
  | `sentry` | Sentry + Spotlight |
53
53
  | `kafka` | Kafka producer/consumer|
54
+ | `redis` | Redis cache + CRUD API |
54
55
  | `websocket` | WebSocket realtime |
55
56
  | `resend` | Resend email |
56
57
  | `cron` | Cron jobs |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kavoru",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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
@@ -37,7 +37,7 @@ Options:
37
37
  --no-features <list> Comma-separated features to exclude
38
38
 
39
39
  Features:
40
- auth, postgres, otel, sentry, kafka, websocket, resend, cron, docker, cli
40
+ auth, postgres, otel, sentry, kafka, redis, websocket, resend, cron, docker, cli
41
41
  (prisma is accepted as an alias for postgres; kavoru-cli for cli)
42
42
 
43
43
  Examples:
package/src/features.ts CHANGED
@@ -8,6 +8,7 @@ export type FeatureId =
8
8
  | "otel"
9
9
  | "sentry"
10
10
  | "kafka"
11
+ | "redis"
11
12
  | "websocket"
12
13
  | "resend"
13
14
  | "cron"
@@ -53,6 +54,11 @@ export const FEATURES: FeatureDef[] = [
53
54
  label: "Kafka",
54
55
  description: "Producer, consumer, and example HTTP endpoints",
55
56
  },
57
+ {
58
+ id: "redis",
59
+ label: "Redis",
60
+ description: "Cache client and CRUD HTTP endpoints",
61
+ },
56
62
  {
57
63
  id: "websocket",
58
64
  label: "WebSockets",
@@ -112,6 +118,12 @@ const FEATURE_PATHS: Record<FeatureId, string[]> = {
112
118
  "src/models/schemas/kafka.ts",
113
119
  "__tests__/kafka.test.ts",
114
120
  ],
121
+ redis: [
122
+ "src/modules/redis",
123
+ "src/infra/redis",
124
+ "src/models/schemas/redis.ts",
125
+ "__tests__/redis.test.ts",
126
+ ],
115
127
  websocket: [
116
128
  "src/modules/realtime",
117
129
  "src/models/schemas/realtime.ts",
@@ -126,8 +138,10 @@ const FEATURE_PATHS: Record<FeatureId, string[]> = {
126
138
  "kavoru.cmd",
127
139
  "scripts/kavoru-cli.ts",
128
140
  "scripts/generate-module.ts",
141
+ "scripts/generate-repository.ts",
129
142
  "scripts/link-cli.ts",
130
143
  "__tests__/generate-module.test.ts",
144
+ "__tests__/generate-repository.test.ts",
131
145
  "__tests__/kavoru-cli.test.ts",
132
146
  "__tests__/link-cli.test.ts",
133
147
  ],
@@ -151,6 +165,7 @@ const FEATURE_DEPENDENCIES: Partial<
151
165
  },
152
166
  sentry: { dependencies: ["@sentry/elysia"] },
153
167
  kafka: { dependencies: ["kafkajs"] },
168
+ redis: { dependencies: ["ioredis"] },
154
169
  resend: { dependencies: ["resend"] },
155
170
  cron: { dependencies: ["@elysiajs/cron"] },
156
171
  };
@@ -185,6 +200,14 @@ export function buildDatabaseUrl(
185
200
  return `postgresql://${name}:${name}@${host}:${port}/${name}`;
186
201
  }
187
202
 
203
+ export function buildRedisCredentials(packageName: string): {
204
+ username: string;
205
+ password: string;
206
+ } {
207
+ const name = toPostgresName(packageName);
208
+ return { username: name, password: name };
209
+ }
210
+
188
211
  export function normalizeFeatureSelection(
189
212
  selection: FeatureSelection,
190
213
  ): FeatureSelection {
@@ -340,6 +363,8 @@ async function patchModulesIndex(
340
363
  }
341
364
 
342
365
  export function buildEntryIndex(selection: FeatureSelection): string {
366
+ const needsAsyncStartup = selection.kafka || selection.redis;
367
+
343
368
  const imports = [
344
369
  selection.sentry
345
370
  ? 'import { initSentry, flushSentry } from "./infra/sentry";'
@@ -350,9 +375,12 @@ export function buildEntryIndex(selection: FeatureSelection): string {
350
375
  selection.kafka
351
376
  ? 'import { startKafka, stopKafka } from "./infra/kafka";'
352
377
  : null,
378
+ selection.redis
379
+ ? 'import { connectRedis, stopRedis } from "./infra/redis";'
380
+ : null,
353
381
  'import { HttpServer } from "./server/index";',
354
382
  'import { logger } from "./common/logger";',
355
- selection.kafka ? 'import { InternalServerError } from "elysia";' : null,
383
+ needsAsyncStartup ? 'import { InternalServerError } from "elysia";' : null,
356
384
  ].filter(Boolean) as string[];
357
385
 
358
386
  const body: string[] = [];
@@ -366,14 +394,18 @@ export function buildEntryIndex(selection: FeatureSelection): string {
366
394
 
367
395
  body.push("", "const server = new HttpServer();", "");
368
396
 
369
- if (selection.kafka) {
397
+ if (needsAsyncStartup) {
398
+ const startupCalls: string[] = [];
399
+ if (selection.kafka) startupCalls.push(" await startKafka();");
400
+ if (selection.redis) startupCalls.push(" await connectRedis();");
401
+
370
402
  body.push(
371
403
  "void server.start().then(async () => {",
372
404
  " try {",
373
- " await startKafka();",
405
+ ...startupCalls,
374
406
  " } catch (error) {",
375
- ' logger.error("Failed to start Kafka", { error });',
376
- ' throw new InternalServerError("Failed to start Kafka");',
407
+ ' logger.error("Failed to start infrastructure", { error });',
408
+ ' throw new InternalServerError("Failed to start infrastructure");',
377
409
  " }",
378
410
  "});",
379
411
  );
@@ -392,6 +424,9 @@ export function buildEntryIndex(selection: FeatureSelection): string {
392
424
  if (selection.kafka) {
393
425
  body.push(" await stopKafka();");
394
426
  }
427
+ if (selection.redis) {
428
+ body.push(" await stopRedis();");
429
+ }
395
430
  if (selection.sentry) {
396
431
  body.push(" await flushSentry();");
397
432
  }
@@ -566,6 +601,19 @@ export function buildEnvExample(
566
601
  );
567
602
  }
568
603
 
604
+ if (selection.redis) {
605
+ const { username, password } = buildRedisCredentials(packageName);
606
+ lines.push(
607
+ "# Redis (enabled by default in development; disabled in test)",
608
+ "# Start server: docker compose up -d redis",
609
+ "# REDIS_ENABLED=false",
610
+ "REDIS_URL=redis://localhost:6379",
611
+ `REDIS_USERNAME=${username}`,
612
+ `REDIS_PASSWORD=${password}`,
613
+ "",
614
+ );
615
+ }
616
+
569
617
  if (selection.resend) {
570
618
  lines.push(
571
619
  "# Resend (disabled when RESEND_API_KEY is unset; always disabled in test)",
@@ -618,9 +666,25 @@ async function patchDockerfile(
618
666
  );
619
667
  }
620
668
 
669
+ if (!selection.cli) {
670
+ content = content.replace(/^COPY bin \.\/bin\n/m, "");
671
+ content = content.replace(
672
+ /^COPY scripts\/link-cli\.ts \.\/scripts\/link-cli\.ts\n/m,
673
+ "",
674
+ );
675
+ content = content.replace(/^ENV PATH="\/root\/\.bun\/bin:\$\{PATH\}"\n/m, "");
676
+ }
677
+
621
678
  await writeText(projectDir, relativePath, content);
622
679
  }
623
680
 
681
+ function buildDockerRedisEnv(packageName: string): string {
682
+ const { username, password } = buildRedisCredentials(packageName);
683
+ return `REDIS_USERNAME=${username}
684
+ REDIS_PASSWORD=${password}
685
+ `;
686
+ }
687
+
624
688
  const DOCKER_KAFKA_ENV = `# KRaft broker config (Confluent cp-kafka 7.6.1)
625
689
  CLUSTER_ID=MkU3OEVBNTcwNTJENDM2Qk
626
690
  KAFKA_NODE_ID=0
@@ -665,6 +729,12 @@ function buildDockerAppEnv(
665
729
  if (selection.kafka) {
666
730
  lines.push("KAFKA_BROKERS=kafka:9092");
667
731
  }
732
+ if (selection.redis) {
733
+ const { username, password } = buildRedisCredentials(packageName);
734
+ lines.push("REDIS_URL=redis://redis:6379");
735
+ lines.push(`REDIS_USERNAME=${username}`);
736
+ lines.push(`REDIS_PASSWORD=${password}`);
737
+ }
668
738
  if (selection.otel) {
669
739
  lines.push("OTEL_EXPORTER_OTLP_ENDPOINT=http://otel:4318/v1/traces");
670
740
  }
@@ -684,6 +754,10 @@ function buildAppDependsOn(selection: FeatureSelection): string {
684
754
  deps.push(` kafka:
685
755
  condition: service_started`);
686
756
  }
757
+ if (selection.redis) {
758
+ deps.push(` redis:
759
+ condition: service_healthy`);
760
+ }
687
761
  if (deps.length === 0) return "";
688
762
  return ` depends_on:
689
763
  ${deps.join("\n")}
@@ -732,6 +806,31 @@ function generateDockerCompose(selection: FeatureSelection): string {
732
806
  `
733
807
  : "";
734
808
 
809
+ const redisService = selection.redis
810
+ ? `
811
+ redis:
812
+ build:
813
+ context: docker/redis
814
+ hostname: redis
815
+ ports:
816
+ - "\${REDIS_PORT:-6379}:6379"
817
+ env_file:
818
+ - docker/redis/.env
819
+ networks:
820
+ - app_network
821
+ healthcheck:
822
+ test:
823
+ [
824
+ "CMD-SHELL",
825
+ "redis-cli --user $$REDIS_USERNAME -a $$REDIS_PASSWORD ping | grep -q PONG",
826
+ ]
827
+ interval: 5s
828
+ timeout: 3s
829
+ retries: 5
830
+ restart: unless-stopped
831
+ `
832
+ : "";
833
+
735
834
  const otelService = selection.otel
736
835
  ? `
737
836
  otel:
@@ -792,7 +891,7 @@ ${appDependsOn} healthcheck:
792
891
  timeout: 300s
793
892
  retries: 1
794
893
  start_period: 90s
795
- ${postgresService}${kafkaService}${otelService}${spotlightService}
894
+ ${postgresService}${kafkaService}${redisService}${otelService}${spotlightService}
796
895
  networks:
797
896
  app_network:
798
897
  driver: bridge
@@ -815,6 +914,9 @@ async function patchDockerCompose(
815
914
  if (!selection.kafka) {
816
915
  await removePaths(projectDir, ["docker/kafka"]);
817
916
  }
917
+ if (!selection.redis) {
918
+ await removePaths(projectDir, ["docker/redis"]);
919
+ }
818
920
  if (!selection.otel) {
819
921
  await removePaths(projectDir, ["docker/otel"]);
820
922
  }
@@ -837,6 +939,13 @@ async function patchDockerCompose(
837
939
  if (selection.kafka) {
838
940
  await writeText(projectDir, "docker/kafka/.env", DOCKER_KAFKA_ENV);
839
941
  }
942
+ if (selection.redis) {
943
+ await writeText(
944
+ projectDir,
945
+ "docker/redis/.env",
946
+ buildDockerRedisEnv(packageName),
947
+ );
948
+ }
840
949
  if (selection.otel) {
841
950
  await writeText(projectDir, "docker/otel/.env", DOCKER_OTEL_ENV);
842
951
  }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { parseArgs, printHelp, printVersion } from "./args";
4
4
  import { runCli } from "./cli";
5
5
  import { log } from "./log";
6
- import { printModuleHelp, runModuleCommand } from "./module-cli";
6
+ import { printModuleHelp, printRepositoryHelp, runModuleCommand, runRepositoryCommand } from "./module-cli";
7
7
 
8
8
  async function main(): Promise<void> {
9
9
  try {
@@ -19,6 +19,16 @@ async function main(): Promise<void> {
19
19
  return;
20
20
  }
21
21
 
22
+ if (argv[0] === "repository") {
23
+ if (argv.includes("-h") || argv.includes("--help")) {
24
+ printRepositoryHelp();
25
+ return;
26
+ }
27
+
28
+ await runRepositoryCommand(argv.slice(1));
29
+ return;
30
+ }
31
+
22
32
  const options = parseArgs(argv);
23
33
 
24
34
  if (options.help) {
package/src/module-cli.ts CHANGED
@@ -9,9 +9,15 @@ function findProjectRoot(cwd: string): string {
9
9
  const packageJson = path.join(current, "package.json");
10
10
  const localCli = path.join(current, "scripts/kavoru-cli.ts");
11
11
  const moduleScript = path.join(current, "scripts/generate-module.ts");
12
+ const repositoryScript = path.join(
13
+ current,
14
+ "scripts/generate-repository.ts",
15
+ );
12
16
  if (
13
17
  existsSync(packageJson) &&
14
- (existsSync(localCli) || existsSync(moduleScript))
18
+ (existsSync(localCli) ||
19
+ existsSync(moduleScript) ||
20
+ existsSync(repositoryScript))
15
21
  ) {
16
22
  return current;
17
23
  }
@@ -28,26 +34,24 @@ function findProjectRoot(cwd: string): string {
28
34
  );
29
35
  }
30
36
 
31
- export async function runModuleCommand(argv: string[]): Promise<void> {
32
- const force = argv.includes("--force") || argv.includes("-f");
33
- const name = argv.find((arg) => !arg.startsWith("-"));
34
-
35
- if (!name) {
36
- throw new Error("Usage: kavoru module <module-name> [--force]");
37
- }
38
-
39
- const projectDir = findProjectRoot(process.cwd());
37
+ async function runProjectScript(
38
+ projectDir: string,
39
+ command: "module" | "repository",
40
+ name: string,
41
+ force: boolean,
42
+ ): Promise<void> {
40
43
  const localCli = path.join(projectDir, "scripts/kavoru-cli.ts");
41
- const scriptPath = existsSync(localCli)
42
- ? localCli
43
- : path.join(projectDir, "scripts/generate-module.ts");
44
+ const fallbackScript =
45
+ command === "module"
46
+ ? path.join(projectDir, "scripts/generate-module.ts")
47
+ : path.join(projectDir, "scripts/generate-repository.ts");
48
+
49
+ const scriptPath = existsSync(localCli) ? localCli : fallbackScript;
44
50
  const cmd = existsSync(localCli)
45
- ? ["bun", scriptPath, "module", name]
51
+ ? ["bun", scriptPath, command, name]
46
52
  : ["bun", scriptPath, name];
47
53
  if (force) cmd.push("--force");
48
54
 
49
- log.info(`Generating module "${name}" in ${projectDir}`);
50
-
51
55
  const proc = Bun.spawn(cmd, {
52
56
  cwd: projectDir,
53
57
  stdout: "inherit",
@@ -60,6 +64,39 @@ export async function runModuleCommand(argv: string[]): Promise<void> {
60
64
  }
61
65
  }
62
66
 
67
+ export async function runModuleCommand(argv: string[]): Promise<void> {
68
+ const force = argv.includes("--force") || argv.includes("-f");
69
+ const name = argv.find((arg) => !arg.startsWith("-"));
70
+
71
+ if (!name) {
72
+ throw new Error("Usage: kavoru module <module-name> [--force]");
73
+ }
74
+
75
+ const projectDir = findProjectRoot(process.cwd());
76
+ log.info(`Generating module "${name}" in ${projectDir}`);
77
+ await runProjectScript(projectDir, "module", name, force);
78
+ }
79
+
80
+ export async function runRepositoryCommand(argv: string[]): Promise<void> {
81
+ const force = argv.includes("--force") || argv.includes("-f");
82
+ const name = argv.find((arg) => !arg.startsWith("-"));
83
+
84
+ if (!name) {
85
+ throw new Error("Usage: kavoru repository <repository-name> [--force]");
86
+ }
87
+
88
+ const projectDir = findProjectRoot(process.cwd());
89
+ const prismaConfig = path.join(projectDir, "prisma.config.ts");
90
+ if (!existsSync(prismaConfig)) {
91
+ throw new Error(
92
+ "PostgreSQL/Prisma is not enabled in this project. Scaffold with the postgres feature first.",
93
+ );
94
+ }
95
+
96
+ log.info(`Generating repository "${name}" in ${projectDir}`);
97
+ await runProjectScript(projectDir, "repository", name, force);
98
+ }
99
+
63
100
  export function printModuleHelp(): void {
64
101
  console.log(`\
65
102
  Usage: kavoru module <module-name> [options]
@@ -77,3 +114,23 @@ Examples:
77
114
  kavoru module user-profile --force
78
115
  `);
79
116
  }
117
+
118
+ export function printRepositoryHelp(): void {
119
+ console.log(`\
120
+ Usage: kavoru repository <repository-name> [options]
121
+
122
+ Generate Prisma model + repository (requires postgres/prisma feature):
123
+ src/infra/prisma/schemas/<name>.prisma
124
+ src/infra/prisma/repositories/<name>.ts
125
+
126
+ Runs bunx prisma generate when finished.
127
+
128
+ Options:
129
+ -f, --force Overwrite existing schema/repository files
130
+ -h, --help Show help
131
+
132
+ Examples:
133
+ kavoru repository user
134
+ kavoru repository billing --force
135
+ `);
136
+ }