pan-stack-cli 0.2.1 → 0.2.3

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 (59) hide show
  1. package/README.md +37 -731
  2. package/bin/pan.js +4978 -269
  3. package/package.json +17 -5
  4. package/lib/ai-skills.js +0 -829
  5. package/lib/create.js +0 -105
  6. package/lib/discover.js +0 -294
  7. package/lib/git/git-checkout.js +0 -395
  8. package/lib/git/git-clone.js +0 -206
  9. package/lib/git/git-fetch-all.js +0 -129
  10. package/lib/git/git-grv.js +0 -125
  11. package/lib/git/git-gst.js +0 -165
  12. package/lib/git/git-mr.js +0 -546
  13. package/lib/git/git-pull.js +0 -137
  14. package/lib/git/git-push.js +0 -185
  15. package/lib/git/git-sync-main.js +0 -334
  16. package/lib/git/helpers/bulk-report.js +0 -202
  17. package/lib/git/helpers/cli.js +0 -188
  18. package/lib/git/helpers/colors.js +0 -19
  19. package/lib/git/helpers/git.js +0 -136
  20. package/lib/git/helpers/manifest.js +0 -223
  21. package/lib/git/helpers/repo-select.js +0 -416
  22. package/lib/git/helpers/repo-summary.js +0 -161
  23. package/lib/i18n.js +0 -61
  24. package/lib/inquirer-theme.js +0 -20
  25. package/lib/language.js +0 -121
  26. package/lib/languages.js +0 -14
  27. package/lib/manifest.js +0 -467
  28. package/lib/menu-prompt.js +0 -201
  29. package/lib/menu.js +0 -134
  30. package/lib/messages.js +0 -647
  31. package/lib/platform.js +0 -140
  32. package/lib/run-command.js +0 -334
  33. package/lib/select.js +0 -0
  34. package/lib/serve.js +0 -75
  35. package/lib/skill-registry-client.js +0 -173
  36. package/lib/stats/aggregate.js +0 -51
  37. package/lib/stats/identity.js +0 -20
  38. package/lib/stats/index.js +0 -189
  39. package/lib/stats/parse.js +0 -46
  40. package/lib/stats/range.js +0 -38
  41. package/lib/stats/report.js +0 -264
  42. package/lib/stats/review.js +0 -157
  43. package/lib/stats/scan.js +0 -92
  44. package/lib/stats/serve.js +0 -25
  45. package/lib/ui/api.js +0 -202
  46. package/lib/ui/clone.js +0 -115
  47. package/lib/ui/config.js +0 -182
  48. package/lib/ui/diff.js +0 -128
  49. package/lib/ui/export-gate.js +0 -79
  50. package/lib/ui/gen-react-mobile-code.js +0 -6
  51. package/lib/ui/gen-react-web-code.js +0 -75
  52. package/lib/ui/index.js +0 -89
  53. package/lib/ui/init.js +0 -252
  54. package/lib/ui/login.js +0 -128
  55. package/lib/ui/logout.js +0 -22
  56. package/lib/ui/palettes.js +0 -103
  57. package/lib/ui/published-html.js +0 -24
  58. package/lib/ui/sync.js +0 -137
  59. package/lib/ui/workspace.js +0 -213
package/lib/manifest.js DELETED
@@ -1,467 +0,0 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const { t } = require("./i18n");
4
-
5
- const PLACEHOLDER_VALUES = new Set(["", "repo_url_to_be_filled", "pan_stack_repo_url"]);
6
-
7
- function cleanValue(value) {
8
- let result = String(value || "").trim();
9
- const commentIndex = result.indexOf(" #");
10
- if (commentIndex >= 0) {
11
- result = result.slice(0, commentIndex).trimEnd();
12
- }
13
- if (
14
- (result.startsWith('"') && result.endsWith('"')) ||
15
- (result.startsWith("'") && result.endsWith("'"))
16
- ) {
17
- result = result.slice(1, -1);
18
- }
19
- return result;
20
- }
21
-
22
- function splitKeyValue(text) {
23
- const index = text.indexOf(":");
24
- if (index < 0) {
25
- return [text.trim(), ""];
26
- }
27
- return [text.slice(0, index).trim(), cleanValue(text.slice(index + 1))];
28
- }
29
-
30
- function parseManifest(manifestPath) {
31
- const data = { repositories: [], stack: {}, commands: {} };
32
- let section = "";
33
- let currentRepo = null;
34
- let currentCommandName = "";
35
- let currentCommand = null;
36
- let currentTargetName = "";
37
- let blockMode = null;
38
- let blockIndent = 0;
39
- let blockLines = [];
40
-
41
- function flushBlock() {
42
- if (!blockMode || !currentCommand) {
43
- blockMode = null;
44
- blockLines = [];
45
- return;
46
- }
47
- const body = blockLines.join("\n");
48
- if (blockMode === "command-shell") {
49
- currentCommand.shell = body;
50
- } else if (blockMode === "command-sequence") {
51
- currentCommand.sequence = body;
52
- } else if (blockMode === "target-shell" && currentCommand.targets[currentTargetName]) {
53
- currentCommand.targets[currentTargetName].shell = body;
54
- }
55
- blockMode = null;
56
- blockLines = [];
57
- }
58
-
59
- const lines = fs.readFileSync(manifestPath, "utf8").split(/\r?\n/);
60
- for (const rawLine of lines) {
61
- if (blockMode) {
62
- const trimmed = rawLine.trimEnd();
63
- if (!trimmed.trim()) {
64
- blockLines.push("");
65
- continue;
66
- }
67
- const indent = rawLine.length - rawLine.trimStart().length;
68
- if (indent < blockIndent) {
69
- flushBlock();
70
- } else {
71
- blockLines.push(rawLine.slice(blockIndent));
72
- continue;
73
- }
74
- }
75
-
76
- if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
77
- continue;
78
- }
79
-
80
- const indent = rawLine.length - rawLine.trimStart().length;
81
- const text = rawLine.trim();
82
-
83
- if (indent === 0) {
84
- flushBlock();
85
- const [key, value] = splitKeyValue(text);
86
- section = key;
87
- currentRepo = null;
88
- currentCommandName = "";
89
- currentCommand = null;
90
- currentTargetName = "";
91
- if (value) {
92
- data[key] = value;
93
- } else if (!(key in data)) {
94
- data[key] = key === "commands" ? {} : {};
95
- }
96
- continue;
97
- }
98
-
99
- if (section === "stack" && indent === 2) {
100
- const [key, value] = splitKeyValue(text);
101
- data.stack[key] = value;
102
- continue;
103
- }
104
-
105
- if (section === "repositories") {
106
- if (indent === 2 && text.startsWith("- ")) {
107
- currentRepo = {};
108
- data.repositories.push(currentRepo);
109
- const rest = text.slice(2).trim();
110
- if (rest) {
111
- const [key, value] = splitKeyValue(rest);
112
- currentRepo[key] = value;
113
- }
114
- continue;
115
- }
116
- if (currentRepo && indent === 4) {
117
- const [key, value] = splitKeyValue(text);
118
- currentRepo[key] = value;
119
- }
120
- continue;
121
- }
122
-
123
- if (section === "commands") {
124
- if (indent === 2) {
125
- flushBlock();
126
- const [key, value] = splitKeyValue(text);
127
- currentCommandName = key;
128
- currentCommand = {};
129
- currentTargetName = "";
130
- data.commands[currentCommandName] = currentCommand;
131
- if (value) {
132
- currentCommand[key] = value;
133
- }
134
- continue;
135
- }
136
-
137
- if (indent === 4 && currentCommand) {
138
- flushBlock();
139
- const [key, value] = splitKeyValue(text);
140
- if (key === "shell" && (value === "|" || value === ">")) {
141
- blockMode = "command-shell";
142
- blockIndent = indent + 2;
143
- blockLines = [];
144
- continue;
145
- }
146
- if (key === "sequence" && (value === "|" || value === ">")) {
147
- blockMode = "command-sequence";
148
- blockIndent = indent + 2;
149
- blockLines = [];
150
- continue;
151
- }
152
- if (key === "targets") {
153
- currentCommand.targets = currentCommand.targets || {};
154
- continue;
155
- }
156
- currentCommand[key] = value;
157
- continue;
158
- }
159
-
160
- if (indent === 6 && currentCommand) {
161
- flushBlock();
162
- const [key, value] = splitKeyValue(text);
163
- currentTargetName = key;
164
- currentCommand.targets = currentCommand.targets || {};
165
- const target = {};
166
- currentCommand.targets[currentTargetName] = target;
167
- if (value) {
168
- target[key] = value;
169
- }
170
- if (key === "shell" && (value === "|" || value === ">")) {
171
- blockMode = "target-shell";
172
- blockIndent = indent + 2;
173
- blockLines = [];
174
- }
175
- continue;
176
- }
177
-
178
- if (indent === 8 && currentCommand && currentCommand.targets && currentTargetName) {
179
- flushBlock();
180
- const target = currentCommand.targets[currentTargetName];
181
- const [key, value] = splitKeyValue(text);
182
- if (key === "shell" && (value === "|" || value === ">")) {
183
- blockMode = "target-shell";
184
- blockIndent = indent + 2;
185
- blockLines = [];
186
- continue;
187
- }
188
- target[key] = value;
189
- }
190
- }
191
- }
192
-
193
- flushBlock();
194
- return data;
195
- }
196
-
197
- function uniqueRepos(repos) {
198
- const seen = new Set();
199
- const result = [];
200
- for (const repo of repos) {
201
- const key = [
202
- cleanValue(repo.name),
203
- cleanValue(repo.url),
204
- cleanValue(repo.path),
205
- ].join("\u0000");
206
- if (seen.has(key)) {
207
- continue;
208
- }
209
- seen.add(key);
210
- result.push(repo);
211
- }
212
- return result;
213
- }
214
-
215
- function manifestRepos(data) {
216
- return uniqueRepos(data.repositories || []);
217
- }
218
-
219
- function validateRepo(repo) {
220
- const name = cleanValue(repo.name);
221
- const url = cleanValue(repo.url);
222
- const repoPath = cleanValue(repo.path) || name;
223
-
224
- if (!name) {
225
- throw new Error(t("REPO_MISSING_NAME"));
226
- }
227
- if (PLACEHOLDER_VALUES.has(url)) {
228
- throw new Error(t("REPO_MISSING_URL__NAME", { name }));
229
- }
230
- if (!repoPath) {
231
- throw new Error(t("REPO_MISSING_PATH__NAME", { name }));
232
- }
233
- return { name, url, repoPath };
234
- }
235
-
236
- const LOCAL_MANIFEST_FILE = ".project.local.yaml";
237
-
238
- function localManifestPath(manifestPath) {
239
- return path.join(path.dirname(manifestPath), LOCAL_MANIFEST_FILE);
240
- }
241
-
242
- /**
243
- * Parse `.project.yaml` rồi GỘP THÊM `repositories:` từ `.project.local.yaml` cùng thư mục
244
- * (nếu có) — file cá nhân, KHÔNG commit (gitignore), chỉ cần có field `repositories:`. Dùng để
245
- * mỗi người tự thêm repo riêng (thử nghiệm, fork cá nhân…) mà không đụng `.project.yaml` chung.
246
- * Mọi lệnh đọc repo (clone/pull/push/fetch/grv/gst/sync-main/mr/checkout/stats/pan.yaml sources)
247
- * đều đi qua đây (loadManifest / loadManifestRepos) nên tự động thấy repo local, không cần sửa
248
- * riêng từng lệnh.
249
- */
250
- function parseManifestWithLocal(manifestPath) {
251
- const data = parseManifest(manifestPath);
252
- const localPath = localManifestPath(manifestPath);
253
- if (fs.existsSync(localPath)) {
254
- const localData = parseManifest(localPath);
255
- const localRepos = Array.isArray(localData.repositories) ? localData.repositories : [];
256
- if (localRepos.length) {
257
- data.repositories = [...(data.repositories || []), ...localRepos];
258
- console.error(`ℹ️ +${localRepos.length} repo từ ${LOCAL_MANIFEST_FILE}`);
259
- }
260
- }
261
- return data;
262
- }
263
-
264
- function loadManifest(manifestPath) {
265
- const resolved = path.resolve(manifestPath);
266
- if (!fs.existsSync(resolved)) {
267
- throw new Error(t("MANIFEST_NOT_FOUND__PATH", { path: manifestPath }));
268
- }
269
- const data = parseManifestWithLocal(resolved);
270
- return {
271
- path: resolved,
272
- root: path.dirname(resolved),
273
- project: cleanValue(data.project) || path.basename(path.dirname(resolved)),
274
- managedBy: cleanValue(data.managed_by),
275
- stack: data.stack || {},
276
- repositories: manifestRepos(data),
277
- commands: data.commands || {},
278
- };
279
- }
280
-
281
- const PAN_CONFIG_FILE = ".pan.yaml";
282
-
283
- function loadPanConfig(configPath, meta = {}) {
284
- const resolved = path.resolve(configPath);
285
- if (!fs.existsSync(resolved)) {
286
- throw new Error(t("CONFIG_NOT_FOUND__PATH", { path: configPath }));
287
- }
288
- const data = parseManifest(resolved);
289
- const root = path.dirname(resolved);
290
- return {
291
- path: resolved,
292
- root,
293
- workspaceRoot: meta.workspaceRoot || root,
294
- project: cleanValue(data.project) || meta.project || path.basename(root),
295
- repoName: meta.repoName || "",
296
- scope: meta.scope || "repo",
297
- commands: data.commands || {},
298
- };
299
- }
300
-
301
- function commandAliases(def) {
302
- if (!def) {
303
- return [];
304
- }
305
- const result = [];
306
- if (def.alias) {
307
- result.push(cleanValue(def.alias));
308
- }
309
- if (def.aliases) {
310
- const raw = def.aliases;
311
- if (Array.isArray(raw)) {
312
- result.push(...raw.map((item) => cleanValue(item)));
313
- } else {
314
- const value = cleanValue(raw);
315
- if (value.startsWith("[") && value.endsWith("]")) {
316
- result.push(
317
- ...value
318
- .slice(1, -1)
319
- .split(",")
320
- .map((item) => cleanValue(item))
321
- .filter(Boolean)
322
- );
323
- } else if (value.includes(",")) {
324
- result.push(
325
- ...value
326
- .split(",")
327
- .map((item) => cleanValue(item))
328
- .filter(Boolean)
329
- );
330
- } else if (value) {
331
- result.push(value);
332
- }
333
- }
334
- }
335
- return [...new Set(result.filter(Boolean))];
336
- }
337
-
338
- function resolveCommandName(sources, commandName) {
339
- if (sources.some((source) => source.commands[commandName])) {
340
- return commandName;
341
- }
342
- for (const source of sources) {
343
- for (const [primaryName, def] of Object.entries(source.commands || {})) {
344
- if (commandAliases(def).includes(commandName)) {
345
- return primaryName;
346
- }
347
- }
348
- }
349
- return commandName;
350
- }
351
-
352
- function getCommandDefinition(source, commandName, subArg) {
353
- const def = source.commands[commandName];
354
- if (!def) {
355
- return null;
356
- }
357
-
358
- if (def.targets && typeof def.targets === "object") {
359
- const targetName = subArg || def.default || Object.keys(def.targets)[0];
360
- const target = def.targets[targetName];
361
- if (!target) {
362
- const valid = Object.keys(def.targets).join(" | ");
363
- throw new Error(t("VALID_TARGETS__COMMAND_VALID", { command: commandName, valid }));
364
- }
365
- return {
366
- ...target,
367
- targetName,
368
- description: def.description,
369
- };
370
- }
371
-
372
- return def;
373
- }
374
-
375
- function listCommands(source) {
376
- return Object.entries(source.commands || {})
377
- .map(([name, def]) => ({
378
- name,
379
- aliases: commandAliases(def),
380
- description: def.description || "",
381
- hasTargets: Boolean(def.targets),
382
- targets: def.targets ? Object.keys(def.targets) : [],
383
- hasSequence: Boolean(def.sequence),
384
- }))
385
- // Sort A-Z theo tên lệnh — dễ dò trong menu tương tác (`pan` trống) và list help (`pan -h`),
386
- // thay vì theo thứ tự khai báo trong .pan.yaml (khó đoán khi file có nhiều lệnh).
387
- .sort((a, b) => a.name.localeCompare(b.name));
388
- }
389
-
390
- /**
391
- * Parse field `sequence` của 1 command → danh sách step chạy TUẦN TỰ theo thứ tự.
392
- * 2 cách viết:
393
- * sequence: build-fe, build-be # 1 dòng, phân tách bởi ,
394
- * sequence: | # nhiều dòng (khuyến nghị khi cần set biến)
395
- * build-fe
396
- * MODE=prod build-be:dev
397
- * build-extra? # dấu ? cuối = optional (lỗi vẫn chạy tiếp bước sau)
398
- * Mỗi dòng: [KEY=VAL ...] tênLệnh[:target][?]
399
- * • tênLệnh phải là 1 command KHÁC đã khai báo trong CÙNG file .pan.yaml này.
400
- * • `:target` chọn target nếu lệnh đó có `targets:`.
401
- * • `KEY=VAL` (đặt trước tên lệnh, cách nhau bởi khoảng trắng) → biến môi trường CHỈ áp dụng
402
- * cho riêng bước đó (không ảnh hưởng các bước khác).
403
- * • `?` cuối tên lệnh → bước lỗi (exit code != 0) KHÔNG dừng cả sequence, chỉ cảnh báo rồi
404
- * chạy tiếp bước sau. Không có `?` → lỗi là DỪNG NGAY (không chạy bước còn lại).
405
- */
406
- function parseSequenceSteps(raw) {
407
- const text = String(raw || "");
408
- const lines = text.includes("\n") ? text.split(/\r?\n/) : text.split(",");
409
- const steps = [];
410
- for (const rawLine of lines) {
411
- const line = rawLine.trim();
412
- if (!line || line.startsWith("#")) {
413
- continue;
414
- }
415
- const tokens = line.split(/\s+/).filter(Boolean);
416
- const env = {};
417
- let ref = null;
418
- for (const token of tokens) {
419
- const match = ref === null && token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
420
- if (match) {
421
- env[match[1]] = cleanValue(match[2]);
422
- continue;
423
- }
424
- if (ref !== null) {
425
- throw new Error(`Invalid sequence step: "${line}" (expected "[KEY=VAL ...] commandName[:target][?]")`);
426
- }
427
- ref = token;
428
- }
429
- if (!ref) {
430
- continue;
431
- }
432
- let optional = false;
433
- if (ref.endsWith("?")) {
434
- optional = true;
435
- ref = ref.slice(0, -1);
436
- }
437
- const [name, target = ""] = ref.split(":");
438
- if (!name) {
439
- throw new Error(`Invalid sequence step: "${line}" — missing command name.`);
440
- }
441
- steps.push({ raw: line, name, target, env, optional });
442
- }
443
- return steps;
444
- }
445
-
446
- function commandBaseDir(source) {
447
- return source.scope === "workspace" ? source.workspaceRoot : source.root;
448
- }
449
-
450
- module.exports = {
451
- PLACEHOLDER_VALUES,
452
- PAN_CONFIG_FILE,
453
- LOCAL_MANIFEST_FILE,
454
- cleanValue,
455
- parseManifest,
456
- parseManifestWithLocal,
457
- loadManifest,
458
- loadPanConfig,
459
- manifestRepos,
460
- validateRepo,
461
- commandAliases,
462
- resolveCommandName,
463
- getCommandDefinition,
464
- parseSequenceSteps,
465
- listCommands,
466
- commandBaseDir,
467
- };
@@ -1,201 +0,0 @@
1
- // Custom @inquirer prompt: danh sách chọn (↑↓/Enter, gõ chữ lọc) + PHÍM TẮT NHẤN-1-PHÍM chạy ngay
2
- // (vd h/v/b/x). Dùng @inquirer/core (dynamic ESM từ CJS). Thiếu core → menuSelect throw 'no-core'
3
- // để caller fallback sang @inquirer/prompts select.
4
- let cachePrompt = null;
5
-
6
- async function buildMenuPrompt() {
7
- if (cachePrompt) return cachePrompt;
8
- const core = await import("@inquirer/core").catch(() => null);
9
- if (!core) return null;
10
- const {
11
- createPrompt, useState, useKeypress, usePrefix, usePagination, useMemo,
12
- isEnterKey, isUpKey, isDownKey, Separator, makeTheme,
13
- } = core;
14
-
15
- cachePrompt = createPrompt((config, done) => {
16
- const { message, choices, hotkeys = [], pageSize = 15, footer = "" } = config;
17
- const theme = makeTheme(config.theme);
18
- const [status, setStatus] = useState("idle");
19
- const prefix = usePrefix({ status, theme });
20
- const items = useMemo(() => choices, [choices]);
21
- const firstSel = items.findIndex((it) => !Separator.isSeparator(it));
22
- const [active, setActive] = useState(firstSel < 0 ? 0 : firstSel);
23
-
24
- useKeypress((key, rl) => {
25
- if (status !== "idle") return;
26
- if (isEnterKey(key)) {
27
- const it = items[active];
28
- if (it && !Separator.isSeparator(it) && !it.disabled) {
29
- setStatus("done");
30
- done(it.value);
31
- }
32
- } else if (isUpKey(key) || isDownKey(key)) {
33
- rl.clearLine(0);
34
- const dir = isUpKey(key) ? -1 : 1;
35
- let n = active;
36
- do {
37
- n = (n + dir + items.length) % items.length;
38
- } while (Separator.isSeparator(items[n]) && n !== active);
39
- setActive(n);
40
- } else if (key.name && hotkeys.some((h) => h.key === key.name)) {
41
- // PHÍM TẮT: nhấn 1 phím → chạy ngay.
42
- setStatus("done");
43
- done(hotkeys.find((h) => h.key === key.name).value);
44
- } else {
45
- // type-ahead: nhảy tới item tên bắt đầu bằng ký tự đang gõ.
46
- const term = String(rl.line || "").toLowerCase();
47
- if (term) {
48
- const idx = items.findIndex(
49
- (it) => !Separator.isSeparator(it) && String(it.name).toLowerCase().startsWith(term)
50
- );
51
- if (idx >= 0) setActive(idx);
52
- }
53
- }
54
- });
55
-
56
- const activeItem = items[active] || {};
57
- if (status === "done") {
58
- return `${prefix} ${message} ${activeItem.name || ""}`.trim();
59
- }
60
-
61
- const page = usePagination({
62
- items,
63
- active,
64
- renderItem({ item, isActive }) {
65
- if (Separator.isSeparator(item)) return ` ${item.separator || "──────────"}`;
66
- const cursor = isActive ? "❯" : " ";
67
- const line = ` ${cursor} ${item.name}`;
68
- return isActive ? `\x1b[36m${line}\x1b[39m` : line;
69
- },
70
- pageSize,
71
- loop: false,
72
- });
73
-
74
- const desc = activeItem.description ? `\x1b[2m ${activeItem.description}\x1b[22m` : "";
75
- const hk = hotkeys.length
76
- ? " " + hotkeys.map((h) => `\x1b[1m${h.key}\x1b[22m ${h.label}`).join(" · ")
77
- : "";
78
- const help = footer || hk ? `\x1b[2m${footer}\x1b[22m${hk}` : "";
79
- return [`${prefix} ${message}`, page, desc, help].filter(Boolean).join("\n");
80
- });
81
- return cachePrompt;
82
- }
83
-
84
- async function menuSelect(config) {
85
- const prompt = await buildMenuPrompt();
86
- if (!prompt) throw new Error("no-core");
87
- return prompt(config);
88
- }
89
-
90
- // Multi-select với phím tắt (x thoát, b quay lại...) — trả:
91
- // { action: 'submit', values: string[] } khi Enter
92
- // { action: <hotkey.value> } khi bấm hotkey
93
- let cachePromptCheckbox = null;
94
-
95
- async function buildMenuCheckbox() {
96
- if (cachePromptCheckbox) return cachePromptCheckbox;
97
- const core = await import("@inquirer/core").catch(() => null);
98
- if (!core) return null;
99
- const {
100
- createPrompt, useState, useKeypress, usePrefix, usePagination, useMemo,
101
- isEnterKey, isUpKey, isDownKey, isSpaceKey, Separator, makeTheme,
102
- } = core;
103
-
104
- cachePromptCheckbox = createPrompt((config, done) => {
105
- const { message, choices, hotkeys = [], pageSize = 15, footer = "" } = config;
106
- const theme = makeTheme(config.theme);
107
- const [status, setStatus] = useState("idle");
108
- const prefix = usePrefix({ status, theme });
109
- const items = useMemo(() => choices, [choices]);
110
- const firstSel = items.findIndex((it) => !Separator.isSeparator(it));
111
- const [active, setActive] = useState(firstSel < 0 ? 0 : firstSel);
112
- const [checked, setChecked] = useState(new Set());
113
-
114
- function toggle(i) {
115
- const it = items[i];
116
- if (!it || Separator.isSeparator(it) || it.disabled) return;
117
- const next = new Set(checked);
118
- if (next.has(i)) next.delete(i);
119
- else next.add(i);
120
- setChecked(next);
121
- }
122
-
123
- useKeypress((key, rl) => {
124
- if (status !== "idle") return;
125
- if (isEnterKey(key)) {
126
- setStatus("done");
127
- const values = [];
128
- items.forEach((it, i) => {
129
- if (!Separator.isSeparator(it) && !it.disabled && checked.has(i)) values.push(it.value);
130
- });
131
- done({ action: "submit", values });
132
- } else if (isSpaceKey && isSpaceKey(key)) {
133
- rl.clearLine(0);
134
- toggle(active);
135
- } else if (key.name === "space") {
136
- rl.clearLine(0);
137
- toggle(active);
138
- } else if (isUpKey(key) || isDownKey(key)) {
139
- rl.clearLine(0);
140
- const dir = isUpKey(key) ? -1 : 1;
141
- let n = active;
142
- do {
143
- n = (n + dir + items.length) % items.length;
144
- } while (Separator.isSeparator(items[n]) && n !== active);
145
- setActive(n);
146
- } else if (key.name === "a") {
147
- // toggle-all: nếu chưa đủ → chọn hết; đã đủ → bỏ hết
148
- const selectable = items.map((it, i) => ({ it, i })).filter(({ it }) => !Separator.isSeparator(it) && !it.disabled);
149
- const allChecked = selectable.every(({ i }) => checked.has(i));
150
- const next = new Set();
151
- if (!allChecked) selectable.forEach(({ i }) => next.add(i));
152
- setChecked(next);
153
- } else if (key.name === "i") {
154
- // invert
155
- const next = new Set();
156
- items.forEach((it, i) => {
157
- if (!Separator.isSeparator(it) && !it.disabled && !checked.has(i)) next.add(i);
158
- });
159
- setChecked(next);
160
- } else if (key.name && hotkeys.some((h) => h.key === key.name)) {
161
- setStatus("done");
162
- done({ action: hotkeys.find((h) => h.key === key.name).value });
163
- }
164
- });
165
-
166
- if (status === "done") {
167
- return `${prefix} ${message}`.trim();
168
- }
169
-
170
- const page = usePagination({
171
- items,
172
- active,
173
- renderItem({ item, index, isActive }) {
174
- if (Separator.isSeparator(item)) return ` ${item.separator || "──────────"}`;
175
- const box = checked.has(index) ? "◉" : "◯";
176
- const cursor = isActive ? "❯" : " ";
177
- const line = ` ${cursor} ${box} ${item.name}`;
178
- return isActive ? `\x1b[36m${line}\x1b[39m` : line;
179
- },
180
- pageSize,
181
- loop: false,
182
- });
183
-
184
- const baseHelp = "space chọn · a chọn tất cả · i đảo";
185
- const hk = hotkeys.length
186
- ? " " + hotkeys.map((h) => `\x1b[1m${h.key}\x1b[22m ${h.label}`).join(" · ")
187
- : "";
188
- const enterTip = ` \x1b[1mEnter\x1b[22m \x1b[2mxác nhận\x1b[22m`;
189
- const help = `\x1b[2m${footer || baseHelp}\x1b[22m${hk}${enterTip}`;
190
- return [`${prefix} ${message}`, page, help].filter(Boolean).join("\n");
191
- });
192
- return cachePromptCheckbox;
193
- }
194
-
195
- async function menuCheckbox(config) {
196
- const prompt = await buildMenuCheckbox();
197
- if (!prompt) throw new Error("no-core");
198
- return prompt(config);
199
- }
200
-
201
- module.exports = { menuSelect, buildMenuPrompt, menuCheckbox, buildMenuCheckbox };