@b0tts/template-dev-installer 1.1.1 → 1.2.1
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/bin/cli.mjs +4 -2
- package/bin/reactive-checkbox.mjs +361 -0
- package/package.json +2 -2
package/bin/cli.mjs
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// 4. All — everything above
|
|
12
12
|
// ────────────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
import
|
|
14
|
+
import checkbox, { Separator } from "./reactive-checkbox.mjs";
|
|
15
|
+
import { confirm } from "@inquirer/prompts";
|
|
15
16
|
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
16
17
|
import { resolve, dirname } from "node:path";
|
|
17
18
|
import { fileURLToPath } from "node:url";
|
|
@@ -134,7 +135,8 @@ async function main() {
|
|
|
134
135
|
const chosen = await checkbox({
|
|
135
136
|
message: "Select categories to install (Space to toggle, Enter to confirm):",
|
|
136
137
|
choices: [
|
|
137
|
-
{ name: "All", value: "all" },
|
|
138
|
+
{ name: "All", value: "all", isAll: true },
|
|
139
|
+
new Separator(),
|
|
138
140
|
{ name: "Skills", value: "skills" },
|
|
139
141
|
{ name: "OpenCode", value: "opencode" },
|
|
140
142
|
{ name: "Pi", value: "pi" },
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// ── reactive-checkbox ───────────────────────────────────────────────
|
|
2
|
+
// Custom @inquirer/core prompt that wraps the standard checkbox
|
|
3
|
+
// behavior but adds a reactive "All" choice:
|
|
4
|
+
//
|
|
5
|
+
// • Space on "All" → toggles all other choices to match
|
|
6
|
+
// • Space on any other while "All" is checked → unchecks "All"
|
|
7
|
+
// • Manually checking all others → auto-checks "All"
|
|
8
|
+
// • 'a' (toggle all) updates "All" accordingly
|
|
9
|
+
// • 'i' (invert) updates "All" accordingly
|
|
10
|
+
//
|
|
11
|
+
// Matches the locally installed @inquirer/checkbox v2.5.0 API
|
|
12
|
+
// (yoctocolors-cjs, ansi-escapes, theme v2 structure).
|
|
13
|
+
// ────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createPrompt,
|
|
17
|
+
useState,
|
|
18
|
+
useKeypress,
|
|
19
|
+
usePrefix,
|
|
20
|
+
usePagination,
|
|
21
|
+
useMemo,
|
|
22
|
+
useRef,
|
|
23
|
+
makeTheme,
|
|
24
|
+
isUpKey,
|
|
25
|
+
isDownKey,
|
|
26
|
+
isSpaceKey,
|
|
27
|
+
isNumberKey,
|
|
28
|
+
isEnterKey,
|
|
29
|
+
ValidationError,
|
|
30
|
+
Separator,
|
|
31
|
+
} from "@inquirer/core";
|
|
32
|
+
import pc from "yoctocolors-cjs";
|
|
33
|
+
import figures from "@inquirer/figures";
|
|
34
|
+
import ansiEscapes from "ansi-escapes";
|
|
35
|
+
|
|
36
|
+
// ── Theme (mirrors @inquirer/checkbox v2.5.0 defaults) ─────────────
|
|
37
|
+
|
|
38
|
+
const checkboxTheme = {
|
|
39
|
+
icon: {
|
|
40
|
+
checked: pc.green(figures.circleFilled),
|
|
41
|
+
unchecked: figures.circle,
|
|
42
|
+
cursor: figures.pointer,
|
|
43
|
+
},
|
|
44
|
+
style: {
|
|
45
|
+
disabledChoice: (text) => pc.dim(`- ${text}`),
|
|
46
|
+
renderSelectedChoices: (selectedChoices) =>
|
|
47
|
+
selectedChoices.map((choice) => choice.short).join(", "),
|
|
48
|
+
description: (text) => pc.cyan(text),
|
|
49
|
+
},
|
|
50
|
+
helpMode: "auto",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ── "All" helpers ───────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function findAllIndex(items) {
|
|
56
|
+
return items.findIndex((item) => !Separator.isSeparator(item) && item.isAll);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reconcile "All" after individual toggles.
|
|
61
|
+
* If every non-All selectable item is checked → "All" becomes checked.
|
|
62
|
+
* Otherwise → "All" becomes unchecked.
|
|
63
|
+
*/
|
|
64
|
+
function reconcileAll(items) {
|
|
65
|
+
const allIdx = findAllIndex(items);
|
|
66
|
+
if (allIdx === -1) return items;
|
|
67
|
+
|
|
68
|
+
const nonAllSelectable = items.filter(
|
|
69
|
+
(item, i) => i !== allIdx && isSelectable(item),
|
|
70
|
+
);
|
|
71
|
+
const allChecked = nonAllSelectable.length > 0 &&
|
|
72
|
+
nonAllSelectable.every((item) => item.checked);
|
|
73
|
+
|
|
74
|
+
const allItem = items[allIdx];
|
|
75
|
+
if (allItem.checked === allChecked) return items;
|
|
76
|
+
|
|
77
|
+
const updated = [...items];
|
|
78
|
+
updated[allIdx] = { ...allItem, checked: allChecked };
|
|
79
|
+
return updated;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Helpers (from @inquirer/checkbox v2.5.0) ────────────────────────
|
|
83
|
+
|
|
84
|
+
function isSelectable(item) {
|
|
85
|
+
return !Separator.isSeparator(item) && !item.disabled;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toggle(item) {
|
|
89
|
+
return isSelectable(item) ? { ...item, checked: !item.checked } : item;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function check(checked) {
|
|
93
|
+
return function (item) {
|
|
94
|
+
return isSelectable(item) ? { ...item, checked } : item;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeChoices(choices) {
|
|
99
|
+
return choices.map((choice) => {
|
|
100
|
+
if (Separator.isSeparator(choice)) return choice;
|
|
101
|
+
|
|
102
|
+
if (typeof choice === "string") {
|
|
103
|
+
return {
|
|
104
|
+
value: choice,
|
|
105
|
+
name: choice,
|
|
106
|
+
short: choice,
|
|
107
|
+
disabled: false,
|
|
108
|
+
checked: false,
|
|
109
|
+
isAll: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const name = choice.name ?? String(choice.value);
|
|
114
|
+
return {
|
|
115
|
+
value: choice.value,
|
|
116
|
+
name,
|
|
117
|
+
short: choice.short ?? name,
|
|
118
|
+
description: choice.description,
|
|
119
|
+
disabled: choice.disabled ?? false,
|
|
120
|
+
checked: choice.checked ?? false,
|
|
121
|
+
isAll: choice.isAll ?? false,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── The prompt ──────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A checkbox prompt with an optional reactive "All" choice.
|
|
130
|
+
*
|
|
131
|
+
* Usage is identical to @inquirer/checkbox, with one addition:
|
|
132
|
+
* any choice with `{ isAll: true }` acts as a master selector.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* const answer = await reactiveCheckbox({
|
|
136
|
+
* message: "Select categories to install:",
|
|
137
|
+
* choices: [
|
|
138
|
+
* { name: "All", value: "all", isAll: true },
|
|
139
|
+
* new Separator(),
|
|
140
|
+
* { name: "Skills", value: "skills" },
|
|
141
|
+
* { name: "OpenCode", value: "opencode" },
|
|
142
|
+
* { name: "Pi", value: "pi" },
|
|
143
|
+
* ],
|
|
144
|
+
* });
|
|
145
|
+
*/
|
|
146
|
+
export default createPrompt((config, done) => {
|
|
147
|
+
const {
|
|
148
|
+
instructions,
|
|
149
|
+
pageSize = 7,
|
|
150
|
+
loop = true,
|
|
151
|
+
required,
|
|
152
|
+
validate = () => true,
|
|
153
|
+
} = config;
|
|
154
|
+
const theme = makeTheme(checkboxTheme, config.theme);
|
|
155
|
+
const prefix = usePrefix({ theme });
|
|
156
|
+
const firstRender = useRef(true);
|
|
157
|
+
const [status, setStatus] = useState("pending");
|
|
158
|
+
const [items, setItems] = useState(() => normalizeChoices(config.choices));
|
|
159
|
+
|
|
160
|
+
const bounds = useMemo(() => {
|
|
161
|
+
const first = items.findIndex(isSelectable);
|
|
162
|
+
const last = items.findLastIndex(isSelectable);
|
|
163
|
+
|
|
164
|
+
if (first < 0) {
|
|
165
|
+
throw new ValidationError(
|
|
166
|
+
"[checkbox prompt] No selectable choices. All choices are disabled.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { first, last };
|
|
171
|
+
}, [items]);
|
|
172
|
+
|
|
173
|
+
const [active, setActive] = useState(bounds.first);
|
|
174
|
+
const [showHelpTip, setShowHelpTip] = useState(true);
|
|
175
|
+
const [errorMsg, setError] = useState();
|
|
176
|
+
|
|
177
|
+
useKeypress(async (key) => {
|
|
178
|
+
if (isEnterKey(key)) {
|
|
179
|
+
const selection = items.filter((item) =>
|
|
180
|
+
!Separator.isSeparator(item) && item.checked,
|
|
181
|
+
);
|
|
182
|
+
const isValid = await validate([...selection]);
|
|
183
|
+
// "required" only counts non-All selectable items
|
|
184
|
+
if (required && !items.some((item) => isSelectable(item) && !item.isAll && item.checked)) {
|
|
185
|
+
setError("At least one choice must be selected");
|
|
186
|
+
} else if (isValid === true) {
|
|
187
|
+
setStatus("done");
|
|
188
|
+
done(selection.map((choice) => choice.value));
|
|
189
|
+
} else {
|
|
190
|
+
setError(isValid || "You must select a valid value");
|
|
191
|
+
}
|
|
192
|
+
} else if (isUpKey(key) || isDownKey(key)) {
|
|
193
|
+
if (
|
|
194
|
+
loop ||
|
|
195
|
+
(isUpKey(key) && active !== bounds.first) ||
|
|
196
|
+
(isDownKey(key) && active !== bounds.last)
|
|
197
|
+
) {
|
|
198
|
+
const offset = isUpKey(key) ? -1 : 1;
|
|
199
|
+
let next = active;
|
|
200
|
+
do {
|
|
201
|
+
next = (next + offset + items.length) % items.length;
|
|
202
|
+
} while (!isSelectable(items[next]));
|
|
203
|
+
setActive(next);
|
|
204
|
+
}
|
|
205
|
+
if (errorMsg) setError(undefined);
|
|
206
|
+
} else if (isSpaceKey(key)) {
|
|
207
|
+
setShowHelpTip(false);
|
|
208
|
+
const activeItem = items[active];
|
|
209
|
+
|
|
210
|
+
if (activeItem && !Separator.isSeparator(activeItem) && activeItem.isAll) {
|
|
211
|
+
// ── "All" toggled ────────────────────────────────────────
|
|
212
|
+
setError(undefined);
|
|
213
|
+
const newChecked = !activeItem.checked;
|
|
214
|
+
const allIdx = findAllIndex(items);
|
|
215
|
+
let updated = items.map((item, i) => {
|
|
216
|
+
if (i === allIdx) return { ...item, checked: newChecked };
|
|
217
|
+
if (isSelectable(item)) return { ...item, checked: newChecked };
|
|
218
|
+
return item;
|
|
219
|
+
});
|
|
220
|
+
setItems(updated);
|
|
221
|
+
} else {
|
|
222
|
+
// ── Individual item toggled ──────────────────────────────
|
|
223
|
+
setError(undefined);
|
|
224
|
+
const newItems = items.map((choice, i) =>
|
|
225
|
+
i === active ? toggle(choice) : choice,
|
|
226
|
+
);
|
|
227
|
+
setItems(reconcileAll(newItems));
|
|
228
|
+
}
|
|
229
|
+
} else if (key.name === "a") {
|
|
230
|
+
// Toggle all non-All items
|
|
231
|
+
const nonAll = items.filter(
|
|
232
|
+
(item) => isSelectable(item) && !item.isAll,
|
|
233
|
+
);
|
|
234
|
+
const selectAll = nonAll.some((item) => !item.checked);
|
|
235
|
+
|
|
236
|
+
const allIdx = findAllIndex(items);
|
|
237
|
+
let updated = items.map((item, i) => {
|
|
238
|
+
if (i === allIdx) return { ...item, checked: selectAll };
|
|
239
|
+
if (isSelectable(item) && !item.isAll) {
|
|
240
|
+
return { ...item, checked: selectAll };
|
|
241
|
+
}
|
|
242
|
+
return item;
|
|
243
|
+
});
|
|
244
|
+
setItems(updated);
|
|
245
|
+
} else if (key.name === "i") {
|
|
246
|
+
// Invert all non-All items, then reconcile All
|
|
247
|
+
const allIdx = findAllIndex(items);
|
|
248
|
+
let updated = items.map((item, i) => {
|
|
249
|
+
if (i === allIdx || !isSelectable(item)) return item;
|
|
250
|
+
return toggle(item);
|
|
251
|
+
});
|
|
252
|
+
updated = reconcileAll(updated);
|
|
253
|
+
setItems(updated);
|
|
254
|
+
} else if (isNumberKey(key)) {
|
|
255
|
+
const position = Number(key.name) - 1;
|
|
256
|
+
const item = items[position];
|
|
257
|
+
if (item != null && isSelectable(item)) {
|
|
258
|
+
setActive(position);
|
|
259
|
+
setShowHelpTip(false);
|
|
260
|
+
if (item.isAll) {
|
|
261
|
+
const newChecked = !item.checked;
|
|
262
|
+
const allIdx = findAllIndex(items);
|
|
263
|
+
let updated = items.map((item, i) => {
|
|
264
|
+
if (i === allIdx) return { ...item, checked: newChecked };
|
|
265
|
+
if (isSelectable(item)) return { ...item, checked: newChecked };
|
|
266
|
+
return item;
|
|
267
|
+
});
|
|
268
|
+
setItems(updated);
|
|
269
|
+
} else {
|
|
270
|
+
const newItems = items.map((choice, i) =>
|
|
271
|
+
i === position ? toggle(choice) : choice,
|
|
272
|
+
);
|
|
273
|
+
setItems(reconcileAll(newItems));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const message = theme.style.message(config.message);
|
|
280
|
+
|
|
281
|
+
let description;
|
|
282
|
+
const page = usePagination({
|
|
283
|
+
items,
|
|
284
|
+
active,
|
|
285
|
+
renderItem({ item, isActive }) {
|
|
286
|
+
if (Separator.isSeparator(item)) {
|
|
287
|
+
return ` ${item.separator}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (item.disabled) {
|
|
291
|
+
const disabledLabel =
|
|
292
|
+
typeof item.disabled === "string" ? item.disabled : "(disabled)";
|
|
293
|
+
return theme.style.disabledChoice(`${item.name} ${disabledLabel}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (isActive) {
|
|
297
|
+
description = item.description;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked;
|
|
301
|
+
const color = isActive ? theme.style.highlight : (x) => x;
|
|
302
|
+
const cursor = isActive ? theme.icon.cursor : " ";
|
|
303
|
+
return color(`${cursor}${checkbox} ${item.name}`);
|
|
304
|
+
},
|
|
305
|
+
pageSize,
|
|
306
|
+
loop,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (status === "done") {
|
|
310
|
+
const selection = items.filter(
|
|
311
|
+
(item) => !Separator.isSeparator(item) && item.checked,
|
|
312
|
+
);
|
|
313
|
+
const answer = theme.style.answer(
|
|
314
|
+
theme.style.renderSelectedChoices(selection, items),
|
|
315
|
+
);
|
|
316
|
+
return `${prefix} ${message} ${answer}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let helpTipTop = "";
|
|
320
|
+
let helpTipBottom = "";
|
|
321
|
+
if (
|
|
322
|
+
theme.helpMode === "always" ||
|
|
323
|
+
(theme.helpMode === "auto" &&
|
|
324
|
+
showHelpTip &&
|
|
325
|
+
(instructions === undefined || instructions))
|
|
326
|
+
) {
|
|
327
|
+
if (typeof instructions === "string") {
|
|
328
|
+
helpTipTop = instructions;
|
|
329
|
+
} else {
|
|
330
|
+
const keys = [
|
|
331
|
+
`${theme.style.key("space")} to select`,
|
|
332
|
+
`${theme.style.key("a")} to toggle all`,
|
|
333
|
+
`${theme.style.key("i")} to invert selection`,
|
|
334
|
+
`and ${theme.style.key("enter")} to proceed`,
|
|
335
|
+
];
|
|
336
|
+
helpTipTop = ` (Press ${keys.join(", ")})`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (
|
|
340
|
+
items.length > pageSize &&
|
|
341
|
+
(theme.helpMode === "always" ||
|
|
342
|
+
(theme.helpMode === "auto" && firstRender.current))
|
|
343
|
+
) {
|
|
344
|
+
helpTipBottom = `\n${theme.style.help("(Use arrow keys to reveal more choices)")}`;
|
|
345
|
+
firstRender.current = false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const choiceDescription = description
|
|
350
|
+
? `\n${theme.style.description(description)}`
|
|
351
|
+
: "";
|
|
352
|
+
|
|
353
|
+
let error = "";
|
|
354
|
+
if (errorMsg) {
|
|
355
|
+
error = `\n${theme.style.error(errorMsg)}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return `${prefix} ${message}${helpTipTop}\n${page}${helpTipBottom}${choiceDescription}${error}${ansiEscapes.cursorHide}`;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
export { Separator };
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@b0tts/template-dev-installer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Interactive installer for the DevelopmentTemplate — pick Skills, OpenCode, Pi, or install everything at once.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"template-dev-installer": "bin/cli.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"bin/
|
|
10
|
+
"bin/",
|
|
11
11
|
"files/"
|
|
12
12
|
],
|
|
13
13
|
"dependencies": {
|