kavoru 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/prompts.ts +152 -45
package/README.md CHANGED
@@ -44,7 +44,7 @@ During setup you can pick which integrations to scaffold. Core is always include
44
44
  | `cron` | Cron jobs |
45
45
  | `docker` | Dockerfile + Compose |
46
46
 
47
- Interactive mode (TTY) shows a toggle menu after the project name. Non-interactive runs use the full stack unless you pass flags.
47
+ Interactive mode (TTY) shows a checkbox menu (↑↓ move, Space toggle, Enter confirm). Non-interactive runs use the full stack unless you pass flags.
48
48
 
49
49
  ### Examples
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kavoru",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a new Kavoru (Elysia + Bun) backend from the official template",
5
5
  "type": "module",
6
6
  "bin": {
package/src/prompts.ts CHANGED
@@ -1,5 +1,4 @@
1
- import * as readline from "node:readline/promises";
2
- import { stdin as input, stdout as output } from "node:process";
1
+ import { stdin, stdout } from "node:process";
3
2
  import {
4
3
  ALL_FEATURES,
5
4
  FEATURES,
@@ -9,70 +8,178 @@ import {
9
8
  formatFeatureSelection,
10
9
  } from "./features";
11
10
 
11
+ const ESC = "\x1b";
12
+ const dim = `${ESC}[2m`;
13
+ const reset = `${ESC}[0m`;
14
+ const cyan = `${ESC}[36m`;
15
+
12
16
  function cloneSelection(selection: FeatureSelection): FeatureSelection {
13
17
  return { ...selection };
14
18
  }
15
19
 
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();
20
+ type KeyAction =
21
+ | "up"
22
+ | "down"
23
+ | "toggle"
24
+ | "confirm"
25
+ | "all"
26
+ | "minimal"
27
+ | "interrupt";
28
+
29
+ const KEY_UP = ESC + "[A";
30
+ const KEY_DOWN = ESC + "[B";
31
+ const KEY_UP_ALT = ESC + "OA";
32
+ const KEY_DOWN_ALT = ESC + "OB";
33
+
34
+ function parseKeyInput(data: string): KeyAction | null {
35
+ if (data === "\u0003") return "interrupt";
36
+ if (data === "\r" || data === "\n") return "confirm";
37
+ if (data === " ") return "toggle";
38
+ if (data === "a" || data === "A") return "all";
39
+ if (data === "m" || data === "M") return "minimal";
40
+ if (data === KEY_UP || data === KEY_UP_ALT) return "up";
41
+ if (data === KEY_DOWN || data === KEY_DOWN_ALT) return "down";
42
+ return null;
43
+ }
44
+
45
+ function createKeyReader(onKey: (action: KeyAction) => void) {
46
+ let pending = "";
47
+
48
+ const onData = (chunk: string) => {
49
+ pending += chunk;
50
+
51
+ while (pending.length > 0) {
52
+ if (pending === ESC) return;
53
+
54
+ if (pending.startsWith(ESC)) {
55
+ if (pending.length < 3) return;
56
+
57
+ const action = parseKeyInput(pending.slice(0, 3));
58
+ if (action) {
59
+ pending = pending.slice(3);
60
+ onKey(action);
61
+ continue;
62
+ }
63
+
64
+ if (pending.startsWith(ESC + "O") && pending.length < 3) return;
65
+
66
+ pending = pending.slice(1);
67
+ continue;
68
+ }
69
+
70
+ const action = parseKeyInput(pending[0] ?? "");
71
+ pending = pending.slice(1);
72
+ if (action) onKey(action);
73
+ }
74
+ };
75
+
76
+ return onData;
77
+ }
78
+
79
+ function renderCheckboxMenu(
80
+ selection: FeatureSelection,
81
+ activeIndex: number,
82
+ lineCount: number,
83
+ ): number {
84
+ const lines: string[] = [
85
+ `${cyan}◆${reset} Select optional features ${dim}(↑↓ move · Space toggle · Enter confirm)${reset}`,
86
+ `${dim} a = all · m = minimal${reset}`,
87
+ "",
88
+ ];
23
89
 
24
90
  FEATURES.forEach((feature, index) => {
25
- const checked = selection[feature.id] ? "x" : " ";
26
- console.log(
27
- ` [${checked}] ${index + 1}. ${feature.label.padEnd(22)} ${feature.description}`,
91
+ const isActive = index === activeIndex;
92
+ const pointer = isActive ? `${cyan}❯${reset}` : " ";
93
+ const mark = selection[feature.id] ? "x" : " ";
94
+ const label = isActive ? `${cyan}${feature.label}${reset}` : feature.label;
95
+ lines.push(
96
+ ` ${pointer} [${mark}] ${label.padEnd(22)} ${dim}${feature.description}${reset}`,
28
97
  );
29
98
  });
30
99
 
31
- console.log();
32
- console.log(` Selected: ${formatFeatureSelection(selection)}`);
33
- console.log();
100
+ lines.push("", ` ${dim}Selected: ${formatFeatureSelection(selection)}${reset}`);
101
+
102
+ if (lineCount > 0) {
103
+ stdout.write(`${ESC}[${lineCount}A`);
104
+ }
105
+
106
+ for (const line of lines) {
107
+ stdout.write(`${ESC}[2K${ESC}[0G${line}\n`);
108
+ }
109
+
110
+ return lines.length;
111
+ }
112
+
113
+ function restoreTerminal(onData: (chunk: string) => void) {
114
+ stdin.off("data", onData);
115
+ if (stdin.isTTY) {
116
+ stdin.setRawMode(false);
117
+ }
118
+ stdin.pause();
119
+ stdout.write(`${ESC}[?25h`);
34
120
  }
35
121
 
36
122
  export async function promptFeatureSelection(
37
123
  initial: FeatureSelection = ALL_FEATURES,
38
124
  ): Promise<FeatureSelection> {
39
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
125
+ if (!stdin.isTTY || !stdout.isTTY) {
40
126
  return cloneSelection(initial);
41
127
  }
42
128
 
43
129
  const selection = cloneSelection(initial);
44
- const rl = readline.createInterface({ input, output });
130
+ let activeIndex = 0;
131
+ let lineCount = 0;
45
132
 
46
- try {
47
- while (true) {
48
- printFeatureMenu(selection);
49
- const answer = (await rl.question("Toggle feature: ")).trim().toLowerCase();
133
+ stdout.write(`${ESC}[?25l`);
50
134
 
51
- if (!answer) {
52
- return selection;
53
- }
54
-
55
- if (answer === "a" || answer === "all") {
56
- Object.assign(selection, ALL_FEATURES);
57
- continue;
58
- }
135
+ if (stdin.isTTY) {
136
+ stdin.setRawMode(true);
137
+ }
138
+ stdin.resume();
139
+ stdin.setEncoding("utf8");
59
140
 
60
- if (answer === "m" || answer === "minimal") {
61
- Object.assign(selection, MINIMAL_FEATURES);
62
- continue;
141
+ return new Promise((resolve, reject) => {
142
+ const onKey = (action: KeyAction) => {
143
+ switch (action) {
144
+ case "up":
145
+ activeIndex =
146
+ (activeIndex - 1 + FEATURES.length) % FEATURES.length;
147
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
148
+ break;
149
+ case "down":
150
+ activeIndex = (activeIndex + 1) % FEATURES.length;
151
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
152
+ break;
153
+ case "toggle": {
154
+ const feature = FEATURES[activeIndex];
155
+ if (!feature) break;
156
+ selection[feature.id as FeatureId] = !selection[feature.id as FeatureId];
157
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
158
+ break;
159
+ }
160
+ case "all":
161
+ Object.assign(selection, ALL_FEATURES);
162
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
163
+ break;
164
+ case "minimal":
165
+ Object.assign(selection, MINIMAL_FEATURES);
166
+ lineCount = renderCheckboxMenu(selection, activeIndex, lineCount);
167
+ break;
168
+ case "confirm":
169
+ restoreTerminal(onData);
170
+ stdout.write("\n");
171
+ resolve(selection);
172
+ break;
173
+ case "interrupt":
174
+ restoreTerminal(onData);
175
+ stdout.write("\n");
176
+ reject(new Error("Feature selection cancelled."));
177
+ break;
63
178
  }
179
+ };
64
180
 
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
- }
181
+ const onData = createKeyReader(onKey);
182
+ stdin.on("data", onData);
183
+ lineCount = renderCheckboxMenu(selection, activeIndex, 0);
184
+ });
78
185
  }