kavoru 0.1.0 → 0.2.0
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 +38 -33
- package/package.json +1 -1
- package/src/args.ts +38 -1
- package/src/cli.ts +45 -0
- package/src/features.ts +630 -0
- package/src/prompts.ts +78 -0
- package/src/template.ts +0 -15
package/README.md
CHANGED
|
@@ -16,24 +16,54 @@ Equivalent to `bunx --bun kavoru` (Bun runs the `kavoru` binary from the npm pac
|
|
|
16
16
|
|
|
17
17
|
### Options
|
|
18
18
|
|
|
19
|
-
| Flag
|
|
20
|
-
|
|
|
21
|
-
| `-h, --help`
|
|
22
|
-
| `-V, --version`
|
|
23
|
-
| `-f, --force`
|
|
24
|
-
| `--no-install`
|
|
19
|
+
| Flag | Description |
|
|
20
|
+
| ------------------- | -------------------------------------------------------- |
|
|
21
|
+
| `-h, --help` | Show help |
|
|
22
|
+
| `-V, --version` | Show CLI version |
|
|
23
|
+
| `-f, --force` | Scaffold into a non-empty directory |
|
|
24
|
+
| `--no-install` | Skip `bun install` |
|
|
25
25
|
| `--repo owner/name` | Override template repo (default: `mertthesamael/Kavoru`) |
|
|
26
|
-
| `--branch name`
|
|
26
|
+
| `--branch name` | Template branch (default: `master`) |
|
|
27
|
+
| `--minimal` | Core only — health, OpenAPI, response envelope |
|
|
28
|
+
| `--features list` | Comma-separated features to include |
|
|
29
|
+
| `--no-features list`| Comma-separated features to exclude (default: all on) |
|
|
30
|
+
|
|
31
|
+
### Optional features
|
|
32
|
+
|
|
33
|
+
During setup you can pick which integrations to scaffold. Core is always included: health routes, OpenAPI at `/help`, CORS, and the JSON response envelope.
|
|
34
|
+
|
|
35
|
+
| ID | Feature |
|
|
36
|
+
| ----------- | ---------------------- |
|
|
37
|
+
| `auth` | JWT authentication |
|
|
38
|
+
| `prisma` | Prisma + PostgreSQL |
|
|
39
|
+
| `otel` | OpenTelemetry |
|
|
40
|
+
| `sentry` | Sentry + Spotlight |
|
|
41
|
+
| `kafka` | Kafka producer/consumer|
|
|
42
|
+
| `websocket` | WebSocket realtime |
|
|
43
|
+
| `resend` | Resend email |
|
|
44
|
+
| `cron` | Cron jobs |
|
|
45
|
+
| `docker` | Dockerfile + Compose |
|
|
46
|
+
|
|
47
|
+
Interactive mode (TTY) shows a toggle menu after the project name. Non-interactive runs use the full stack unless you pass flags.
|
|
27
48
|
|
|
28
49
|
### Examples
|
|
29
50
|
|
|
30
51
|
```bash
|
|
31
|
-
# Interactive (prompts for project name)
|
|
52
|
+
# Interactive (prompts for project name + feature toggles)
|
|
32
53
|
bunx kavoru
|
|
33
54
|
|
|
34
55
|
# Current directory
|
|
35
56
|
bunx kavoru .
|
|
36
57
|
|
|
58
|
+
# Minimal API skeleton
|
|
59
|
+
bunx kavoru my-api --minimal
|
|
60
|
+
|
|
61
|
+
# Pick specific features
|
|
62
|
+
bunx kavoru my-api --features auth,prisma,otel,sentry
|
|
63
|
+
|
|
64
|
+
# Full stack minus Kafka and Docker
|
|
65
|
+
bunx kavoru my-api --no-features kafka,docker
|
|
66
|
+
|
|
37
67
|
# Custom template fork (local dev)
|
|
38
68
|
bunx kavoru demo --repo your-user/Kavoru --no-install
|
|
39
69
|
```
|
|
@@ -52,31 +82,6 @@ bun link
|
|
|
52
82
|
bunx kavoru my-test-app
|
|
53
83
|
```
|
|
54
84
|
|
|
55
|
-
## Publish to npm
|
|
56
|
-
|
|
57
|
-
1. Ensure the [Kavoru](https://github.com/mertthesamael/Kavoru) template repo is public on `master`.
|
|
58
|
-
2. Create a **Granular Access Token** at [npm → Access Tokens](https://www.npmjs.com/settings/~/tokens) with:
|
|
59
|
-
- **Bypass two-factor authentication** — checked (required for first publish without 2FA)
|
|
60
|
-
- **Packages** — All packages, **Read and write**
|
|
61
|
-
3. Configure auth (do not commit the token):
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
echo "//registry.npmjs.org/:_authToken=YOUR_TOKEN" > ~/.npmrc
|
|
65
|
-
unset NPM_CONFIG_TOKEN
|
|
66
|
-
npm whoami
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
4. Publish: `npm publish` (or `bun publish`)
|
|
70
|
-
|
|
71
|
-
The `bin/kavoru.js` shim uses `#!/usr/bin/env bun` so `bunx kavoru` runs with Bun.
|
|
72
|
-
|
|
73
|
-
## What the CLI does
|
|
74
|
-
|
|
75
|
-
1. Shallow-clones the GitHub template (or downloads a zip if `git` is missing)
|
|
76
|
-
2. Removes `.git` so the new project starts fresh
|
|
77
|
-
3. Sets `package.json` `name`, copies `.env` from `.env.example`, and adjusts default service IDs
|
|
78
|
-
4. Runs `bun install` (unless `--no-install`)
|
|
79
|
-
|
|
80
85
|
## License
|
|
81
86
|
|
|
82
87
|
MIT
|
package/package.json
CHANGED
package/src/args.ts
CHANGED
|
@@ -8,6 +8,9 @@ export type CliOptions = {
|
|
|
8
8
|
force: boolean;
|
|
9
9
|
repo: string;
|
|
10
10
|
branch: string;
|
|
11
|
+
minimal: boolean;
|
|
12
|
+
features: string | undefined;
|
|
13
|
+
noFeatures: string[];
|
|
11
14
|
};
|
|
12
15
|
|
|
13
16
|
const HELP = `\
|
|
@@ -25,10 +28,18 @@ Options:
|
|
|
25
28
|
--no-install Skip "bun install" after scaffolding
|
|
26
29
|
--repo <owner/name> GitHub template repo (default: mertthesamael/Kavoru)
|
|
27
30
|
--branch <name> Template branch (default: master)
|
|
31
|
+
--minimal Core only (health, OpenAPI, response envelope)
|
|
32
|
+
--features <list> Comma-separated features to include (default: all)
|
|
33
|
+
--no-features <list> Comma-separated features to exclude
|
|
34
|
+
|
|
35
|
+
Features:
|
|
36
|
+
auth, prisma, otel, sentry, kafka, websocket, resend, cron, docker
|
|
28
37
|
|
|
29
38
|
Examples:
|
|
30
39
|
bunx kavoru my-api
|
|
31
|
-
bunx kavoru
|
|
40
|
+
bunx kavoru my-api --minimal
|
|
41
|
+
bunx kavoru my-api --features auth,prisma,otel
|
|
42
|
+
bunx kavoru my-api --no-features kafka,docker,resend
|
|
32
43
|
`;
|
|
33
44
|
|
|
34
45
|
export function parseArgs(argv: string[]): CliOptions {
|
|
@@ -40,6 +51,9 @@ export function parseArgs(argv: string[]): CliOptions {
|
|
|
40
51
|
force: false,
|
|
41
52
|
repo: "mertthesamael/Kavoru",
|
|
42
53
|
branch: "master",
|
|
54
|
+
minimal: false,
|
|
55
|
+
features: undefined,
|
|
56
|
+
noFeatures: [],
|
|
43
57
|
};
|
|
44
58
|
|
|
45
59
|
const positional: string[] = [];
|
|
@@ -64,6 +78,25 @@ export function parseArgs(argv: string[]): CliOptions {
|
|
|
64
78
|
case "--no-install":
|
|
65
79
|
options.install = false;
|
|
66
80
|
break;
|
|
81
|
+
case "--minimal":
|
|
82
|
+
options.minimal = true;
|
|
83
|
+
break;
|
|
84
|
+
case "--features": {
|
|
85
|
+
const value = argv[++i];
|
|
86
|
+
if (!value) throw new Error("--features requires a comma-separated list.");
|
|
87
|
+
options.features = value;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "--no-features": {
|
|
91
|
+
const value = argv[++i];
|
|
92
|
+
if (!value) {
|
|
93
|
+
throw new Error("--no-features requires a comma-separated list.");
|
|
94
|
+
}
|
|
95
|
+
options.noFeatures.push(
|
|
96
|
+
...value.split(",").map((part) => part.trim()).filter(Boolean),
|
|
97
|
+
);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
67
100
|
case "--repo": {
|
|
68
101
|
const value = argv[++i];
|
|
69
102
|
if (!value) throw new Error("--repo requires a value (owner/name).");
|
|
@@ -88,6 +121,10 @@ export function parseArgs(argv: string[]): CliOptions {
|
|
|
88
121
|
options.targetDir = positional[0];
|
|
89
122
|
}
|
|
90
123
|
|
|
124
|
+
if (options.minimal && options.features) {
|
|
125
|
+
throw new Error("Use either --minimal or --features, not both.");
|
|
126
|
+
}
|
|
127
|
+
|
|
91
128
|
return options;
|
|
92
129
|
}
|
|
93
130
|
|
package/src/cli.ts
CHANGED
|
@@ -5,7 +5,17 @@ import path from "node:path";
|
|
|
5
5
|
import * as readline from "node:readline/promises";
|
|
6
6
|
import { stdin as input, stdout as output } from "node:process";
|
|
7
7
|
import type { CliOptions } from "./args";
|
|
8
|
+
import {
|
|
9
|
+
ALL_FEATURES,
|
|
10
|
+
MINIMAL_FEATURES,
|
|
11
|
+
applyFeatures,
|
|
12
|
+
formatFeatureSelection,
|
|
13
|
+
parseFeatureExcludeList,
|
|
14
|
+
parseFeatureIncludeList,
|
|
15
|
+
type FeatureSelection,
|
|
16
|
+
} from "./features";
|
|
8
17
|
import { log } from "./log";
|
|
18
|
+
import { promptFeatureSelection } from "./prompts";
|
|
9
19
|
import {
|
|
10
20
|
customizeProject,
|
|
11
21
|
fetchTemplate,
|
|
@@ -49,6 +59,38 @@ async function copyTemplateIntoTarget(
|
|
|
49
59
|
}
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
export function resolveFeatureSelection(options: CliOptions): FeatureSelection {
|
|
63
|
+
if (options.minimal) {
|
|
64
|
+
return { ...MINIMAL_FEATURES };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (options.features) {
|
|
68
|
+
return parseFeatureIncludeList(options.features);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.noFeatures.length > 0) {
|
|
72
|
+
return parseFeatureExcludeList(options.noFeatures, ALL_FEATURES);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ...ALL_FEATURES };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function resolveFeatureSelectionInteractive(
|
|
79
|
+
options: CliOptions,
|
|
80
|
+
): Promise<FeatureSelection> {
|
|
81
|
+
const fromFlags = resolveFeatureSelection(options);
|
|
82
|
+
const hasExplicitFlags =
|
|
83
|
+
options.minimal ||
|
|
84
|
+
options.features !== undefined ||
|
|
85
|
+
options.noFeatures.length > 0;
|
|
86
|
+
|
|
87
|
+
if (hasExplicitFlags || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
88
|
+
return fromFlags;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return promptFeatureSelection(fromFlags);
|
|
92
|
+
}
|
|
93
|
+
|
|
52
94
|
export async function runCli(options: CliOptions): Promise<void> {
|
|
53
95
|
let targetArg = options.targetDir;
|
|
54
96
|
|
|
@@ -77,15 +119,18 @@ export async function runCli(options: CliOptions): Promise<void> {
|
|
|
77
119
|
);
|
|
78
120
|
}
|
|
79
121
|
|
|
122
|
+
const featureSelection = await resolveFeatureSelectionInteractive(options);
|
|
80
123
|
const source = resolveTemplateSource(options.repo, options.branch);
|
|
81
124
|
const tempDir = path.join(os.tmpdir(), `kavoru-${Date.now()}`);
|
|
82
125
|
|
|
83
126
|
log.info(`Creating Kavoru project "${packageName}"`);
|
|
127
|
+
log.info(`Features: ${formatFeatureSelection(featureSelection)}`);
|
|
84
128
|
|
|
85
129
|
try {
|
|
86
130
|
await fetchTemplate(source, tempDir);
|
|
87
131
|
await removeGitMetadata(tempDir);
|
|
88
132
|
await customizeProject(tempDir, packageName);
|
|
133
|
+
await applyFeatures(tempDir, featureSelection, packageName);
|
|
89
134
|
await copyTemplateIntoTarget(tempDir, targetDir);
|
|
90
135
|
|
|
91
136
|
if (options.install) {
|
package/src/features.ts
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { log } from "./log";
|
|
4
|
+
|
|
5
|
+
export type FeatureId =
|
|
6
|
+
| "auth"
|
|
7
|
+
| "prisma"
|
|
8
|
+
| "otel"
|
|
9
|
+
| "sentry"
|
|
10
|
+
| "kafka"
|
|
11
|
+
| "websocket"
|
|
12
|
+
| "resend"
|
|
13
|
+
| "cron"
|
|
14
|
+
| "docker";
|
|
15
|
+
|
|
16
|
+
export type FeatureSelection = Record<FeatureId, boolean>;
|
|
17
|
+
|
|
18
|
+
export type FeatureDef = {
|
|
19
|
+
id: FeatureId;
|
|
20
|
+
label: string;
|
|
21
|
+
description: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const FEATURES: FeatureDef[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "auth",
|
|
27
|
+
label: "JWT Authentication",
|
|
28
|
+
description: "Bearer auth, sign-in route, protected routes",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "prisma",
|
|
32
|
+
label: "Prisma + PostgreSQL",
|
|
33
|
+
description: "Prisma 7 config, migrations, seed scripts",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "otel",
|
|
37
|
+
label: "OpenTelemetry",
|
|
38
|
+
description: "OTLP tracing with Bun-compatible exporter",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "sentry",
|
|
42
|
+
label: "Sentry + Spotlight",
|
|
43
|
+
description: "Error monitoring and local Spotlight UI",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "kafka",
|
|
47
|
+
label: "Kafka",
|
|
48
|
+
description: "Producer, consumer, and example HTTP endpoints",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "websocket",
|
|
52
|
+
label: "WebSockets",
|
|
53
|
+
description: "Validated real-time connections with rooms",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "resend",
|
|
57
|
+
label: "Resend Email",
|
|
58
|
+
description: "Transactional email via sendEmail()",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "cron",
|
|
62
|
+
label: "Cron Jobs",
|
|
63
|
+
description: "Scheduled tasks via @elysiajs/cron",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "docker",
|
|
67
|
+
label: "Docker",
|
|
68
|
+
description: "Dockerfile and Docker Compose stack",
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const FEATURE_IDS = FEATURES.map((feature) => feature.id);
|
|
73
|
+
|
|
74
|
+
export const ALL_FEATURES: FeatureSelection = Object.fromEntries(
|
|
75
|
+
FEATURE_IDS.map((id) => [id, true]),
|
|
76
|
+
) as FeatureSelection;
|
|
77
|
+
|
|
78
|
+
export const MINIMAL_FEATURES: FeatureSelection = Object.fromEntries(
|
|
79
|
+
FEATURE_IDS.map((id) => [id, false]),
|
|
80
|
+
) as FeatureSelection;
|
|
81
|
+
|
|
82
|
+
const FEATURE_PATHS: Record<FeatureId, string[]> = {
|
|
83
|
+
auth: [
|
|
84
|
+
"src/modules/signin",
|
|
85
|
+
"src/modules/protected",
|
|
86
|
+
"src/middleware/auth.ts",
|
|
87
|
+
"src/infra/auth",
|
|
88
|
+
"src/constants/jwt.ts",
|
|
89
|
+
"src/models/schemas/signin.ts",
|
|
90
|
+
],
|
|
91
|
+
prisma: ["prisma.config.ts", "src/infra/prisma"],
|
|
92
|
+
otel: ["src/infra/telemetry"],
|
|
93
|
+
sentry: [
|
|
94
|
+
"src/infra/sentry",
|
|
95
|
+
"src/constants/sentry.ts",
|
|
96
|
+
"__tests__/sentry.test.ts",
|
|
97
|
+
],
|
|
98
|
+
kafka: [
|
|
99
|
+
"src/modules/kafka",
|
|
100
|
+
"src/infra/kafka",
|
|
101
|
+
"src/models/schemas/kafka.ts",
|
|
102
|
+
"__tests__/kafka.test.ts",
|
|
103
|
+
],
|
|
104
|
+
websocket: [
|
|
105
|
+
"src/modules/realtime",
|
|
106
|
+
"src/models/schemas/realtime.ts",
|
|
107
|
+
"__tests__/realtime.test.ts",
|
|
108
|
+
],
|
|
109
|
+
resend: ["src/infra/resend"],
|
|
110
|
+
cron: ["src/schedules"],
|
|
111
|
+
docker: ["Dockerfile", "docker-compose.yaml"],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const FEATURE_DEPENDENCIES: Partial<
|
|
115
|
+
Record<FeatureId, { dependencies?: string[]; devDependencies?: string[] }>
|
|
116
|
+
> = {
|
|
117
|
+
auth: { dependencies: ["@elysiajs/bearer", "@elysiajs/jwt"] },
|
|
118
|
+
prisma: {
|
|
119
|
+
dependencies: ["@prisma/adapter-pg", "@prisma/client"],
|
|
120
|
+
devDependencies: ["prisma"],
|
|
121
|
+
},
|
|
122
|
+
otel: {
|
|
123
|
+
dependencies: [
|
|
124
|
+
"@elysiajs/opentelemetry",
|
|
125
|
+
"@opentelemetry/exporter-trace-otlp-http",
|
|
126
|
+
"@opentelemetry/otlp-transformer",
|
|
127
|
+
"@opentelemetry/sdk-trace-node",
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
sentry: { dependencies: ["@sentry/elysia"] },
|
|
131
|
+
kafka: { dependencies: ["kafkajs"] },
|
|
132
|
+
resend: { dependencies: ["resend"] },
|
|
133
|
+
cron: { dependencies: ["@elysiajs/cron"] },
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const FEATURE_SCRIPTS: Partial<Record<FeatureId, string[]>> = {
|
|
137
|
+
otel: ["otel:view", "otel:tui"],
|
|
138
|
+
sentry: ["sentry:spotlight"],
|
|
139
|
+
prisma: ["seed"],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function enabledFeatures(selection: FeatureSelection): FeatureId[] {
|
|
143
|
+
return FEATURE_IDS.filter((id) => selection[id]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function disabledFeatures(selection: FeatureSelection): FeatureId[] {
|
|
147
|
+
return FEATURE_IDS.filter((id) => !selection[id]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseFeatureIncludeList(input: string): FeatureSelection {
|
|
151
|
+
const normalized = input.trim().toLowerCase();
|
|
152
|
+
if (!normalized || normalized === "all") return { ...ALL_FEATURES };
|
|
153
|
+
if (normalized === "minimal" || normalized === "none") {
|
|
154
|
+
return { ...MINIMAL_FEATURES };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const requested = normalized
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((part) => part.trim())
|
|
160
|
+
.filter(Boolean);
|
|
161
|
+
|
|
162
|
+
const unknown = requested.filter(
|
|
163
|
+
(id) => !FEATURE_IDS.includes(id as FeatureId),
|
|
164
|
+
);
|
|
165
|
+
if (unknown.length > 0) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const selection = { ...MINIMAL_FEATURES };
|
|
172
|
+
for (const id of requested) {
|
|
173
|
+
selection[id as FeatureId] = true;
|
|
174
|
+
}
|
|
175
|
+
return selection;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function parseFeatureExcludeList(
|
|
179
|
+
excluded: string[],
|
|
180
|
+
base: FeatureSelection = ALL_FEATURES,
|
|
181
|
+
): FeatureSelection {
|
|
182
|
+
const selection = { ...base };
|
|
183
|
+
const unknown: string[] = [];
|
|
184
|
+
|
|
185
|
+
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);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
selection[id as FeatureId] = false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (unknown.length > 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Unknown feature(s): ${unknown.join(", ")}. Valid: ${FEATURE_IDS.join(", ")}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return selection;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function formatFeatureSelection(selection: FeatureSelection): string {
|
|
205
|
+
const enabled = enabledFeatures(selection);
|
|
206
|
+
if (enabled.length === 0) return "core only";
|
|
207
|
+
return enabled.join(", ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function removePaths(projectDir: string, relativePaths: string[]) {
|
|
211
|
+
for (const relativePath of relativePaths) {
|
|
212
|
+
await rm(path.join(projectDir, relativePath), {
|
|
213
|
+
recursive: true,
|
|
214
|
+
force: true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function readText(projectDir: string, relativePath: string) {
|
|
220
|
+
const file = Bun.file(path.join(projectDir, relativePath));
|
|
221
|
+
if (!(await file.exists())) return null;
|
|
222
|
+
return file.text();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function writeText(
|
|
226
|
+
projectDir: string,
|
|
227
|
+
relativePath: string,
|
|
228
|
+
content: string,
|
|
229
|
+
) {
|
|
230
|
+
await Bun.write(path.join(projectDir, relativePath), content);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function removeImportLines(content: string, modules: string[]) {
|
|
234
|
+
let next = content;
|
|
235
|
+
for (const modulePath of modules) {
|
|
236
|
+
const escaped = modulePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
237
|
+
next = next.replace(
|
|
238
|
+
new RegExp(`^import .* from ["']${escaped}["'];?\\r?\\n`, "gm"),
|
|
239
|
+
"",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return next;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function removeUseLines(content: string, identifiers: string[]) {
|
|
246
|
+
let next = content;
|
|
247
|
+
for (const identifier of identifiers) {
|
|
248
|
+
next = next.replace(
|
|
249
|
+
new RegExp(`^\\s*\\.use\\(${identifier}\\)\\r?\\n`, "gm"),
|
|
250
|
+
"",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return next;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function patchModulesIndex(
|
|
257
|
+
projectDir: string,
|
|
258
|
+
selection: FeatureSelection,
|
|
259
|
+
) {
|
|
260
|
+
const relativePath = "src/modules/index.ts";
|
|
261
|
+
const current = await readText(projectDir, relativePath);
|
|
262
|
+
if (!current) return;
|
|
263
|
+
|
|
264
|
+
let content = current;
|
|
265
|
+
if (!selection.sentry) {
|
|
266
|
+
content = removeImportLines(content, ["../infra/sentry"]);
|
|
267
|
+
content = removeUseLines(content, ["withSentry"]);
|
|
268
|
+
}
|
|
269
|
+
if (!selection.otel) {
|
|
270
|
+
content = removeImportLines(content, ["../infra/telemetry"]);
|
|
271
|
+
content = removeUseLines(content, ["withOpenTelemetry"]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
content = content.replace(/app\s+\.use\(/g, "app\n .use(");
|
|
275
|
+
|
|
276
|
+
await writeText(projectDir, relativePath, content);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function patchEntryIndex(projectDir: string, selection: FeatureSelection) {
|
|
280
|
+
const imports = [
|
|
281
|
+
selection.sentry
|
|
282
|
+
? 'import { initSentry, flushSentry } from "./infra/sentry";'
|
|
283
|
+
: null,
|
|
284
|
+
selection.kafka
|
|
285
|
+
? 'import { startKafka, stopKafka } from "./infra/kafka";'
|
|
286
|
+
: null,
|
|
287
|
+
'import { HttpServer } from "./server/index";',
|
|
288
|
+
'import { logger } from "./common/logger";',
|
|
289
|
+
selection.kafka ? 'import { InternalServerError } from "elysia";' : null,
|
|
290
|
+
].filter(Boolean) as string[];
|
|
291
|
+
|
|
292
|
+
const body: string[] = [];
|
|
293
|
+
|
|
294
|
+
if (selection.sentry) {
|
|
295
|
+
body.push("", "initSentry();");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
body.push("", "const server = new HttpServer();", "");
|
|
299
|
+
|
|
300
|
+
if (selection.kafka) {
|
|
301
|
+
body.push(
|
|
302
|
+
"void server.start().then(async () => {",
|
|
303
|
+
" try {",
|
|
304
|
+
" await startKafka();",
|
|
305
|
+
" } catch (error) {",
|
|
306
|
+
' logger.error("Failed to start Kafka", { error });',
|
|
307
|
+
' throw new InternalServerError("Failed to start Kafka");',
|
|
308
|
+
" }",
|
|
309
|
+
"});",
|
|
310
|
+
);
|
|
311
|
+
} else {
|
|
312
|
+
body.push("void server.start();");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
body.push(
|
|
316
|
+
"",
|
|
317
|
+
"const shutdown = (signal: string) => {",
|
|
318
|
+
" return async () => {",
|
|
319
|
+
" logger.warn(`Received ${signal}, shutting down`);",
|
|
320
|
+
" await server.stop();",
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
if (selection.kafka) {
|
|
324
|
+
body.push(" await stopKafka();");
|
|
325
|
+
}
|
|
326
|
+
if (selection.sentry) {
|
|
327
|
+
body.push(" await flushSentry();");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
body.push(
|
|
331
|
+
" process.exit(0);",
|
|
332
|
+
" };",
|
|
333
|
+
"};",
|
|
334
|
+
"",
|
|
335
|
+
'process.on("SIGINT", () => void shutdown("SIGINT")());',
|
|
336
|
+
'process.on("SIGTERM", () => void shutdown("SIGTERM")());',
|
|
337
|
+
"",
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
await writeText(projectDir, "src/index.ts", [...imports, ...body].join("\n"));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function patchServerIndex(projectDir: string, selection: FeatureSelection) {
|
|
344
|
+
const relativePath = "src/server/index.ts";
|
|
345
|
+
const current = await readText(projectDir, relativePath);
|
|
346
|
+
if (!current) return;
|
|
347
|
+
|
|
348
|
+
let content = current;
|
|
349
|
+
|
|
350
|
+
if (selection.cron) {
|
|
351
|
+
content = content.replace(
|
|
352
|
+
/\/\/import \{ schedules \} from "\.\.\/schedules";.*\n/,
|
|
353
|
+
'import { schedules } from "../schedules";\n',
|
|
354
|
+
);
|
|
355
|
+
content = content.replace(
|
|
356
|
+
/\/\/\.use\(schedules\);.*\n/,
|
|
357
|
+
" .use(schedules);\n",
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
content = content.replace(
|
|
361
|
+
/^import \{ schedules \} from "\.\.\/schedules";\n/m,
|
|
362
|
+
"",
|
|
363
|
+
);
|
|
364
|
+
content = content.replace(/^\s*\.use\(schedules\);\n/m, "");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!selection.websocket) {
|
|
368
|
+
content = content.replace(
|
|
369
|
+
/new Elysia\(\{\s*websocket:\s*\{\s*idleTimeout:\s*120,\s*\},\s*\}\)/,
|
|
370
|
+
"new Elysia()",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await writeText(projectDir, relativePath, content);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function patchPackageJson(
|
|
378
|
+
projectDir: string,
|
|
379
|
+
selection: FeatureSelection,
|
|
380
|
+
) {
|
|
381
|
+
const pkgPath = path.join(projectDir, "package.json");
|
|
382
|
+
const pkgFile = Bun.file(pkgPath);
|
|
383
|
+
if (!(await pkgFile.exists())) return;
|
|
384
|
+
|
|
385
|
+
const pkg = (await pkgFile.json()) as {
|
|
386
|
+
dependencies?: Record<string, string>;
|
|
387
|
+
devDependencies?: Record<string, string>;
|
|
388
|
+
scripts?: Record<string, string>;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
for (const featureId of disabledFeatures(selection)) {
|
|
392
|
+
const deps = FEATURE_DEPENDENCIES[featureId];
|
|
393
|
+
if (deps?.dependencies) {
|
|
394
|
+
for (const name of deps.dependencies) {
|
|
395
|
+
delete pkg.dependencies?.[name];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (deps?.devDependencies) {
|
|
399
|
+
for (const name of deps.devDependencies) {
|
|
400
|
+
delete pkg.devDependencies?.[name];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const scripts = FEATURE_SCRIPTS[featureId];
|
|
405
|
+
if (scripts) {
|
|
406
|
+
for (const scriptName of scripts) {
|
|
407
|
+
delete pkg.scripts?.[scriptName];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!selection.prisma && pkg.scripts) {
|
|
413
|
+
pkg.scripts.start = "bun run src/index.ts";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function buildEnvExample(
|
|
420
|
+
packageName: string,
|
|
421
|
+
selection: FeatureSelection,
|
|
422
|
+
): string {
|
|
423
|
+
const lines = ["NODE_ENV=development", "PORT=3131", ""];
|
|
424
|
+
|
|
425
|
+
if (selection.prisma) {
|
|
426
|
+
lines.push(
|
|
427
|
+
"DATABASE_URL=postgresql://user:password@localhost:5432/your_database",
|
|
428
|
+
"",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (selection.otel) {
|
|
433
|
+
lines.push(
|
|
434
|
+
"# OpenTelemetry (optional in production; enabled by default in development)",
|
|
435
|
+
"# Terminal 1: bun run otel:view",
|
|
436
|
+
"# Terminal 2: bun run dev",
|
|
437
|
+
"# Disable locally: OTEL_EXPORTER_OTLP_ENDPOINT=",
|
|
438
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces",
|
|
439
|
+
`OTEL_SERVICE_NAME=${packageName}`,
|
|
440
|
+
"",
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (selection.sentry) {
|
|
445
|
+
lines.push(
|
|
446
|
+
"# Sentry Spotlight (local UI — enabled by default in development)",
|
|
447
|
+
"# Terminal 1: bun run sentry:spotlight",
|
|
448
|
+
"# Terminal 2: bun run dev",
|
|
449
|
+
"# Disable locally: SENTRY_SPOTLIGHT=false",
|
|
450
|
+
"SENTRY_SPOTLIGHT=true",
|
|
451
|
+
"",
|
|
452
|
+
"# Sentry cloud (optional — events also go to sentry.io when set)",
|
|
453
|
+
"# SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0",
|
|
454
|
+
"# SENTRY_TRACES_SAMPLE_RATE=1.0",
|
|
455
|
+
"",
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (selection.kafka) {
|
|
460
|
+
lines.push(
|
|
461
|
+
"# Kafka (enabled by default in development; disabled in test)",
|
|
462
|
+
"# Start broker: docker compose up -d kafka",
|
|
463
|
+
"# Host dev uses EXTERNAL listener on port 9094",
|
|
464
|
+
"# KAFKA_ENABLED=false",
|
|
465
|
+
"KAFKA_BROKERS=localhost:9094",
|
|
466
|
+
`KAFKA_CLIENT_ID=${packageName}`,
|
|
467
|
+
`KAFKA_GROUP_ID=${packageName}-consumer`,
|
|
468
|
+
"KAFKA_TOPIC=elysia.events",
|
|
469
|
+
"",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (selection.resend) {
|
|
474
|
+
lines.push(
|
|
475
|
+
"# Resend (disabled when RESEND_API_KEY is unset; always disabled in test)",
|
|
476
|
+
"# Create an API key: https://resend.com/api-keys",
|
|
477
|
+
"# RESEND_ENABLED=false",
|
|
478
|
+
"# RESEND_API_KEY=re_xxxxxxxx",
|
|
479
|
+
"# RESEND_FROM=Acme <onboarding@resend.dev>",
|
|
480
|
+
"",
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function patchEnvExample(
|
|
488
|
+
projectDir: string,
|
|
489
|
+
selection: FeatureSelection,
|
|
490
|
+
packageName = "kavoru",
|
|
491
|
+
) {
|
|
492
|
+
const content = buildEnvExample(packageName, selection);
|
|
493
|
+
await writeText(projectDir, ".env.example", content);
|
|
494
|
+
await writeText(projectDir, ".env", content);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function patchDockerfile(projectDir: string, selection: FeatureSelection) {
|
|
498
|
+
if (!selection.docker) return;
|
|
499
|
+
|
|
500
|
+
const relativePath = "Dockerfile";
|
|
501
|
+
const current = await readText(projectDir, relativePath);
|
|
502
|
+
if (!current) return;
|
|
503
|
+
|
|
504
|
+
let content = current;
|
|
505
|
+
if (!selection.prisma) {
|
|
506
|
+
content = content.replace(/^\s*COPY prisma\.config\.ts \.\/.*\n/m, "");
|
|
507
|
+
content = content.replace(/^\s*ENV DATABASE_URL=\$\{DATABASE_URL\}\n/m, "");
|
|
508
|
+
content = content.replace(/^\s*RUN bunx prisma db pull\n/m, "");
|
|
509
|
+
content = content.replace(/^\s*RUN bunx prisma generate\n/m, "");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
await writeText(projectDir, relativePath, content);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function generateDockerCompose(selection: FeatureSelection): string {
|
|
516
|
+
const appDependsOn = selection.kafka
|
|
517
|
+
? ` depends_on:
|
|
518
|
+
kafka:
|
|
519
|
+
condition: service_started
|
|
520
|
+
environment:
|
|
521
|
+
KAFKA_BROKERS: kafka:9092
|
|
522
|
+
`
|
|
523
|
+
: "";
|
|
524
|
+
|
|
525
|
+
const kafkaService = selection.kafka
|
|
526
|
+
? `
|
|
527
|
+
kafka:
|
|
528
|
+
image: bitnami/kafka:3.9
|
|
529
|
+
ports:
|
|
530
|
+
- "9094:9094"
|
|
531
|
+
environment:
|
|
532
|
+
KAFKA_CFG_NODE_ID: "0"
|
|
533
|
+
KAFKA_CFG_PROCESS_ROLES: controller,broker
|
|
534
|
+
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
|
|
535
|
+
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
|
|
536
|
+
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
|
|
537
|
+
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
|
|
538
|
+
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
|
539
|
+
KAFKA_CFG_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
|
540
|
+
ALLOW_PLAINTEXT_LISTENER: "yes"
|
|
541
|
+
networks:
|
|
542
|
+
- app_network
|
|
543
|
+
restart: unless-stopped
|
|
544
|
+
`
|
|
545
|
+
: "";
|
|
546
|
+
|
|
547
|
+
const jaegerService = selection.otel
|
|
548
|
+
? `
|
|
549
|
+
jaeger:
|
|
550
|
+
image: jaegertracing/all-in-one:1.62
|
|
551
|
+
ports:
|
|
552
|
+
- "16686:16686"
|
|
553
|
+
- "4318:4318"
|
|
554
|
+
environment:
|
|
555
|
+
COLLECTOR_OTLP_ENABLED: "true"
|
|
556
|
+
networks:
|
|
557
|
+
- app_network
|
|
558
|
+
restart: unless-stopped
|
|
559
|
+
`
|
|
560
|
+
: "";
|
|
561
|
+
|
|
562
|
+
return `services:
|
|
563
|
+
app:
|
|
564
|
+
build:
|
|
565
|
+
context: .
|
|
566
|
+
target: build
|
|
567
|
+
args:
|
|
568
|
+
PORT: \${PORT:-3131}
|
|
569
|
+
command: /app/server
|
|
570
|
+
volumes:
|
|
571
|
+
- ./src:/app/src
|
|
572
|
+
networks:
|
|
573
|
+
app_network:
|
|
574
|
+
aliases:
|
|
575
|
+
- app
|
|
576
|
+
extra_hosts:
|
|
577
|
+
- "host.docker.internal:host-gateway"
|
|
578
|
+
expose:
|
|
579
|
+
- "\${PORT}"
|
|
580
|
+
restart: unless-stopped
|
|
581
|
+
env_file:
|
|
582
|
+
- .env
|
|
583
|
+
${appDependsOn} healthcheck:
|
|
584
|
+
test: ["CMD", "curl", "-f", "http://localhost:\${PORT}/healthz"]
|
|
585
|
+
interval: 600s
|
|
586
|
+
timeout: 300s
|
|
587
|
+
retries: 1
|
|
588
|
+
start_period: 40s
|
|
589
|
+
${kafkaService}${jaegerService}
|
|
590
|
+
networks:
|
|
591
|
+
app_network:
|
|
592
|
+
name: app_network
|
|
593
|
+
driver: bridge
|
|
594
|
+
|
|
595
|
+
version: "3.8"
|
|
596
|
+
`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function patchDockerCompose(
|
|
600
|
+
projectDir: string,
|
|
601
|
+
selection: FeatureSelection,
|
|
602
|
+
) {
|
|
603
|
+
if (!selection.docker) return;
|
|
604
|
+
await writeText(projectDir, "docker-compose.yaml", generateDockerCompose(selection));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export async function applyFeatures(
|
|
608
|
+
projectDir: string,
|
|
609
|
+
selection: FeatureSelection,
|
|
610
|
+
packageName = "kavoru",
|
|
611
|
+
): Promise<void> {
|
|
612
|
+
const disabled = disabledFeatures(selection);
|
|
613
|
+
log.step(`Applying feature selection (${formatFeatureSelection(selection)})`);
|
|
614
|
+
|
|
615
|
+
for (const featureId of disabled) {
|
|
616
|
+
await removePaths(projectDir, FEATURE_PATHS[featureId]);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
await patchModulesIndex(projectDir, selection);
|
|
620
|
+
await patchEntryIndex(projectDir, selection);
|
|
621
|
+
await patchServerIndex(projectDir, selection);
|
|
622
|
+
await patchPackageJson(projectDir, selection);
|
|
623
|
+
await patchEnvExample(projectDir, selection, packageName);
|
|
624
|
+
await patchDockerfile(projectDir, selection);
|
|
625
|
+
await patchDockerCompose(projectDir, selection);
|
|
626
|
+
|
|
627
|
+
if (disabled.length > 0) {
|
|
628
|
+
log.success("Feature selection applied");
|
|
629
|
+
}
|
|
630
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as readline from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import {
|
|
4
|
+
ALL_FEATURES,
|
|
5
|
+
FEATURES,
|
|
6
|
+
MINIMAL_FEATURES,
|
|
7
|
+
type FeatureId,
|
|
8
|
+
type FeatureSelection,
|
|
9
|
+
formatFeatureSelection,
|
|
10
|
+
} from "./features";
|
|
11
|
+
|
|
12
|
+
function cloneSelection(selection: FeatureSelection): FeatureSelection {
|
|
13
|
+
return { ...selection };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function printFeatureMenu(selection: FeatureSelection) {
|
|
17
|
+
console.log();
|
|
18
|
+
console.log("Select optional features for your project:");
|
|
19
|
+
console.log(
|
|
20
|
+
" Type a number to toggle · a = all · m = minimal · Enter = continue",
|
|
21
|
+
);
|
|
22
|
+
console.log();
|
|
23
|
+
|
|
24
|
+
FEATURES.forEach((feature, index) => {
|
|
25
|
+
const checked = selection[feature.id] ? "x" : " ";
|
|
26
|
+
console.log(
|
|
27
|
+
` [${checked}] ${index + 1}. ${feature.label.padEnd(22)} ${feature.description}`,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(` Selected: ${formatFeatureSelection(selection)}`);
|
|
33
|
+
console.log();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function promptFeatureSelection(
|
|
37
|
+
initial: FeatureSelection = ALL_FEATURES,
|
|
38
|
+
): Promise<FeatureSelection> {
|
|
39
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
40
|
+
return cloneSelection(initial);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const selection = cloneSelection(initial);
|
|
44
|
+
const rl = readline.createInterface({ input, output });
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
while (true) {
|
|
48
|
+
printFeatureMenu(selection);
|
|
49
|
+
const answer = (await rl.question("Toggle feature: ")).trim().toLowerCase();
|
|
50
|
+
|
|
51
|
+
if (!answer) {
|
|
52
|
+
return selection;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (answer === "a" || answer === "all") {
|
|
56
|
+
Object.assign(selection, ALL_FEATURES);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (answer === "m" || answer === "minimal") {
|
|
61
|
+
Object.assign(selection, MINIMAL_FEATURES);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const index = Number.parseInt(answer, 10);
|
|
66
|
+
if (Number.isNaN(index) || index < 1 || index > FEATURES.length) {
|
|
67
|
+
console.log("Enter a number between 1 and 9, a, m, or press Enter.");
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const feature = FEATURES[index - 1];
|
|
72
|
+
if (!feature) continue;
|
|
73
|
+
selection[feature.id as FeatureId] = !selection[feature.id as FeatureId];
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
rl.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/template.ts
CHANGED
|
@@ -130,21 +130,6 @@ export async function customizeProject(
|
|
|
130
130
|
pkg.name = packageName;
|
|
131
131
|
await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
132
132
|
|
|
133
|
-
const envExamplePath = path.join(projectDir, ".env.example");
|
|
134
|
-
const envExample = Bun.file(envExamplePath);
|
|
135
|
-
if (await envExample.exists()) {
|
|
136
|
-
let envText = await envExample.text();
|
|
137
|
-
envText = envText
|
|
138
|
-
.replace(/^OTEL_SERVICE_NAME=kavoru$/m, `OTEL_SERVICE_NAME=${packageName}`)
|
|
139
|
-
.replace(/^KAFKA_CLIENT_ID=kavoru$/m, `KAFKA_CLIENT_ID=${packageName}`)
|
|
140
|
-
.replace(
|
|
141
|
-
/^KAFKA_GROUP_ID=kavoru-consumer$/m,
|
|
142
|
-
`KAFKA_GROUP_ID=${packageName}-consumer`,
|
|
143
|
-
);
|
|
144
|
-
await Bun.write(envExamplePath, envText);
|
|
145
|
-
await Bun.write(path.join(projectDir, ".env"), envText);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
133
|
const modulesIndex = path.join(projectDir, "src", "modules", "index.ts");
|
|
149
134
|
const modulesFile = Bun.file(modulesIndex);
|
|
150
135
|
if (await modulesFile.exists()) {
|