kavoru 0.8.12 → 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.8.12",
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/cli.ts CHANGED
@@ -155,10 +155,12 @@ export async function runCli(options: CliOptions): Promise<void> {
155
155
  console.log(" bun install");
156
156
  }
157
157
  if (featureSelection.cli) {
158
+ console.log(" ./kavoru module <name> # Git Bash / macOS / Linux");
159
+ console.log(" .\\kavoru.cmd module <name> # Windows cmd / PowerShell");
158
160
  if (!options.install) {
159
- console.log(" bun run link-cli # optional: put kavoru on PATH");
161
+ console.log(" bun run link-cli # bare kavoru on PATH (~/.bun/bin)");
160
162
  }
161
- console.log(" kavoru module <name> # generate modules");
163
+ console.log(" bunx kavoru@latest module <name> # works without PATH setup");
162
164
  }
163
165
  console.log(" bunx kavoru@latest <dir> # scaffold another project");
164
166
  console.log(" bun run dev");
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
  }
@@ -498,8 +533,13 @@ async function patchPackageJson(
498
533
 
499
534
  if (!selection.cli) {
500
535
  delete pkg.bin;
536
+ if (pkg.scripts?.postinstall === "bun scripts/link-cli.ts") {
537
+ delete pkg.scripts.postinstall;
538
+ }
501
539
  } else {
502
540
  pkg.bin = { kavoru: "./bin/kavoru.js" };
541
+ pkg.scripts ??= {};
542
+ pkg.scripts.postinstall = "bun scripts/link-cli.ts";
503
543
  }
504
544
 
505
545
  await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
@@ -561,6 +601,19 @@ export function buildEnvExample(
561
601
  );
562
602
  }
563
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
+
564
617
  if (selection.resend) {
565
618
  lines.push(
566
619
  "# Resend (disabled when RESEND_API_KEY is unset; always disabled in test)",
@@ -613,9 +666,25 @@ async function patchDockerfile(
613
666
  );
614
667
  }
615
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
+
616
678
  await writeText(projectDir, relativePath, content);
617
679
  }
618
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
+
619
688
  const DOCKER_KAFKA_ENV = `# KRaft broker config (Confluent cp-kafka 7.6.1)
620
689
  CLUSTER_ID=MkU3OEVBNTcwNTJENDM2Qk
621
690
  KAFKA_NODE_ID=0
@@ -660,6 +729,12 @@ function buildDockerAppEnv(
660
729
  if (selection.kafka) {
661
730
  lines.push("KAFKA_BROKERS=kafka:9092");
662
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
+ }
663
738
  if (selection.otel) {
664
739
  lines.push("OTEL_EXPORTER_OTLP_ENDPOINT=http://otel:4318/v1/traces");
665
740
  }
@@ -679,6 +754,10 @@ function buildAppDependsOn(selection: FeatureSelection): string {
679
754
  deps.push(` kafka:
680
755
  condition: service_started`);
681
756
  }
757
+ if (selection.redis) {
758
+ deps.push(` redis:
759
+ condition: service_healthy`);
760
+ }
682
761
  if (deps.length === 0) return "";
683
762
  return ` depends_on:
684
763
  ${deps.join("\n")}
@@ -727,6 +806,31 @@ function generateDockerCompose(selection: FeatureSelection): string {
727
806
  `
728
807
  : "";
729
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
+
730
834
  const otelService = selection.otel
731
835
  ? `
732
836
  otel:
@@ -787,7 +891,7 @@ ${appDependsOn} healthcheck:
787
891
  timeout: 300s
788
892
  retries: 1
789
893
  start_period: 90s
790
- ${postgresService}${kafkaService}${otelService}${spotlightService}
894
+ ${postgresService}${kafkaService}${redisService}${otelService}${spotlightService}
791
895
  networks:
792
896
  app_network:
793
897
  driver: bridge
@@ -810,6 +914,9 @@ async function patchDockerCompose(
810
914
  if (!selection.kafka) {
811
915
  await removePaths(projectDir, ["docker/kafka"]);
812
916
  }
917
+ if (!selection.redis) {
918
+ await removePaths(projectDir, ["docker/redis"]);
919
+ }
813
920
  if (!selection.otel) {
814
921
  await removePaths(projectDir, ["docker/otel"]);
815
922
  }
@@ -832,6 +939,13 @@ async function patchDockerCompose(
832
939
  if (selection.kafka) {
833
940
  await writeText(projectDir, "docker/kafka/.env", DOCKER_KAFKA_ENV);
834
941
  }
942
+ if (selection.redis) {
943
+ await writeText(
944
+ projectDir,
945
+ "docker/redis/.env",
946
+ buildDockerRedisEnv(packageName),
947
+ );
948
+ }
835
949
  if (selection.otel) {
836
950
  await writeText(projectDir, "docker/otel/.env", DOCKER_OTEL_ENV);
837
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
+ }