pan-stack-cli 0.1.4 → 0.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.
Files changed (64) hide show
  1. package/README.md +572 -23
  2. package/bin/pan.js +206 -20
  3. package/lib/ai-skills.js +829 -0
  4. package/lib/create.js +22 -4
  5. package/lib/discover.js +69 -2
  6. package/lib/git/git-checkout.js +395 -0
  7. package/lib/git/git-clone.js +163 -28
  8. package/lib/git/git-fetch-all.js +100 -33
  9. package/lib/git/git-grv.js +68 -37
  10. package/lib/git/git-gst.js +97 -26
  11. package/lib/git/git-mr.js +546 -0
  12. package/lib/git/git-pull.js +104 -43
  13. package/lib/git/git-push.js +150 -41
  14. package/lib/git/git-sync-main.js +334 -0
  15. package/lib/git/helpers/bulk-report.js +202 -0
  16. package/lib/git/helpers/cli.js +41 -5
  17. package/lib/git/helpers/colors.js +19 -0
  18. package/lib/git/helpers/git.js +74 -0
  19. package/lib/git/helpers/manifest.js +93 -0
  20. package/lib/git/helpers/repo-select.js +416 -0
  21. package/lib/git/helpers/repo-summary.js +161 -0
  22. package/lib/i18n.js +61 -0
  23. package/lib/inquirer-theme.js +20 -0
  24. package/lib/language.js +121 -0
  25. package/lib/languages.js +14 -0
  26. package/lib/manifest.js +115 -14
  27. package/lib/menu-prompt.js +201 -0
  28. package/lib/menu.js +134 -0
  29. package/lib/messages.js +647 -0
  30. package/lib/run-command.js +173 -52
  31. package/lib/select.js +0 -0
  32. package/lib/serve.js +75 -0
  33. package/lib/skill-registry-client.js +173 -0
  34. package/lib/stats/aggregate.js +51 -0
  35. package/lib/stats/identity.js +20 -0
  36. package/lib/stats/index.js +189 -0
  37. package/lib/stats/parse.js +46 -0
  38. package/lib/stats/range.js +38 -0
  39. package/lib/stats/report.js +264 -0
  40. package/lib/stats/review.js +157 -0
  41. package/lib/stats/scan.js +92 -0
  42. package/lib/stats/serve.js +25 -0
  43. package/lib/ui/api.js +202 -0
  44. package/lib/ui/clone.js +115 -0
  45. package/lib/ui/config.js +182 -0
  46. package/lib/ui/diff.js +128 -0
  47. package/lib/ui/export-gate.js +79 -0
  48. package/lib/ui/gen-react-mobile-code.js +6 -0
  49. package/lib/ui/gen-react-web-code.js +75 -0
  50. package/lib/ui/index.js +89 -0
  51. package/lib/ui/init.js +252 -0
  52. package/lib/ui/login.js +128 -0
  53. package/lib/ui/logout.js +22 -0
  54. package/lib/ui/palettes.js +103 -0
  55. package/lib/ui/published-html.js +24 -0
  56. package/lib/ui/sync.js +137 -0
  57. package/lib/ui/workspace.js +213 -0
  58. package/package.json +10 -3
  59. package/templates/workspace/.claude/skills/writing-guidelines/SKILL.md +0 -48
  60. package/templates/workspace/.codex/skills/writing-guidelines/SKILL.md +0 -48
  61. package/templates/workspace/.cursor/skills/writing-guidelines/SKILL.md +0 -48
  62. package/templates/workspace/.pan.yaml +0 -25
  63. package/templates/workspace/.project.yaml +0 -15
  64. package/templates/workspace/README.md +0 -35
package/lib/i18n.js ADDED
@@ -0,0 +1,61 @@
1
+ // Ngôn ngữ pan CLI: MẶC ĐỊNH 'en'. Ưu tiên `language:` trong .project.yaml gần nhất (walk-up từ cwd),
2
+ // hoặc env PAN_LANG. Cache 1 lần/tiến trình.
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const MESSAGES = require("./messages");
6
+
7
+ let cached = null;
8
+
9
+ function detectLang() {
10
+ if (process.env.PAN_LANG) return String(process.env.PAN_LANG).toLowerCase();
11
+ let dir = process.cwd();
12
+ for (let i = 0; i < 50; i += 1) {
13
+ const p = path.join(dir, ".project.yaml");
14
+ try {
15
+ if (fs.existsSync(p)) {
16
+ const m = fs.readFileSync(p, "utf8").match(/^\s*language\s*:\s*['"]?([A-Za-z-]+)/m);
17
+ return m ? m[1].toLowerCase() : "en";
18
+ }
19
+ } catch {
20
+ /* bỏ qua */
21
+ }
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir) break;
24
+ dir = parent;
25
+ }
26
+ return "en";
27
+ }
28
+
29
+ function lang() {
30
+ if (cached === null) cached = detectLang();
31
+ return cached;
32
+ }
33
+
34
+ // Ghi đè cache trong tiến trình hiện tại (vd sau khi `pan language <code>` vừa ghi file) — để các
35
+ // thông báo in ra ngay sau đó (cùng lệnh) dùng ngôn ngữ mới thay vì ngôn ngữ đã cache trước đó.
36
+ function setLangOverride(code) {
37
+ cached = String(code).toLowerCase();
38
+ }
39
+
40
+ function availableLangs() {
41
+ return Object.keys(MESSAGES);
42
+ }
43
+
44
+ function interpolate(template, vars) {
45
+ if (!vars) return template;
46
+ return template.replace(/\{(\w+)\}/g, (match, name) => (name in vars ? String(vars[name]) : match));
47
+ }
48
+
49
+ /**
50
+ * t('SOME_KEY', { var1, var2 }) → chuỗi theo ngôn ngữ hiện tại, nội suy {var1}/{var2} trong template.
51
+ * Thêm ngôn ngữ mới: thêm object con trong messages.js — KHÔNG cần sửa call site nào.
52
+ * Thiếu key ở ngôn ngữ hiện tại → fallback 'en' → fallback chính key (không throw, luôn có gì đó để in ra).
53
+ */
54
+ function t(key, vars) {
55
+ const currentLang = lang();
56
+ const dict = MESSAGES[currentLang] || MESSAGES.en;
57
+ const template = dict[key] ?? MESSAGES.en[key] ?? key;
58
+ return interpolate(template, vars);
59
+ }
60
+
61
+ module.exports = { lang, t, setLangOverride, availableLangs };
@@ -0,0 +1,20 @@
1
+ // Help-tip dùng chung cho @inquirer select/checkbox (repo-select, select, menu). MẶC ĐỊNH English;
2
+ // 'vi' khi .project.yaml có `language: vi` (xem i18n.js).
3
+ const { lang } = require("./i18n");
4
+
5
+ const HELP = {
6
+ en: { multi: "↑↓ move · space select · a all · i invert · enter confirm", single: "↑↓ move · enter select" },
7
+ vi: { multi: "↑↓ di chuyển · space chọn · a tất cả · i đảo · Enter xác nhận", single: "↑↓ di chuyển · Enter chọn" },
8
+ };
9
+
10
+ function helpText(multi) {
11
+ const h = HELP[lang()] || HELP.en;
12
+ return multi ? h.multi : h.single;
13
+ }
14
+
15
+ // theme cho @inquirer: override keysHelpTip (nhận mảng [key,action]) → chuỗi help theo ngôn ngữ.
16
+ function helpTheme(multi) {
17
+ return { style: { keysHelpTip: () => ` (${helpText(multi)})` } };
18
+ }
19
+
20
+ module.exports = { helpText, helpTheme };
@@ -0,0 +1,121 @@
1
+ // `pan language [<code>]` — đổi ngôn ngữ CLI bằng cách ghi `language: <code>` vào .project.yaml
2
+ // gần nhất (walk-up từ cwd, giống findNearestProjectManifest). Chưa có key → thêm; đã có → update.
3
+ // Không truyền code → menu tương tác (danh sách ngôn ngữ available), theo đúng quy ước chung của
4
+ // mọi menu trong pan: ↑↓ di chuyển, Enter chọn, x thoát hẳn, b quay lại (không đổi gì).
5
+ const fs = require("fs");
6
+ const { findNearestProjectManifest } = require("./discover");
7
+ const { loadInquirer } = require("./select");
8
+ const { menuSelect } = require("./menu-prompt");
9
+ const { helpTheme } = require("./inquirer-theme");
10
+ const { t, lang, setLangOverride, availableLangs } = require("./i18n");
11
+ const LANGUAGE_NAMES = require("./languages");
12
+
13
+ function isCancelError(err) {
14
+ const s = String((err && (err.name + " " + err.message)) || "");
15
+ return /ExitPromptError|force closed|SIGINT|cancel/i.test(s);
16
+ }
17
+
18
+ function langLabel(code) {
19
+ return (LANGUAGE_NAMES[code] && LANGUAGE_NAMES[code].name) || code;
20
+ }
21
+
22
+ const TOP_LEVEL_LANGUAGE_RE = /^language\s*:.*$/m;
23
+ const TOP_LEVEL_MANAGED_BY_RE = /^managed_by\s*:.*$/m;
24
+ const TOP_LEVEL_PROJECT_RE = /^project\s*:.*$/m;
25
+
26
+ function upsertLanguageLine(content, code) {
27
+ const newLine = `language: ${code}`;
28
+ if (TOP_LEVEL_LANGUAGE_RE.test(content)) {
29
+ return content.replace(TOP_LEVEL_LANGUAGE_RE, newLine);
30
+ }
31
+ const anchorRe = TOP_LEVEL_MANAGED_BY_RE.test(content) ? TOP_LEVEL_MANAGED_BY_RE : TOP_LEVEL_PROJECT_RE;
32
+ if (anchorRe.test(content)) {
33
+ return content.replace(anchorRe, (line) => `${line}\n${newLine}`);
34
+ }
35
+ // Không có project:/managed_by: (manifest lạ) → chèn lên đầu file.
36
+ return `${newLine}\n${content}`;
37
+ }
38
+
39
+ // @returns mã ngôn ngữ được chọn, hoặc "__exit__"/"__back__"/null (không chọn được gì — thiếu
40
+ // TTY/dependency picker, caller tự in usage).
41
+ async function promptLangChoice() {
42
+ const langs = availableLangs();
43
+ const current = lang();
44
+ const choices = langs.map((code) => ({
45
+ name: code === current ? `${langLabel(code)} (${code}) — hiện tại` : `${langLabel(code)} (${code})`,
46
+ value: code,
47
+ short: code,
48
+ }));
49
+ const hotkeys = [
50
+ { key: "x", label: t("EXIT_LABEL"), value: "__exit__" },
51
+ { key: "b", label: t("BACK_LABEL"), value: "__back__" },
52
+ ];
53
+
54
+ try {
55
+ // Ưu tiên menu-prompt (phím tắt x/b nhấn-1-phím); thiếu @inquirer/core → fallback
56
+ // @inquirer/prompts select với item ẩn cuối danh sách cho x/b (vẫn chọn được bằng Enter).
57
+ try {
58
+ return await menuSelect({ message: t("LANGUAGE_SELECT_PROMPT"), choices, hotkeys, pageSize: 10 });
59
+ } catch (err) {
60
+ if (String(err && err.message) !== "no-core") throw err;
61
+ const inq = await loadInquirer();
62
+ if (!inq || typeof inq.select !== "function" || !process.stdin.isTTY) {
63
+ return null;
64
+ }
65
+ return await inq.select({
66
+ message: t("LANGUAGE_SELECT_PROMPT"),
67
+ choices: [
68
+ ...choices,
69
+ new inq.Separator(),
70
+ { name: `b ${t("BACK_LABEL")}`, value: "__back__", short: "b" },
71
+ { name: `x ${t("EXIT_LABEL")}`, value: "__exit__", short: "x" },
72
+ ],
73
+ pageSize: 10,
74
+ loop: false,
75
+ theme: helpTheme(false),
76
+ });
77
+ }
78
+ } catch (err) {
79
+ if (isCancelError(err)) return "__exit__";
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ async function runLanguage(args) {
85
+ const manifestPath = findNearestProjectManifest(process.cwd());
86
+ let code = (args[0] || "").trim().toLowerCase();
87
+
88
+ if (!code) {
89
+ if (manifestPath) {
90
+ console.log(t("LANGUAGE_CURRENT__LANG_PATH", { lang: lang(), path: manifestPath }));
91
+ } else {
92
+ console.log(t("LANGUAGE_CURRENT_NO_PROJECT__LANG", { lang: lang() }));
93
+ }
94
+ console.log(t("LANGUAGE_AVAILABLE__LANGS", { langs: availableLangs().map((c) => `${c} (${langLabel(c)})`).join(", ") }));
95
+ code = await promptLangChoice();
96
+ if (code === "__exit__") {
97
+ process.exit(0);
98
+ }
99
+ if (!code || code === "__back__") {
100
+ console.log(t("LANGUAGE_USAGE"));
101
+ return;
102
+ }
103
+ }
104
+
105
+ const langs = availableLangs();
106
+ if (!langs.includes(code)) {
107
+ throw new Error(t("LANGUAGE_UNKNOWN__LANG_AVAILABLE", { lang: code, available: langs.join(", ") }));
108
+ }
109
+
110
+ if (!manifestPath) {
111
+ throw new Error(t("LANGUAGE_NO_PROJECT_YAML"));
112
+ }
113
+
114
+ const content = fs.readFileSync(manifestPath, "utf8");
115
+ const updated = upsertLanguageLine(content, code);
116
+ fs.writeFileSync(manifestPath, updated, "utf8");
117
+ setLangOverride(code);
118
+ console.log(t("LANGUAGE_SET__LANG_PATH", { lang: code, path: manifestPath }));
119
+ }
120
+
121
+ module.exports = { runLanguage };
@@ -0,0 +1,14 @@
1
+ // Định nghĩa NGÔN NGỮ available của pan CLI — nguồn duy nhất cho tên hiển thị (dùng bởi
2
+ // `pan language`/`pan lang` menu chọn ngôn ngữ). Danh sách mã hợp lệ thực tế lấy từ
3
+ // `Object.keys(messages.js)` (xem lib/i18n.js#availableLangs) — file này chỉ cung cấp
4
+ // tên người-đọc-được cho từng mã.
5
+ //
6
+ // Thêm ngôn ngữ mới:
7
+ // 1. Thêm 1 object con trong lib/messages.js (vd `ja: { ... }`) — copy đủ key từ `en`.
8
+ // 2. Thêm 1 dòng ở đây (mã → tên hiển thị) để menu `pan language` in đúng tên.
9
+ // Thiếu dòng ở đây (mã có trong messages.js nhưng chưa khai ở đây) → menu vẫn hiện, chỉ
10
+ // dùng luôn mã (vd "ja") làm tên hiển thị thay vì tên đầy đủ.
11
+ module.exports = {
12
+ en: { code: "en", name: "English" },
13
+ vi: { code: "vi", name: "Tiếng Việt" },
14
+ };
package/lib/manifest.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
+ const { t } = require("./i18n");
3
4
 
4
5
  const PLACEHOLDER_VALUES = new Set(["", "repo_url_to_be_filled", "pan_stack_repo_url"]);
5
6
 
@@ -46,6 +47,8 @@ function parseManifest(manifestPath) {
46
47
  const body = blockLines.join("\n");
47
48
  if (blockMode === "command-shell") {
48
49
  currentCommand.shell = body;
50
+ } else if (blockMode === "command-sequence") {
51
+ currentCommand.sequence = body;
49
52
  } else if (blockMode === "target-shell" && currentCommand.targets[currentTargetName]) {
50
53
  currentCommand.targets[currentTargetName].shell = body;
51
54
  }
@@ -140,6 +143,12 @@ function parseManifest(manifestPath) {
140
143
  blockLines = [];
141
144
  continue;
142
145
  }
146
+ if (key === "sequence" && (value === "|" || value === ">")) {
147
+ blockMode = "command-sequence";
148
+ blockIndent = indent + 2;
149
+ blockLines = [];
150
+ continue;
151
+ }
143
152
  if (key === "targets") {
144
153
  currentCommand.targets = currentCommand.targets || {};
145
154
  continue;
@@ -213,23 +222,51 @@ function validateRepo(repo) {
213
222
  const repoPath = cleanValue(repo.path) || name;
214
223
 
215
224
  if (!name) {
216
- throw new Error("Repository entry is missing 'name'.");
225
+ throw new Error(t("REPO_MISSING_NAME"));
217
226
  }
218
227
  if (PLACEHOLDER_VALUES.has(url)) {
219
- throw new Error(`Repository '${name}' is missing a real url.`);
228
+ throw new Error(t("REPO_MISSING_URL__NAME", { name }));
220
229
  }
221
230
  if (!repoPath) {
222
- throw new Error(`Repository '${name}' is missing 'path'.`);
231
+ throw new Error(t("REPO_MISSING_PATH__NAME", { name }));
223
232
  }
224
233
  return { name, url, repoPath };
225
234
  }
226
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
+
227
264
  function loadManifest(manifestPath) {
228
265
  const resolved = path.resolve(manifestPath);
229
266
  if (!fs.existsSync(resolved)) {
230
- throw new Error(`Manifest not found: ${manifestPath}`);
267
+ throw new Error(t("MANIFEST_NOT_FOUND__PATH", { path: manifestPath }));
231
268
  }
232
- const data = parseManifest(resolved);
269
+ const data = parseManifestWithLocal(resolved);
233
270
  return {
234
271
  path: resolved,
235
272
  root: path.dirname(resolved),
@@ -246,7 +283,7 @@ const PAN_CONFIG_FILE = ".pan.yaml";
246
283
  function loadPanConfig(configPath, meta = {}) {
247
284
  const resolved = path.resolve(configPath);
248
285
  if (!fs.existsSync(resolved)) {
249
- throw new Error(`Config not found: ${configPath}`);
286
+ throw new Error(t("CONFIG_NOT_FOUND__PATH", { path: configPath }));
250
287
  }
251
288
  const data = parseManifest(resolved);
252
289
  const root = path.dirname(resolved);
@@ -323,7 +360,7 @@ function getCommandDefinition(source, commandName, subArg) {
323
360
  const target = def.targets[targetName];
324
361
  if (!target) {
325
362
  const valid = Object.keys(def.targets).join(" | ");
326
- throw new Error(`Valid targets for '${commandName}': ${valid}`);
363
+ throw new Error(t("VALID_TARGETS__COMMAND_VALID", { command: commandName, valid }));
327
364
  }
328
365
  return {
329
366
  ...target,
@@ -336,13 +373,74 @@ function getCommandDefinition(source, commandName, subArg) {
336
373
  }
337
374
 
338
375
  function listCommands(source) {
339
- return Object.entries(source.commands || {}).map(([name, def]) => ({
340
- name,
341
- aliases: commandAliases(def),
342
- description: def.description || "",
343
- hasTargets: Boolean(def.targets),
344
- targets: def.targets ? Object.keys(def.targets) : [],
345
- }));
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;
346
444
  }
347
445
 
348
446
  function commandBaseDir(source) {
@@ -352,8 +450,10 @@ function commandBaseDir(source) {
352
450
  module.exports = {
353
451
  PLACEHOLDER_VALUES,
354
452
  PAN_CONFIG_FILE,
453
+ LOCAL_MANIFEST_FILE,
355
454
  cleanValue,
356
455
  parseManifest,
456
+ parseManifestWithLocal,
357
457
  loadManifest,
358
458
  loadPanConfig,
359
459
  manifestRepos,
@@ -361,6 +461,7 @@ module.exports = {
361
461
  commandAliases,
362
462
  resolveCommandName,
363
463
  getCommandDefinition,
464
+ parseSequenceSteps,
364
465
  listCommands,
365
466
  commandBaseDir,
366
467
  };
@@ -0,0 +1,201 @@
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 };