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 +102 -101
- package/package.json +2 -2
- package/src/args.ts +144 -143
- package/src/features.ts +95 -25
- package/src/prompts.ts +187 -193
package/README.md
CHANGED
|
@@ -1,101 +1,102 @@
|
|
|
1
|
-
# kavoru (CLI)
|
|
2
|
-
|
|
3
|
-
Scaffold a new [Kavoru](https://
|
|
4
|
-
|
|
5
|
-
## Usage
|
|
6
|
-
|
|
7
|
-
After publishing to npm:
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
bunx kavoru@latest my-api
|
|
11
|
-
cd my-api
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Always use `@latest` so you get the newest published CLI. Equivalent to `bunx --bun kavoru@latest`.
|
|
16
|
-
|
|
17
|
-
**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
| `-
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
| `--
|
|
39
|
-
| `--
|
|
40
|
-
| `--
|
|
41
|
-
| `--
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
bun
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
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://
|
|
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,
|
|
41
|
-
(prisma is accepted as an alias for postgres; kavoru-cli for cli)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
bunx kavoru@latest my-api
|
|
46
|
-
bunx kavoru@latest my-api --
|
|
47
|
-
bunx kavoru@latest my-api --
|
|
48
|
-
bunx kavoru@latest
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
case "
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
case "
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
case "
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
break;
|
|
175
|
-
case "
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
}
|