pan-stack-cli 0.1.5 → 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.
- package/README.md +572 -24
- package/bin/pan.js +204 -21
- package/lib/ai-skills.js +829 -0
- package/lib/create.js +22 -4
- package/lib/discover.js +59 -1
- package/lib/git/git-checkout.js +395 -0
- package/lib/git/git-clone.js +162 -27
- package/lib/git/git-fetch-all.js +100 -33
- package/lib/git/git-grv.js +68 -37
- package/lib/git/git-gst.js +97 -26
- package/lib/git/git-mr.js +546 -0
- package/lib/git/git-pull.js +104 -43
- package/lib/git/git-push.js +150 -41
- package/lib/git/git-sync-main.js +42 -28
- package/lib/git/helpers/bulk-report.js +202 -0
- package/lib/git/helpers/cli.js +40 -5
- package/lib/git/helpers/manifest.js +93 -0
- package/lib/git/helpers/repo-select.js +328 -9
- package/lib/git/helpers/repo-summary.js +161 -0
- package/lib/i18n.js +61 -0
- package/lib/inquirer-theme.js +20 -0
- package/lib/language.js +121 -0
- package/lib/languages.js +14 -0
- package/lib/manifest.js +115 -14
- package/lib/menu-prompt.js +201 -0
- package/lib/menu.js +134 -0
- package/lib/messages.js +647 -0
- package/lib/run-command.js +173 -52
- package/lib/select.js +0 -0
- package/lib/serve.js +75 -0
- package/lib/skill-registry-client.js +173 -0
- package/lib/stats/aggregate.js +51 -0
- package/lib/stats/identity.js +20 -0
- package/lib/stats/index.js +189 -0
- package/lib/stats/parse.js +46 -0
- package/lib/stats/range.js +38 -0
- package/lib/stats/report.js +264 -0
- package/lib/stats/review.js +157 -0
- package/lib/stats/scan.js +92 -0
- package/lib/stats/serve.js +25 -0
- package/lib/ui/api.js +202 -0
- package/lib/ui/clone.js +115 -0
- package/lib/ui/config.js +182 -0
- package/lib/ui/diff.js +128 -0
- package/lib/ui/export-gate.js +79 -0
- package/lib/ui/gen-react-mobile-code.js +6 -0
- package/lib/ui/gen-react-web-code.js +75 -0
- package/lib/ui/index.js +89 -0
- package/lib/ui/init.js +252 -0
- package/lib/ui/login.js +128 -0
- package/lib/ui/logout.js +22 -0
- package/lib/ui/palettes.js +103 -0
- package/lib/ui/published-html.js +24 -0
- package/lib/ui/sync.js +137 -0
- package/lib/ui/workspace.js +213 -0
- package/package.json +10 -3
- package/templates/workspace/.claude/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.codex/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.cursor/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.pan.yaml +0 -25
- package/templates/workspace/.project.yaml +0 -15
- package/templates/workspace/README.md +0 -35
package/lib/language.js
ADDED
|
@@ -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 };
|
package/lib/languages.js
ADDED
|
@@ -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("
|
|
225
|
+
throw new Error(t("REPO_MISSING_NAME"));
|
|
217
226
|
}
|
|
218
227
|
if (PLACEHOLDER_VALUES.has(url)) {
|
|
219
|
-
throw new Error(
|
|
228
|
+
throw new Error(t("REPO_MISSING_URL__NAME", { name }));
|
|
220
229
|
}
|
|
221
230
|
if (!repoPath) {
|
|
222
|
-
throw new Error(
|
|
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(
|
|
267
|
+
throw new Error(t("MANIFEST_NOT_FOUND__PATH", { path: manifestPath }));
|
|
231
268
|
}
|
|
232
|
-
const data =
|
|
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(
|
|
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(
|
|
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 || {})
|
|
340
|
-
name,
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 };
|
package/lib/menu.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Menu tương tác khi gõ `pan` trống (TTY): chọn DỰ ÁN → chọn LỆNH → chạy — khỏi nhớ lệnh.
|
|
2
|
+
// Ưu tiên custom prompt (menu-prompt) có PHÍM TẮT nhấn-1-phím (h/v/b/x); thiếu @inquirer/core →
|
|
3
|
+
// fallback @inquirer/prompts select (phím tắt thành item chọn). Chuỗi theo ngôn ngữ (i18n, mặc định en).
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { loadAllCommandSources } = require("./discover");
|
|
6
|
+
const { listCommands } = require("./manifest");
|
|
7
|
+
const { runProjectCommand, sourceLabel } = require("./run-command");
|
|
8
|
+
const { loadInquirer } = require("./select");
|
|
9
|
+
const { menuSelect, buildMenuPrompt } = require("./menu-prompt");
|
|
10
|
+
const { helpTheme } = require("./inquirer-theme");
|
|
11
|
+
const { t } = require("./i18n");
|
|
12
|
+
|
|
13
|
+
function isCancel(err) {
|
|
14
|
+
const s = String((err && (err.name + " " + err.message)) || "");
|
|
15
|
+
return /ExitPromptError|force closed|SIGINT|cancel/i.test(s);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Chọn 1 item; hotkeys hiện ở footer (custom prompt) hoặc thành item cuối (fallback select).
|
|
19
|
+
async function pick({ message, choices, hotkeys = [] }) {
|
|
20
|
+
try {
|
|
21
|
+
return await menuSelect({ message, choices, hotkeys });
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (String(err && err.message) !== "no-core") throw err;
|
|
24
|
+
const inq = await loadInquirer();
|
|
25
|
+
if (!inq || typeof inq.select !== "function") return "__exit__";
|
|
26
|
+
const extra = hotkeys.map((h) => ({ name: `${h.key} ${h.label}`, value: h.value }));
|
|
27
|
+
const list = extra.length ? [...choices, new inq.Separator(), ...extra] : choices;
|
|
28
|
+
return inq.select({ message, choices: list, pageSize: 20, loop: false, theme: helpTheme(false) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function runInteractiveMenu({ printUsage, version } = {}) {
|
|
33
|
+
// Có ít nhất 1 backend picker? (core hoặc prompts). Không → trả false để caller in usage.
|
|
34
|
+
const core = await buildMenuPrompt();
|
|
35
|
+
const inqMod = await loadInquirer();
|
|
36
|
+
if (!core && (!inqMod || typeof inqMod.select !== "function")) return false;
|
|
37
|
+
|
|
38
|
+
// Sort A-Z theo tên hiển thị — dễ dò khi .project.yaml có nhiều repo/project.
|
|
39
|
+
const sources = loadAllCommandSources()
|
|
40
|
+
.filter((s) => listCommands(s).length > 0)
|
|
41
|
+
.sort((a, b) => sourceLabel(a).localeCompare(sourceLabel(b)));
|
|
42
|
+
if (sources.length === 0) {
|
|
43
|
+
// Không có .pan.yaml (project commands) — KHÔNG coi là lỗi/dead-end: các lệnh git
|
|
44
|
+
// built-in (pull/push/fetch/checkout/...) vẫn dùng được nhờ fallback quét git repo
|
|
45
|
+
// con quanh cwd (xem lib/git/helpers/cli.js). In gợi ý rồi để caller in usage đầy đủ.
|
|
46
|
+
console.log(t("NO_PAN_YAML_WITH_COMMANDS"));
|
|
47
|
+
console.log(t("NO_PAN_YAML_BUT_GIT_STILL_WORKS"));
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
for (;;) {
|
|
53
|
+
// ── Tầng 1: dự án ──
|
|
54
|
+
const proj = await pick({
|
|
55
|
+
message: t("MENU_SELECT_PROJECT"),
|
|
56
|
+
choices: sources.map((s, i) => ({
|
|
57
|
+
name: sourceLabel(s),
|
|
58
|
+
value: i,
|
|
59
|
+
description: `${listCommands(s).length} ${t("COMMANDS_LABEL")} · ${s.root}`,
|
|
60
|
+
})),
|
|
61
|
+
hotkeys: [
|
|
62
|
+
{ key: "h", label: t("HELP_LABEL"), value: "__help__" },
|
|
63
|
+
{ key: "v", label: t("VERSION_LABEL"), value: "__version__" },
|
|
64
|
+
{ key: "x", label: t("EXIT_LABEL"), value: "__exit__" },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
if (proj === "__exit__") return true;
|
|
68
|
+
if (proj === "__version__") {
|
|
69
|
+
console.log(`pan-stack-cli ${version || "?"}`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (proj === "__help__") {
|
|
73
|
+
if (typeof printUsage === "function") printUsage();
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const source = sources[proj];
|
|
77
|
+
|
|
78
|
+
// ── Tầng 2: lệnh ──
|
|
79
|
+
const cmds = listCommands(source);
|
|
80
|
+
const cmd = await pick({
|
|
81
|
+
message: `[${sourceLabel(source)}] ${t("MENU_SELECT_COMMAND")}`,
|
|
82
|
+
choices: cmds.map((c) => ({
|
|
83
|
+
name:
|
|
84
|
+
c.name +
|
|
85
|
+
(c.aliases && c.aliases.length ? ` (${c.aliases.join(", ")})` : "") +
|
|
86
|
+
(c.hasTargets ? ` [${c.targets.join("|")}]` : ""),
|
|
87
|
+
value: c.name,
|
|
88
|
+
description: c.description || "",
|
|
89
|
+
})),
|
|
90
|
+
hotkeys: [
|
|
91
|
+
{ key: "b", label: t("BACK_LABEL"), value: "__back__" },
|
|
92
|
+
{ key: "x", label: t("EXIT_LABEL"), value: "__exit__" },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
if (cmd === "__exit__") return true;
|
|
96
|
+
if (cmd === "__back__") continue;
|
|
97
|
+
|
|
98
|
+
// ── Tầng 3: target (nếu có) ──
|
|
99
|
+
const def = source.commands[cmd];
|
|
100
|
+
const args = [];
|
|
101
|
+
if (def && def.targets && Object.keys(def.targets).length > 0) {
|
|
102
|
+
const targets = Object.keys(def.targets);
|
|
103
|
+
const tgt = await pick({
|
|
104
|
+
message: t("SELECT_TARGET__CMD", { cmd }),
|
|
105
|
+
choices: [
|
|
106
|
+
...targets.map((x) => ({ name: x, value: x })),
|
|
107
|
+
{ name: t("DEFAULT_LABEL"), value: "__default__" },
|
|
108
|
+
],
|
|
109
|
+
hotkeys: [{ key: "b", label: t("BACK_LABEL"), value: "__back__" }],
|
|
110
|
+
});
|
|
111
|
+
if (tgt === "__back__") continue;
|
|
112
|
+
if (tgt !== "__default__") args.push(tgt);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.error(`\n▶ pan ${cmd}${args.length ? " " + args.join(" ") : ""} (${path.basename(source.root)})\n`);
|
|
116
|
+
// runProjectCommand tự process.exit() khi lệnh THẬT chạy xong — chỉ trả về (không exit) khi
|
|
117
|
+
// user bấm 'b' ở bước chọn (selectFrom/selectChoices) bên trong lệnh đó. Lúc đó `continue`
|
|
118
|
+
// để quay lại Tầng 1 (chọn dự án) thay vì thoát hẳn CLI, đúng quy ước 'b' = quay lại.
|
|
119
|
+
const result = await runProjectCommand(source, cmd, args);
|
|
120
|
+
if (result && result.backed) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (isCancel(err)) {
|
|
127
|
+
console.error("\n" + t("EXITED"));
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { runInteractiveMenu };
|