kavoru 0.9.2 → 0.9.4

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
@@ -1,101 +1,102 @@
1
- # kavoru (CLI)
2
-
3
- Scaffold a new [Kavoru](https://github.com/mertthesamael/Kavoru) backend — ElysiaJS, Bun, TypeScript, Prisma, and the full production starter stack.
4
-
5
- ## Usage
6
-
7
- After publishing to npm:
8
-
9
- ```bash
10
- bunx kavoru@latest my-api
11
- cd my-api
12
- bun run dev
13
- ```
14
-
15
- Always use `@latest` so you get the newest published CLI. Equivalent to `bunx --bun kavoru@latest`.
16
-
17
- **Stale CLI after a new publish?** Bun caches `bunx` installs under `%TEMP%\bunx-*-kavoru@latest` and does not auto-refresh. Clear the cache, then run `@latest` again:
18
-
19
- ```powershell
20
- Remove-Item -Recurse -Force "$env:TEMP\bunx-*-kavoru*"
21
- bunx kavoru@latest my-api
22
- ```
23
-
24
- ```bash
25
- rm -rf "${TMPDIR:-/tmp}"/bunx-*-kavoru*
26
- bunx kavoru@latest my-api
27
- ```
28
-
29
- ### Options
30
-
31
- | Flag | Description |
32
- | ------------------- | -------------------------------------------------------- |
33
- | `-h, --help` | Show help |
34
- | `-V, --version` | Show CLI version |
35
- | `-f, --force` | Scaffold into a non-empty directory |
36
- | `--no-install` | Skip `bun install` |
37
- | `--repo owner/name` | Override template repo (default: `mertthesamael/Kavoru`) |
38
- | `--branch name` | Template branch (default: `master`) |
39
- | `--minimal` | Core only health, OpenAPI, response envelope |
40
- | `--features list` | Comma-separated features to include |
41
- | `--no-features list`| Comma-separated features to exclude (default: all on) |
42
-
43
- ### Optional features
44
-
45
- During setup you can pick which integrations to scaffold. Core is always included: health routes, OpenAPI at `/help`, CORS, and the JSON response envelope.
46
-
47
- | ID | Feature |
48
- | ----------- | ---------------------- |
49
- | `auth` | JWT authentication |
50
- | `postgres` | PostgreSQL + Prisma (includes Docker Postgres) |
51
- | `otel` | OpenTelemetry |
52
- | `sentry` | Sentry + Spotlight |
53
- | `kafka` | Kafka producer/consumer|
54
- | `redis` | Redis cache + CRUD API |
55
- | `websocket` | WebSocket realtime |
56
- | `resend` | Resend email |
57
- | `cron` | Cron jobs |
58
- | `docker` | Dockerfile + Compose |
59
- | `cli` | Project CLI (`kavoru module`, bin, root shims) |
60
-
61
- Interactive mode (TTY) shows a checkbox menu (↑↓ move, Space toggle, Enter confirm). Non-interactive runs use the full stack unless you pass flags.
62
-
63
- ### Examples
64
-
65
- ```bash
66
- # Interactive (prompts for project name + feature toggles)
67
- bunx kavoru@latest
68
-
69
- # Current directory
70
- bunx kavoru@latest .
71
-
72
- # Minimal API skeleton
73
- bunx kavoru@latest my-api --minimal
74
-
75
- # Pick specific features
76
- bunx kavoru@latest my-api --features auth,postgres,otel,sentry
77
-
78
- # Full stack minus Kafka and Docker
79
- bunx kavoru@latest my-api --no-features kafka,docker
80
-
81
- # Custom template fork (local dev)
82
- bunx kavoru@latest demo --repo your-user/Kavoru --no-install
83
- ```
84
-
85
- ## Development
86
-
87
- ```bash
88
- cd elysia-template-initializer
89
- bun install
90
- bun test
91
-
92
- # Run locally without publishing
93
- bun run src/index.ts my-test-app
94
- # or
95
- bun link
96
- bunx kavoru@latest my-test-app
97
- ```
98
-
99
- ## License
100
-
101
- MIT
1
+ # kavoru (CLI)
2
+
3
+ Scaffold a new [Kavoru](https://kavoru.com) backend — ElysiaJS, Bun, TypeScript, Prisma, and the full production starter stack.
4
+
5
+ ## Usage
6
+
7
+ After publishing to npm:
8
+
9
+ ```bash
10
+ bunx kavoru@latest my-api
11
+ cd my-api
12
+ docker compose up --build
13
+ ```
14
+
15
+ Always use `@latest` so you get the newest published CLI. Equivalent to `bunx --bun kavoru@latest`.
16
+
17
+ Every scaffold includes **Docker Compose** (`docker-compose.yaml` + `docker/app/`). Infra services (Postgres, Kafka, Redis, OTEL, Spotlight) are added when you select those features — the app service is always present.
18
+
19
+ **Stale CLI after a new publish?** Bun caches `bunx` installs under `%TEMP%\bunx-*-kavoru@latest` and does not auto-refresh. Clear the cache, then run `@latest` again:
20
+
21
+ ```powershell
22
+ Remove-Item -Recurse -Force "$env:TEMP\bunx-*-kavoru*"
23
+ bunx kavoru@latest my-api
24
+ ```
25
+
26
+ ```bash
27
+ rm -rf "${TMPDIR:-/tmp}"/bunx-*-kavoru*
28
+ bunx kavoru@latest my-api
29
+ ```
30
+
31
+ ### Options
32
+
33
+ | Flag | Description |
34
+ | ------------------- | -------------------------------------------------------- |
35
+ | `-h, --help` | Show help |
36
+ | `-V, --version` | Show CLI version |
37
+ | `-f, --force` | Scaffold into a non-empty directory |
38
+ | `--no-install` | Skip `bun install` |
39
+ | `--repo owner/name` | Override template repo (default: `mertthesamael/Kavoru`) |
40
+ | `--branch name` | Template branch (default: `master`) |
41
+ | `--minimal` | Core only health, OpenAPI, response envelope |
42
+ | `--features list` | Comma-separated features to include |
43
+ | `--no-features list`| Comma-separated features to exclude (default: all on) |
44
+
45
+ ### Optional features
46
+
47
+ During setup you can pick which integrations to scaffold. **Docker Compose is always included** (app image + compose file). Core is always included: health routes, OpenAPI at `/help`, CORS, and the JSON response envelope.
48
+
49
+ | ID | Feature |
50
+ | ----------- | ---------------------- |
51
+ | `auth` | JWT authentication |
52
+ | `postgres` | PostgreSQL + Prisma (includes Docker Postgres) |
53
+ | `otel` | OpenTelemetry |
54
+ | `sentry` | Sentry + Spotlight |
55
+ | `kafka` | Kafka producer/consumer|
56
+ | `redis` | Redis cache + CRUD API |
57
+ | `websocket` | WebSocket realtime |
58
+ | `resend` | Resend email |
59
+ | `cron` | Cron jobs |
60
+ | `cli` | Project CLI (`kavoru module`, bin, root shims) |
61
+
62
+ Interactive mode (TTY) shows a checkbox menu (↑↓ move, Space toggle, Enter confirm). Non-interactive runs use the full stack unless you pass flags.
63
+
64
+ ### Examples
65
+
66
+ ```bash
67
+ # Interactive (prompts for project name + feature toggles)
68
+ bunx kavoru@latest
69
+
70
+ # Current directory
71
+ bunx kavoru@latest .
72
+
73
+ # Minimal API skeleton
74
+ bunx kavoru@latest my-api --minimal
75
+
76
+ # Pick specific features
77
+ bunx kavoru@latest my-api --features auth,postgres,otel,sentry
78
+
79
+ # Full stack minus Kafka and Resend
80
+ bunx kavoru@latest my-api --no-features kafka,resend
81
+
82
+ # Custom template fork (local dev)
83
+ bunx kavoru@latest demo --repo your-user/Kavoru --no-install
84
+ ```
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ cd elysia-template-initializer
90
+ bun install
91
+ bun test
92
+
93
+ # Run locally without publishing
94
+ bun run src/index.ts my-test-app
95
+ # or
96
+ bun link
97
+ bunx kavoru@latest my-test-app
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kavoru",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Scaffold a new Kavoru (Elysia + Bun) backend from the official template",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "type": "git",
29
29
  "url": "git+https://github.com/mertthesamael/kavoru-cli.git"
30
30
  },
31
- "homepage": "https://github.com/mertthesamael/Kavoru",
31
+ "homepage": "https://kavoru.com",
32
32
  "bugs": {
33
33
  "url": "https://github.com/mertthesamael/Kavoru/issues"
34
34
  },
package/src/args.ts CHANGED
@@ -1,143 +1,144 @@
1
- import { PACKAGE_VERSION } from "./constants";
2
-
3
- export type CliOptions = {
4
- targetDir: string | undefined;
5
- help: boolean;
6
- version: boolean;
7
- install: boolean;
8
- force: boolean;
9
- repo: string;
10
- branch: string;
11
- minimal: boolean;
12
- features: string | undefined;
13
- noFeatures: string[];
14
- };
15
-
16
- const HELP = `\
17
- Usage: kavoru [options] [directory]
18
- kavoru module <module-name> [options]
19
-
20
- Create a new project from the Kavoru Elysia + Bun template.
21
-
22
- Commands:
23
- module <name> Generate src/modules/<name> (routes, service, types)
24
-
25
- Arguments:
26
- directory Project folder (use "." for current directory)
27
-
28
- Options:
29
- -h, --help Show help
30
- -V, --version Show version
31
- -f, --force Overwrite / use a non-empty target directory
32
- --no-install Skip "bun install" after scaffolding
33
- --repo <owner/name> GitHub template repo (default: mertthesamael/Kavoru)
34
- --branch <name> Template branch (default: master)
35
- --minimal Core only (health, OpenAPI, response envelope)
36
- --features <list> Comma-separated features to include (default: all)
37
- --no-features <list> Comma-separated features to exclude
38
-
39
- Features:
40
- auth, postgres, otel, sentry, kafka, redis, websocket, resend, cron, docker, cli
41
- (prisma is accepted as an alias for postgres; kavoru-cli for cli)
42
-
43
- Examples:
44
- bunx kavoru@latest my-api
45
- bunx kavoru@latest my-api --minimal
46
- bunx kavoru@latest my-api --features auth,postgres,otel
47
- bunx kavoru@latest my-api --no-features kafka,docker,resend
48
- bunx kavoru@latest module users
49
- `;
50
-
51
- export function parseArgs(argv: string[]): CliOptions {
52
- const options: CliOptions = {
53
- targetDir: undefined,
54
- help: false,
55
- version: false,
56
- install: true,
57
- force: false,
58
- repo: "mertthesamael/Kavoru",
59
- branch: "master",
60
- minimal: false,
61
- features: undefined,
62
- noFeatures: [],
63
- };
64
-
65
- const positional: string[] = [];
66
-
67
- for (let i = 0; i < argv.length; i++) {
68
- const arg = argv[i];
69
- if (!arg) continue;
70
-
71
- switch (arg) {
72
- case "-h":
73
- case "--help":
74
- options.help = true;
75
- break;
76
- case "-V":
77
- case "--version":
78
- options.version = true;
79
- break;
80
- case "-f":
81
- case "--force":
82
- options.force = true;
83
- break;
84
- case "--no-install":
85
- options.install = false;
86
- break;
87
- case "--minimal":
88
- options.minimal = true;
89
- break;
90
- case "--features": {
91
- const value = argv[++i];
92
- if (!value) throw new Error("--features requires a comma-separated list.");
93
- options.features = value;
94
- break;
95
- }
96
- case "--no-features": {
97
- const value = argv[++i];
98
- if (!value) {
99
- throw new Error("--no-features requires a comma-separated list.");
100
- }
101
- options.noFeatures.push(
102
- ...value.split(",").map((part) => part.trim()).filter(Boolean),
103
- );
104
- break;
105
- }
106
- case "--repo": {
107
- const value = argv[++i];
108
- if (!value) throw new Error("--repo requires a value (owner/name).");
109
- options.repo = value;
110
- break;
111
- }
112
- case "--branch": {
113
- const value = argv[++i];
114
- if (!value) throw new Error("--branch requires a value.");
115
- options.branch = value;
116
- break;
117
- }
118
- default:
119
- if (arg.startsWith("-")) {
120
- throw new Error(`Unknown option: ${arg}`);
121
- }
122
- positional.push(arg);
123
- }
124
- }
125
-
126
- if (positional[0]) {
127
- options.targetDir = positional[0];
128
- }
129
-
130
- if (options.minimal && options.features) {
131
- throw new Error("Use either --minimal or --features, not both.");
132
- }
133
-
134
- return options;
135
- }
136
-
137
- export function printHelp(): void {
138
- console.log(HELP.trim());
139
- }
140
-
141
- export function printVersion(): void {
142
- console.log(PACKAGE_VERSION);
143
- }
1
+ import { PACKAGE_VERSION } from "./constants";
2
+
3
+ export type CliOptions = {
4
+ targetDir: string | undefined;
5
+ help: boolean;
6
+ version: boolean;
7
+ install: boolean;
8
+ force: boolean;
9
+ repo: string;
10
+ branch: string;
11
+ minimal: boolean;
12
+ features: string | undefined;
13
+ noFeatures: string[];
14
+ };
15
+
16
+ const HELP = `\
17
+ Usage: kavoru [options] [directory]
18
+ kavoru module <module-name> [options]
19
+
20
+ Create a new project from the Kavoru Elysia + Bun template.
21
+
22
+ Commands:
23
+ module <name> Generate src/modules/<name> (routes, service, types)
24
+
25
+ Arguments:
26
+ directory Project folder (use "." for current directory)
27
+
28
+ Options:
29
+ -h, --help Show help
30
+ -V, --version Show version
31
+ -f, --force Overwrite / use a non-empty target directory
32
+ --no-install Skip "bun install" after scaffolding
33
+ --repo <owner/name> GitHub template repo (default: mertthesamael/Kavoru)
34
+ --branch <name> Template branch (default: master)
35
+ --minimal Core only (health, OpenAPI, response envelope)
36
+ --features <list> Comma-separated features to include (default: all)
37
+ --no-features <list> Comma-separated features to exclude
38
+
39
+ Features:
40
+ auth, postgres, otel, sentry, kafka, redis, llama, websocket, resend, cron, cli
41
+ (prisma is accepted as an alias for postgres; kavoru-cli for cli)
42
+ Docker Compose is always included — not a toggle.
43
+
44
+ Examples:
45
+ bunx kavoru@latest my-api
46
+ bunx kavoru@latest my-api --minimal
47
+ bunx kavoru@latest my-api --features auth,postgres,otel
48
+ bunx kavoru@latest my-api --no-features kafka,resend
49
+ bunx kavoru@latest module users
50
+ `;
51
+
52
+ export function parseArgs(argv: string[]): CliOptions {
53
+ const options: CliOptions = {
54
+ targetDir: undefined,
55
+ help: false,
56
+ version: false,
57
+ install: true,
58
+ force: false,
59
+ repo: "mertthesamael/Kavoru",
60
+ branch: "master",
61
+ minimal: false,
62
+ features: undefined,
63
+ noFeatures: [],
64
+ };
65
+
66
+ const positional: string[] = [];
67
+
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const arg = argv[i];
70
+ if (!arg) continue;
71
+
72
+ switch (arg) {
73
+ case "-h":
74
+ case "--help":
75
+ options.help = true;
76
+ break;
77
+ case "-V":
78
+ case "--version":
79
+ options.version = true;
80
+ break;
81
+ case "-f":
82
+ case "--force":
83
+ options.force = true;
84
+ break;
85
+ case "--no-install":
86
+ options.install = false;
87
+ break;
88
+ case "--minimal":
89
+ options.minimal = true;
90
+ break;
91
+ case "--features": {
92
+ const value = argv[++i];
93
+ if (!value) throw new Error("--features requires a comma-separated list.");
94
+ options.features = value;
95
+ break;
96
+ }
97
+ case "--no-features": {
98
+ const value = argv[++i];
99
+ if (!value) {
100
+ throw new Error("--no-features requires a comma-separated list.");
101
+ }
102
+ options.noFeatures.push(
103
+ ...value.split(",").map((part) => part.trim()).filter(Boolean),
104
+ );
105
+ break;
106
+ }
107
+ case "--repo": {
108
+ const value = argv[++i];
109
+ if (!value) throw new Error("--repo requires a value (owner/name).");
110
+ options.repo = value;
111
+ break;
112
+ }
113
+ case "--branch": {
114
+ const value = argv[++i];
115
+ if (!value) throw new Error("--branch requires a value.");
116
+ options.branch = value;
117
+ break;
118
+ }
119
+ default:
120
+ if (arg.startsWith("-")) {
121
+ throw new Error(`Unknown option: ${arg}`);
122
+ }
123
+ positional.push(arg);
124
+ }
125
+ }
126
+
127
+ if (positional[0]) {
128
+ options.targetDir = positional[0];
129
+ }
130
+
131
+ if (options.minimal && options.features) {
132
+ throw new Error("Use either --minimal or --features, not both.");
133
+ }
134
+
135
+ return options;
136
+ }
137
+
138
+ export function printHelp(): void {
139
+ console.log(HELP.trim());
140
+ }
141
+
142
+ export function printVersion(): void {
143
+ console.log(PACKAGE_VERSION);
144
+ }
package/src/features.ts CHANGED
@@ -9,12 +9,15 @@ export type FeatureId =
9
9
  | "sentry"
10
10
  | "kafka"
11
11
  | "redis"
12
+ | "llama"
12
13
  | "websocket"
13
14
  | "resend"
14
15
  | "cron"
15
- | "docker"
16
16
  | "cli";
17
17
 
18
+ /** Always scaffolded — not a CLI toggle. */
19
+ export const ALWAYS_INCLUDED = ["docker"] as const;
20
+
18
21
  const FEATURE_ALIASES: Record<string, FeatureId> = {
19
22
  prisma: "postgres",
20
23
  "kavoru-cli": "cli",
@@ -59,6 +62,11 @@ export const FEATURES: FeatureDef[] = [
59
62
  label: "Redis",
60
63
  description: "Cache client and CRUD HTTP endpoints",
61
64
  },
65
+ {
66
+ id: "llama",
67
+ label: "Llama (Ollama)",
68
+ description: "Local LLM via Ollama Docker service and chat endpoint",
69
+ },
62
70
  {
63
71
  id: "websocket",
64
72
  label: "WebSockets",
@@ -74,11 +82,6 @@ export const FEATURES: FeatureDef[] = [
74
82
  label: "Cron Jobs",
75
83
  description: "Scheduled tasks via @elysiajs/cron",
76
84
  },
77
- {
78
- id: "docker",
79
- label: "Docker",
80
- description: "Dockerfile and Docker Compose stack",
81
- },
82
85
  {
83
86
  id: "cli",
84
87
  label: "Project CLI",
@@ -124,6 +127,12 @@ const FEATURE_PATHS: Record<FeatureId, string[]> = {
124
127
  "src/models/schemas/redis.ts",
125
128
  "__tests__/redis.test.ts",
126
129
  ],
130
+ llama: [
131
+ "src/modules/llama",
132
+ "src/infra/llama",
133
+ "src/models/schemas/llama.ts",
134
+ "__tests__/llama.test.ts",
135
+ ],
127
136
  websocket: [
128
137
  "src/modules/realtime",
129
138
  "src/models/schemas/realtime.ts",
@@ -131,7 +140,6 @@ const FEATURE_PATHS: Record<FeatureId, string[]> = {
131
140
  ],
132
141
  resend: ["src/infra/resend"],
133
142
  cron: ["src/schedules"],
134
- docker: ["docker-compose.yaml", "docker"],
135
143
  cli: [
136
144
  "bin/kavoru.js",
137
145
  "kavoru",
@@ -211,11 +219,19 @@ export function buildRedisCredentials(packageName: string): {
211
219
  export function normalizeFeatureSelection(
212
220
  selection: FeatureSelection,
213
221
  ): FeatureSelection {
214
- const next = { ...selection };
215
- if (next.postgres) {
216
- next.docker = true;
217
- }
218
- return next;
222
+ return { ...selection };
223
+ }
224
+
225
+ function rejectReservedFeatureToggle(parts: string[], action: "include" | "exclude") {
226
+ const reserved = parts.filter((part) =>
227
+ ALWAYS_INCLUDED.includes(part as (typeof ALWAYS_INCLUDED)[number]),
228
+ );
229
+ if (reserved.length === 0) return;
230
+
231
+ const verb = action === "exclude" ? "disable" : "toggle";
232
+ throw new Error(
233
+ `Docker is always included and cannot be ${verb}. Omit "docker" from --features / --no-features.`,
234
+ );
219
235
  }
220
236
 
221
237
  function enabledFeatures(selection: FeatureSelection): FeatureId[] {
@@ -238,6 +254,8 @@ export function parseFeatureIncludeList(input: string): FeatureSelection {
238
254
  .map((part) => part.trim())
239
255
  .filter(Boolean);
240
256
 
257
+ rejectReservedFeatureToggle(requested, "include");
258
+
241
259
  const unknown = requested.filter((part) => resolveFeatureId(part) === null);
242
260
  if (unknown.length > 0) {
243
261
  throw new Error(
@@ -250,9 +268,6 @@ export function parseFeatureIncludeList(input: string): FeatureSelection {
250
268
  const id = resolveFeatureId(part);
251
269
  if (id) selection[id] = true;
252
270
  }
253
- if (selection.postgres) {
254
- selection.docker = true;
255
- }
256
271
  return selection;
257
272
  }
258
273
 
@@ -263,6 +278,11 @@ export function parseFeatureExcludeList(
263
278
  const selection = { ...base };
264
279
  const unknown: string[] = [];
265
280
 
281
+ rejectReservedFeatureToggle(
282
+ excluded.map((part) => part.trim().toLowerCase()).filter(Boolean),
283
+ "exclude",
284
+ );
285
+
266
286
  for (const raw of excluded) {
267
287
  const part = raw.trim().toLowerCase();
268
288
  if (!part) continue;
@@ -280,10 +300,6 @@ export function parseFeatureExcludeList(
280
300
  );
281
301
  }
282
302
 
283
- if (!selection.docker) {
284
- selection.postgres = false;
285
- }
286
-
287
303
  return selection;
288
304
  }
289
305
 
@@ -614,6 +630,18 @@ export function buildEnvExample(
614
630
  );
615
631
  }
616
632
 
633
+ if (selection.llama) {
634
+ lines.push(
635
+ "# Llama via Ollama (enabled by default in development; disabled in test)",
636
+ "# Start server: docker compose up -d llama",
637
+ "# Model is pulled automatically on first llama container start",
638
+ "# LLAMA_ENABLED=false",
639
+ "LLAMA_URL=http://localhost:11434",
640
+ "LLAMA_MODEL=llama3.2",
641
+ "",
642
+ );
643
+ }
644
+
617
645
  if (selection.resend) {
618
646
  lines.push(
619
647
  "# Resend (disabled when RESEND_API_KEY is unset; always disabled in test)",
@@ -642,8 +670,6 @@ async function patchDockerfile(
642
670
  projectDir: string,
643
671
  selection: FeatureSelection,
644
672
  ) {
645
- if (!selection.docker) return;
646
-
647
673
  const relativePath = "docker/app/Dockerfile";
648
674
  const current = await readText(projectDir, relativePath);
649
675
  if (!current) return;
@@ -707,6 +733,12 @@ const DOCKER_OTEL_ENV =
707
733
  const DOCKER_SPOTLIGHT_ENV =
708
734
  "# Official Spotlight image; add overrides here if needed.\n";
709
735
 
736
+ const DOCKER_LLAMA_ENV = `# Ollama serves Llama models on port 11434
737
+ # Pulled automatically on first container start (see docker-entrypoint.sh)
738
+ OLLAMA_HOST=0.0.0.0
739
+ OLLAMA_MODEL=llama3.2
740
+ `;
741
+
710
742
  function buildDockerPostgresEnv(packageName: string): string {
711
743
  const name = toPostgresName(packageName);
712
744
  return `POSTGRES_USER=${name}
@@ -741,6 +773,10 @@ function buildDockerAppEnv(
741
773
  if (selection.sentry) {
742
774
  lines.push("SENTRY_SPOTLIGHT=http://spotlight:8969/stream");
743
775
  }
776
+ if (selection.llama) {
777
+ lines.push("LLAMA_URL=http://llama:11434");
778
+ lines.push("LLAMA_MODEL=llama3.2");
779
+ }
744
780
  return `${lines.join("\n")}\n`;
745
781
  }
746
782
 
@@ -758,6 +794,10 @@ function buildAppDependsOn(selection: FeatureSelection): string {
758
794
  deps.push(` redis:
759
795
  condition: service_healthy`);
760
796
  }
797
+ if (selection.llama) {
798
+ deps.push(` llama:
799
+ condition: service_started`);
800
+ }
761
801
  if (deps.length === 0) return "";
762
802
  return ` depends_on:
763
803
  ${deps.join("\n")}
@@ -861,6 +901,24 @@ function generateDockerCompose(selection: FeatureSelection): string {
861
901
  `
862
902
  : "";
863
903
 
904
+ const llamaService = selection.llama
905
+ ? `
906
+ llama:
907
+ build:
908
+ context: docker/llama
909
+ hostname: llama
910
+ ports:
911
+ - "\${LLAMA_PORT:-11434}:11434"
912
+ env_file:
913
+ - docker/llama/.env
914
+ volumes:
915
+ - llama_data:/root/.ollama
916
+ networks:
917
+ - app_network
918
+ restart: unless-stopped
919
+ `
920
+ : "";
921
+
864
922
  return `services:
865
923
  app:
866
924
  build:
@@ -891,11 +949,19 @@ ${appDependsOn} healthcheck:
891
949
  timeout: 300s
892
950
  retries: 1
893
951
  start_period: 90s
894
- ${postgresService}${kafkaService}${redisService}${otelService}${spotlightService}
952
+ ${postgresService}${kafkaService}${redisService}${otelService}${spotlightService}${llamaService}
895
953
  networks:
896
954
  app_network:
897
955
  driver: bridge
898
- ${selection.postgres ? "\nvolumes:\n postgres_data:\n" : ""}`;
956
+ ${buildComposeVolumes(selection)}`;
957
+ }
958
+
959
+ function buildComposeVolumes(selection: FeatureSelection): string {
960
+ const volumes: string[] = [];
961
+ if (selection.postgres) volumes.push(" postgres_data:");
962
+ if (selection.llama) volumes.push(" llama_data:");
963
+ if (volumes.length === 0) return "";
964
+ return `\nvolumes:\n${volumes.join("\n")}\n`;
899
965
  }
900
966
 
901
967
  async function patchDockerCompose(
@@ -903,8 +969,6 @@ async function patchDockerCompose(
903
969
  selection: FeatureSelection,
904
970
  packageName: string,
905
971
  ) {
906
- if (!selection.docker) return;
907
-
908
972
  if (!selection.postgres) {
909
973
  await removePaths(projectDir, [
910
974
  "docker/postgres",
@@ -923,6 +987,9 @@ async function patchDockerCompose(
923
987
  if (!selection.sentry) {
924
988
  await removePaths(projectDir, ["docker/spotlight"]);
925
989
  }
990
+ if (!selection.llama) {
991
+ await removePaths(projectDir, ["docker/llama"]);
992
+ }
926
993
 
927
994
  await writeText(
928
995
  projectDir,
@@ -952,6 +1019,9 @@ async function patchDockerCompose(
952
1019
  if (selection.sentry) {
953
1020
  await writeText(projectDir, "docker/spotlight/.env", DOCKER_SPOTLIGHT_ENV);
954
1021
  }
1022
+ if (selection.llama) {
1023
+ await writeText(projectDir, "docker/llama/.env", DOCKER_LLAMA_ENV);
1024
+ }
955
1025
  await writeText(
956
1026
  projectDir,
957
1027
  "docker-compose.yaml",
package/src/prompts.ts CHANGED
@@ -1,193 +1,187 @@
1
- import { stdin, stdout } from "node:process";
2
- import {
3
- ALL_FEATURES,
4
- FEATURES,
5
- MINIMAL_FEATURES,
6
- normalizeFeatureSelection,
7
- type FeatureId,
8
- type FeatureSelection,
9
- formatFeatureSelection,
10
- } from "./features";
11
-
12
- const ESC = "\x1b";
13
- const dim = `${ESC}[2m`;
14
- const reset = `${ESC}[0m`;
15
- const cyan = `${ESC}[36m`;
16
-
17
- function cloneSelection(selection: FeatureSelection): FeatureSelection {
18
- return { ...selection };
19
- }
20
-
21
- type KeyAction =
22
- | "up"
23
- | "down"
24
- | "toggle"
25
- | "confirm"
26
- | "all"
27
- | "minimal"
28
- | "interrupt";
29
-
30
- const KEY_UP = ESC + "[A";
31
- const KEY_DOWN = ESC + "[B";
32
- const KEY_UP_ALT = ESC + "OA";
33
- const KEY_DOWN_ALT = ESC + "OB";
34
-
35
- function parseKeyInput(data: string): KeyAction | null {
36
- if (data === "\u0003") return "interrupt";
37
- if (data === "\r" || data === "\n") return "confirm";
38
- if (data === " ") return "toggle";
39
- if (data === "a" || data === "A") return "all";
40
- if (data === "m" || data === "M") return "minimal";
41
- if (data === KEY_UP || data === KEY_UP_ALT) return "up";
42
- if (data === KEY_DOWN || data === KEY_DOWN_ALT) return "down";
43
- return null;
44
- }
45
-
46
- function createKeyReader(onKey: (action: KeyAction) => void) {
47
- let pending = "";
48
-
49
- const onData = (chunk: string) => {
50
- pending += chunk;
51
-
52
- while (pending.length > 0) {
53
- if (pending === ESC) return;
54
-
55
- if (pending.startsWith(ESC)) {
56
- if (pending.length < 3) return;
57
-
58
- const action = parseKeyInput(pending.slice(0, 3));
59
- if (action) {
60
- pending = pending.slice(3);
61
- onKey(action);
62
- continue;
63
- }
64
-
65
- if (pending.startsWith(ESC + "O") && pending.length < 3) return;
66
-
67
- pending = pending.slice(1);
68
- continue;
69
- }
70
-
71
- const action = parseKeyInput(pending[0] ?? "");
72
- pending = pending.slice(1);
73
- if (action) onKey(action);
74
- }
75
- };
76
-
77
- return onData;
78
- }
79
-
80
- function renderCheckboxMenu(
81
- selection: FeatureSelection,
82
- activeIndex: number,
83
- lineCount: number,
84
- ): number {
85
- const lines: string[] = [
86
- `${cyan}◆${reset} Select optional features ${dim}(↑↓ move · Space toggle · Enter confirm)${reset}`,
87
- `${dim} a = all · m = minimal${reset}`,
88
- "",
89
- ];
90
-
91
- FEATURES.forEach((feature, index) => {
92
- const isActive = index === activeIndex;
93
- const pointer = isActive ? `${cyan}❯${reset}` : " ";
94
- const mark = selection[feature.id] ? "x" : " ";
95
- const label = isActive ? `${cyan}${feature.label}${reset}` : feature.label;
96
- lines.push(
97
- ` ${pointer} [${mark}] ${label.padEnd(22)} ${dim}${feature.description}${reset}`,
98
- );
99
- });
100
-
101
- lines.push("", ` ${dim}Selected: ${formatFeatureSelection(selection)}${reset}`);
102
-
103
- if (lineCount > 0) {
104
- stdout.write(`${ESC}[${lineCount}A`);
105
- }
106
-
107
- for (const line of lines) {
108
- stdout.write(`${ESC}[2K${ESC}[0G${line}\n`);
109
- }
110
-
111
- return lines.length;
112
- }
113
-
114
- function restoreTerminal(onData: (chunk: string) => void) {
115
- stdin.off("data", onData);
116
- if (stdin.isTTY) {
117
- stdin.setRawMode(false);
118
- }
119
- stdin.pause();
120
- stdout.write(`${ESC}[?25h`);
121
- }
122
-
123
- export async function promptFeatureSelection(
124
- initial: FeatureSelection = ALL_FEATURES,
125
- ): Promise<FeatureSelection> {
126
- if (!stdin.isTTY || !stdout.isTTY) {
127
- return cloneSelection(initial);
128
- }
129
-
130
- const selection = cloneSelection(initial);
131
- let activeIndex = 0;
132
- let lineCount = 0;
133
-
134
- stdout.write(`${ESC}[?25l`);
135
-
136
- if (stdin.isTTY) {
137
- stdin.setRawMode(true);
138
- }
139
- stdin.resume();
140
- stdin.setEncoding("utf8");
141
-
142
- return new Promise((resolve, reject) => {
143
- const onKey = (action: KeyAction) => {
144
- switch (action) {
145
- case "up":
146
- activeIndex =
147
- (activeIndex - 1 + FEATURES.length) % FEATURES.length;
148
- lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
149
- break;
150
- case "down":
151
- activeIndex = (activeIndex + 1) % FEATURES.length;
152
- lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
153
- break;
154
- case "toggle": {
155
- const feature = FEATURES[activeIndex];
156
- if (!feature) break;
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
- }
164
- lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
165
- break;
166
- }
167
- case "all":
168
- Object.assign(selection, ALL_FEATURES);
169
- lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
170
- break;
171
- case "minimal":
172
- Object.assign(selection, MINIMAL_FEATURES);
173
- lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
174
- break;
175
- case "confirm":
176
- Object.assign(selection, normalizeFeatureSelection(selection));
177
- restoreTerminal(onData);
178
- stdout.write("\n");
179
- resolve(selection);
180
- break;
181
- case "interrupt":
182
- restoreTerminal(onData);
183
- stdout.write("\n");
184
- reject(new Error("Feature selection cancelled."));
185
- break;
186
- }
187
- };
188
-
189
- const onData = createKeyReader(onKey);
190
- stdin.on("data", onData);
191
- lineCount = renderCheckboxMenu(selection, activeIndex, 0);
192
- });
193
- }
1
+ import { stdin, stdout } from "node:process";
2
+ import {
3
+ ALL_FEATURES,
4
+ FEATURES,
5
+ MINIMAL_FEATURES,
6
+ normalizeFeatureSelection,
7
+ type FeatureId,
8
+ type FeatureSelection,
9
+ formatFeatureSelection,
10
+ } from "./features";
11
+
12
+ const ESC = "\x1b";
13
+ const dim = `${ESC}[2m`;
14
+ const reset = `${ESC}[0m`;
15
+ const cyan = `${ESC}[36m`;
16
+
17
+ function cloneSelection(selection: FeatureSelection): FeatureSelection {
18
+ return { ...selection };
19
+ }
20
+
21
+ type KeyAction =
22
+ | "up"
23
+ | "down"
24
+ | "toggle"
25
+ | "confirm"
26
+ | "all"
27
+ | "minimal"
28
+ | "interrupt";
29
+
30
+ const KEY_UP = ESC + "[A";
31
+ const KEY_DOWN = ESC + "[B";
32
+ const KEY_UP_ALT = ESC + "OA";
33
+ const KEY_DOWN_ALT = ESC + "OB";
34
+
35
+ function parseKeyInput(data: string): KeyAction | null {
36
+ if (data === "\u0003") return "interrupt";
37
+ if (data === "\r" || data === "\n") return "confirm";
38
+ if (data === " ") return "toggle";
39
+ if (data === "a" || data === "A") return "all";
40
+ if (data === "m" || data === "M") return "minimal";
41
+ if (data === KEY_UP || data === KEY_UP_ALT) return "up";
42
+ if (data === KEY_DOWN || data === KEY_DOWN_ALT) return "down";
43
+ return null;
44
+ }
45
+
46
+ function createKeyReader(onKey: (action: KeyAction) => void) {
47
+ let pending = "";
48
+
49
+ const onData = (chunk: string) => {
50
+ pending += chunk;
51
+
52
+ while (pending.length > 0) {
53
+ if (pending === ESC) return;
54
+
55
+ if (pending.startsWith(ESC)) {
56
+ if (pending.length < 3) return;
57
+
58
+ const action = parseKeyInput(pending.slice(0, 3));
59
+ if (action) {
60
+ pending = pending.slice(3);
61
+ onKey(action);
62
+ continue;
63
+ }
64
+
65
+ if (pending.startsWith(ESC + "O") && pending.length < 3) return;
66
+
67
+ pending = pending.slice(1);
68
+ continue;
69
+ }
70
+
71
+ const action = parseKeyInput(pending[0] ?? "");
72
+ pending = pending.slice(1);
73
+ if (action) onKey(action);
74
+ }
75
+ };
76
+
77
+ return onData;
78
+ }
79
+
80
+ function renderCheckboxMenu(
81
+ selection: FeatureSelection,
82
+ activeIndex: number,
83
+ lineCount: number,
84
+ ): number {
85
+ const lines: string[] = [
86
+ `${cyan}◆${reset} Select optional features ${dim}(↑↓ move · Space toggle · Enter confirm)${reset}`,
87
+ `${dim} a = all · m = minimal${reset}`,
88
+ "",
89
+ ];
90
+
91
+ FEATURES.forEach((feature, index) => {
92
+ const isActive = index === activeIndex;
93
+ const pointer = isActive ? `${cyan}❯${reset}` : " ";
94
+ const mark = selection[feature.id] ? "x" : " ";
95
+ const label = isActive ? `${cyan}${feature.label}${reset}` : feature.label;
96
+ lines.push(
97
+ ` ${pointer} [${mark}] ${label.padEnd(22)} ${dim}${feature.description}${reset}`,
98
+ );
99
+ });
100
+
101
+ lines.push("", ` ${dim}Selected: ${formatFeatureSelection(selection)}${reset}`);
102
+
103
+ if (lineCount > 0) {
104
+ stdout.write(`${ESC}[${lineCount}A`);
105
+ }
106
+
107
+ for (const line of lines) {
108
+ stdout.write(`${ESC}[2K${ESC}[0G${line}\n`);
109
+ }
110
+
111
+ return lines.length;
112
+ }
113
+
114
+ function restoreTerminal(onData: (chunk: string) => void) {
115
+ stdin.off("data", onData);
116
+ if (stdin.isTTY) {
117
+ stdin.setRawMode(false);
118
+ }
119
+ stdin.pause();
120
+ stdout.write(`${ESC}[?25h`);
121
+ }
122
+
123
+ export async function promptFeatureSelection(
124
+ initial: FeatureSelection = ALL_FEATURES,
125
+ ): Promise<FeatureSelection> {
126
+ if (!stdin.isTTY || !stdout.isTTY) {
127
+ return cloneSelection(initial);
128
+ }
129
+
130
+ const selection = cloneSelection(initial);
131
+ let activeIndex = 0;
132
+ let lineCount = 0;
133
+
134
+ stdout.write(`${ESC}[?25l`);
135
+
136
+ if (stdin.isTTY) {
137
+ stdin.setRawMode(true);
138
+ }
139
+ stdin.resume();
140
+ stdin.setEncoding("utf8");
141
+
142
+ return new Promise((resolve, reject) => {
143
+ const onKey = (action: KeyAction) => {
144
+ switch (action) {
145
+ case "up":
146
+ activeIndex =
147
+ (activeIndex - 1 + FEATURES.length) % FEATURES.length;
148
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
149
+ break;
150
+ case "down":
151
+ activeIndex = (activeIndex + 1) % FEATURES.length;
152
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
153
+ break;
154
+ case "toggle": {
155
+ const feature = FEATURES[activeIndex];
156
+ if (!feature) break;
157
+ selection[feature.id as FeatureId] = !selection[feature.id as FeatureId];
158
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
159
+ break;
160
+ }
161
+ case "all":
162
+ Object.assign(selection, ALL_FEATURES);
163
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
164
+ break;
165
+ case "minimal":
166
+ Object.assign(selection, MINIMAL_FEATURES);
167
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
168
+ break;
169
+ case "confirm":
170
+ Object.assign(selection, normalizeFeatureSelection(selection));
171
+ restoreTerminal(onData);
172
+ stdout.write("\n");
173
+ resolve(selection);
174
+ break;
175
+ case "interrupt":
176
+ restoreTerminal(onData);
177
+ stdout.write("\n");
178
+ reject(new Error("Feature selection cancelled."));
179
+ break;
180
+ }
181
+ };
182
+
183
+ const onData = createKeyReader(onKey);
184
+ stdin.on("data", onData);
185
+ lineCount = renderCheckboxMenu(selection, activeIndex, 0);
186
+ });
187
+ }