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
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
const readline = require("readline");
|
|
2
2
|
const { isGitRepo } = require("./git");
|
|
3
|
+
const { helpTheme } = require("../../inquirer-theme");
|
|
4
|
+
const { t } = require("../../i18n");
|
|
5
|
+
const { menuSelect, menuCheckbox } = require("../../menu-prompt");
|
|
6
|
+
|
|
7
|
+
// Lazy-load @inquirer/prompts (ESM) from CJS via dynamic import. Cached so
|
|
8
|
+
// repeated pickers in one run reuse the same module. Returns null when the
|
|
9
|
+
// dependency is missing (e.g. deps not installed) so callers can fall back to
|
|
10
|
+
// the readline-based prompts.
|
|
11
|
+
let inquirerModulePromise = null;
|
|
12
|
+
async function loadInquirer() {
|
|
13
|
+
if (inquirerModulePromise === null) {
|
|
14
|
+
inquirerModulePromise = import("@inquirer/prompts").catch(() => null);
|
|
15
|
+
}
|
|
16
|
+
return inquirerModulePromise;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isCancelError(err) {
|
|
20
|
+
if (!err) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const name = String(err.name || "");
|
|
24
|
+
return (
|
|
25
|
+
name === "ExitPromptError" ||
|
|
26
|
+
name === "CancelPromptError" ||
|
|
27
|
+
/User force closed/i.test(String(err.message || ""))
|
|
28
|
+
);
|
|
29
|
+
}
|
|
3
30
|
|
|
4
31
|
function buildTargets({ repos, workspaceRoot, includeWorkspace }) {
|
|
5
32
|
const targets = [];
|
|
@@ -28,7 +55,37 @@ function findTargetByName(targets, name) {
|
|
|
28
55
|
return targets.filter((target) => target.name === needle);
|
|
29
56
|
}
|
|
30
57
|
|
|
31
|
-
function
|
|
58
|
+
function splitSelectionTokens(text) {
|
|
59
|
+
return String(text || "")
|
|
60
|
+
.split(/[\s,]+/)
|
|
61
|
+
.map((tok) => tok.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findTargetsByNames(targets, names) {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const picked = [];
|
|
68
|
+
const missing = [];
|
|
69
|
+
for (const raw of names) {
|
|
70
|
+
const name = String(raw || "").trim();
|
|
71
|
+
if (!name) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const match = targets.find((target) => target.name === name);
|
|
75
|
+
if (!match) {
|
|
76
|
+
missing.push(name);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (seen.has(match.name)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
seen.add(match.name);
|
|
83
|
+
picked.push(match);
|
|
84
|
+
}
|
|
85
|
+
return { picked, missing };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function promptRepoChoiceReadline(targets, message) {
|
|
32
89
|
return new Promise((resolve, reject) => {
|
|
33
90
|
const rl = readline.createInterface({
|
|
34
91
|
input: process.stdin,
|
|
@@ -36,19 +93,23 @@ function promptRepoChoice(targets) {
|
|
|
36
93
|
});
|
|
37
94
|
|
|
38
95
|
console.log("");
|
|
39
|
-
console.log(
|
|
40
|
-
console.log(` 1.
|
|
96
|
+
console.log(message);
|
|
97
|
+
console.log(` 1. ${t("REPO_SELECT_ALL__N", { n: targets.length })}`);
|
|
41
98
|
targets.forEach((target, index) => {
|
|
42
99
|
console.log(` ${index + 2}. ${target.label}`);
|
|
43
100
|
console.log(` ${target.destination}`);
|
|
44
101
|
});
|
|
45
102
|
console.log("");
|
|
46
103
|
|
|
47
|
-
rl.question("
|
|
104
|
+
rl.question(t("REPO_SELECT_SINGLE_PROMPT"), (answer) => {
|
|
48
105
|
rl.close();
|
|
49
106
|
const choice = Number.parseInt(String(answer).trim(), 10);
|
|
50
|
-
if (
|
|
51
|
-
|
|
107
|
+
if (
|
|
108
|
+
!Number.isFinite(choice) ||
|
|
109
|
+
choice < 1 ||
|
|
110
|
+
choice > targets.length + 1
|
|
111
|
+
) {
|
|
112
|
+
reject(new Error(t("REPO_SELECT_INVALID")));
|
|
52
113
|
return;
|
|
53
114
|
}
|
|
54
115
|
if (choice === 1) {
|
|
@@ -60,15 +121,76 @@ function promptRepoChoice(targets) {
|
|
|
60
121
|
});
|
|
61
122
|
}
|
|
62
123
|
|
|
124
|
+
const ALL_SENTINEL = "__all__";
|
|
125
|
+
|
|
126
|
+
async function promptRepoChoice(targets, { message } = {}) {
|
|
127
|
+
const promptMsg = message || t("REPO_SELECT_PROMPT_MSG");
|
|
128
|
+
const choices = [
|
|
129
|
+
{
|
|
130
|
+
name: t("REPO_SELECT_ALL__N", { n: targets.length }),
|
|
131
|
+
value: ALL_SENTINEL,
|
|
132
|
+
short: "all",
|
|
133
|
+
},
|
|
134
|
+
...targets.map((target) => ({
|
|
135
|
+
name: `${target.label} ${dimHint(target.destination)}`,
|
|
136
|
+
value: target,
|
|
137
|
+
short: target.name,
|
|
138
|
+
})),
|
|
139
|
+
];
|
|
140
|
+
const hotkeys = [{ key: "x", label: t("EXIT_LABEL"), value: "__exit__" }];
|
|
141
|
+
|
|
142
|
+
// Ưu tiên menu-prompt (phím x thoát ngay); thiếu @inquirer/core → @inquirer/prompts select
|
|
143
|
+
// với item thoát ẩn ở cuối; thiếu cả hai → readline thô (không hotkey, Ctrl+C để thoát).
|
|
144
|
+
try {
|
|
145
|
+
const picked = await menuSelect({ message: promptMsg, choices, pageSize: Math.min(Math.max(targets.length + 2, 8), 20), hotkeys });
|
|
146
|
+
if (picked === "__exit__") {
|
|
147
|
+
console.log(t("EXITED"));
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
return picked === ALL_SENTINEL ? targets.slice() : [picked];
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (String(err && err.message) !== "no-core") {
|
|
153
|
+
if (isCancelError(err)) throw new Error(t("REPO_SELECT_CANCELLED"));
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const inq = await loadInquirer();
|
|
159
|
+
if (!inq || typeof inq.select !== "function") {
|
|
160
|
+
return promptRepoChoiceReadline(targets, promptMsg);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const picked = await inq.select({
|
|
165
|
+
message: promptMsg,
|
|
166
|
+
choices: [...choices, new inq.Separator(), { name: `x ${t("EXIT_LABEL")}`, value: "__exit__", short: "x" }],
|
|
167
|
+
pageSize: Math.min(Math.max(targets.length + 2, 8), 20),
|
|
168
|
+
loop: false,
|
|
169
|
+
theme: helpTheme(false),
|
|
170
|
+
});
|
|
171
|
+
if (picked === "__exit__") {
|
|
172
|
+
console.log(t("EXITED"));
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
return picked === ALL_SENTINEL ? targets.slice() : [picked];
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (isCancelError(err)) {
|
|
178
|
+
throw new Error(t("REPO_SELECT_CANCELLED"));
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
63
184
|
async function resolveTargets({
|
|
64
185
|
repos,
|
|
65
186
|
workspaceRoot,
|
|
66
187
|
includeWorkspace,
|
|
67
188
|
selection,
|
|
189
|
+
message,
|
|
68
190
|
}) {
|
|
69
191
|
const targets = buildTargets({ repos, workspaceRoot, includeWorkspace });
|
|
70
192
|
if (targets.length === 0) {
|
|
71
|
-
throw new Error("
|
|
193
|
+
throw new Error(t("REPO_SELECT_NO_REPO_SYNC"));
|
|
72
194
|
}
|
|
73
195
|
|
|
74
196
|
const picked = String(selection || "").trim();
|
|
@@ -79,19 +201,216 @@ async function resolveTargets({
|
|
|
79
201
|
const matches = findTargetByName(targets, picked);
|
|
80
202
|
if (matches.length === 0) {
|
|
81
203
|
const names = targets.map((target) => target.name).join(", ");
|
|
82
|
-
throw new Error(
|
|
204
|
+
throw new Error(
|
|
205
|
+
t("REPO_SELECT_ONE_MISSING__PICKED_ALL", { picked, all: names })
|
|
206
|
+
);
|
|
83
207
|
}
|
|
84
208
|
return matches;
|
|
85
209
|
}
|
|
86
210
|
|
|
87
211
|
if (process.stdin.isTTY) {
|
|
88
|
-
return promptRepoChoice(targets);
|
|
212
|
+
return promptRepoChoice(targets, { message: message || t("REPO_SELECT_SYNCMAIN_MSG") });
|
|
89
213
|
}
|
|
90
214
|
|
|
91
215
|
return targets;
|
|
92
216
|
}
|
|
93
217
|
|
|
218
|
+
function pickTargetsByIndices(targets, indices) {
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
const picked = [];
|
|
221
|
+
for (const raw of indices) {
|
|
222
|
+
const num = Number.parseInt(String(raw).trim(), 10);
|
|
223
|
+
if (!Number.isFinite(num) || num < 1 || num > targets.length + 1) {
|
|
224
|
+
const err = new Error(t("REPO_SELECT_INVALID_INDEX__RAW", { raw }));
|
|
225
|
+
err.code = "INVALID_CHOICE";
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
if (num === 1) {
|
|
229
|
+
return targets.slice();
|
|
230
|
+
}
|
|
231
|
+
const idx = num - 2;
|
|
232
|
+
if (seen.has(idx)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
seen.add(idx);
|
|
236
|
+
picked.push(targets[idx]);
|
|
237
|
+
}
|
|
238
|
+
return picked;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function promptRepoChoiceMultiReadline(targets, { action } = {}) {
|
|
242
|
+
const actionLabel = action || t("REPO_SELECT_ACTION_CHOOSE");
|
|
243
|
+
return new Promise((resolve, reject) => {
|
|
244
|
+
const rl = readline.createInterface({
|
|
245
|
+
input: process.stdin,
|
|
246
|
+
output: process.stdout,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
console.log("");
|
|
250
|
+
console.log(
|
|
251
|
+
t("REPO_SELECT_MULTI_HELP__ACTION", { action: actionLabel })
|
|
252
|
+
);
|
|
253
|
+
console.log(` 1. ${t("REPO_SELECT_ALL__N", { n: targets.length })}`);
|
|
254
|
+
targets.forEach((target, index) => {
|
|
255
|
+
console.log(` ${index + 2}. ${target.label}`);
|
|
256
|
+
console.log(` ${target.destination}`);
|
|
257
|
+
});
|
|
258
|
+
console.log("");
|
|
259
|
+
|
|
260
|
+
rl.question(t("REPO_SELECT_MULTI_PROMPT"), (answer) => {
|
|
261
|
+
rl.close();
|
|
262
|
+
const raw = String(answer || "").trim();
|
|
263
|
+
if (!raw) {
|
|
264
|
+
reject(new Error(t("REPO_SELECT_EMPTY_ANSWER")));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (raw.toLowerCase() === "all") {
|
|
268
|
+
resolve(targets.slice());
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const tokens = splitSelectionTokens(raw);
|
|
272
|
+
if (tokens.length === 0) {
|
|
273
|
+
reject(new Error(t("REPO_SELECT_INVALID")));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const picked = pickTargetsByIndices(targets, tokens);
|
|
278
|
+
if (picked.length === 0) {
|
|
279
|
+
reject(new Error(t("REPO_SELECT_NONE_PICKED")));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
resolve(picked);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
reject(err);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function promptRepoChoiceMulti(targets, { action } = {}) {
|
|
291
|
+
const actionLabel = action || t("REPO_SELECT_ACTION_CHOOSE");
|
|
292
|
+
const choices = targets.map((target, i) => ({
|
|
293
|
+
name: `${target.label} ${dimHint(target.destination)}`,
|
|
294
|
+
value: i,
|
|
295
|
+
short: target.name,
|
|
296
|
+
}));
|
|
297
|
+
const hotkeys = [{ key: "x", label: t("EXIT_LABEL"), value: "exit" }];
|
|
298
|
+
|
|
299
|
+
// Ưu tiên menu-prompt (menuCheckbox: có sẵn a=chọn tất cả, i=đảo chọn, x=thoát) — nhất quán
|
|
300
|
+
// với mọi nơi chọn-nhiều khác trong CLI; thiếu @inquirer/core → @inquirer/prompts checkbox
|
|
301
|
+
// (không có a/i, chỉ Enter/Space); thiếu cả hai → readline thô.
|
|
302
|
+
try {
|
|
303
|
+
const res = await menuCheckbox({
|
|
304
|
+
message: actionLabel,
|
|
305
|
+
choices,
|
|
306
|
+
hotkeys,
|
|
307
|
+
pageSize: Math.min(Math.max(targets.length + 2, 8), 20),
|
|
308
|
+
});
|
|
309
|
+
if (res.action === "exit") {
|
|
310
|
+
console.log(t("EXITED"));
|
|
311
|
+
process.exit(0);
|
|
312
|
+
}
|
|
313
|
+
const values = res.values || [];
|
|
314
|
+
if (values.length === 0) {
|
|
315
|
+
throw new Error(t("REPO_SELECT_NONE_PICKED"));
|
|
316
|
+
}
|
|
317
|
+
return values.map((i) => targets[i]);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (String(err && err.message) !== "no-core") {
|
|
320
|
+
if (isCancelError(err)) throw new Error(t("REPO_SELECT_CANCELLED"));
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const inq = await loadInquirer();
|
|
326
|
+
if (!inq || typeof inq.checkbox !== "function") {
|
|
327
|
+
return promptRepoChoiceMultiReadline(targets, { action: actionLabel });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const picked = await inq.checkbox({
|
|
332
|
+
message: actionLabel,
|
|
333
|
+
choices: targets.map((target) => ({
|
|
334
|
+
name: `${target.label} ${dimHint(target.destination)}`,
|
|
335
|
+
value: target,
|
|
336
|
+
short: target.name,
|
|
337
|
+
})),
|
|
338
|
+
required: true,
|
|
339
|
+
pageSize: Math.min(Math.max(targets.length + 2, 8), 20),
|
|
340
|
+
theme: helpTheme(true),
|
|
341
|
+
});
|
|
342
|
+
if (!Array.isArray(picked) || picked.length === 0) {
|
|
343
|
+
throw new Error(t("REPO_SELECT_NONE_PICKED"));
|
|
344
|
+
}
|
|
345
|
+
return picked;
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (isCancelError(err)) {
|
|
348
|
+
throw new Error(t("REPO_SELECT_CANCELLED"));
|
|
349
|
+
}
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function dimHint(text) {
|
|
355
|
+
if (!process.stdout.isTTY || process.env.NO_COLOR) {
|
|
356
|
+
return `— ${text}`;
|
|
357
|
+
}
|
|
358
|
+
return `\u001b[2m— ${text}\u001b[0m`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function resolveMultiTargets({
|
|
362
|
+
repos,
|
|
363
|
+
workspaceRoot,
|
|
364
|
+
includeWorkspace,
|
|
365
|
+
selection,
|
|
366
|
+
action,
|
|
367
|
+
}) {
|
|
368
|
+
const actionLabel = action || t("REPO_SELECT_ACTION_DEFAULT");
|
|
369
|
+
const targets = buildTargets({ repos, workspaceRoot, includeWorkspace });
|
|
370
|
+
if (targets.length === 0) {
|
|
371
|
+
throw new Error(t("REPO_SELECT_NO_REPO"));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const raw = String(selection || "").trim();
|
|
375
|
+
if (raw.toLowerCase() === "all") {
|
|
376
|
+
return targets.slice();
|
|
377
|
+
}
|
|
378
|
+
if (raw) {
|
|
379
|
+
const tokens = splitSelectionTokens(raw);
|
|
380
|
+
const allNumeric =
|
|
381
|
+
tokens.length > 0 && tokens.every((tok) => /^[0-9]+$/.test(tok));
|
|
382
|
+
if (allNumeric) {
|
|
383
|
+
const picked = pickTargetsByIndices(targets, tokens);
|
|
384
|
+
if (picked.length === 0) {
|
|
385
|
+
throw new Error(t("REPO_SELECT_NONE_PICKED"));
|
|
386
|
+
}
|
|
387
|
+
return picked;
|
|
388
|
+
}
|
|
389
|
+
const { picked, missing } = findTargetsByNames(targets, tokens);
|
|
390
|
+
if (missing.length > 0) {
|
|
391
|
+
const names = targets.map((target) => target.name).join(", ");
|
|
392
|
+
throw new Error(
|
|
393
|
+
t("REPO_SELECT_MISSING__MISSING_ALL", { missing: missing.join(", "), all: names })
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (picked.length === 0) {
|
|
397
|
+
throw new Error(t("REPO_SELECT_NONE_PICKED"));
|
|
398
|
+
}
|
|
399
|
+
return picked;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (process.stdin.isTTY) {
|
|
403
|
+
return promptRepoChoiceMulti(targets, { action: actionLabel });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
throw new Error(t("REPO_SELECT_NEED_NON_INTERACTIVE"));
|
|
407
|
+
}
|
|
408
|
+
|
|
94
409
|
module.exports = {
|
|
95
410
|
buildTargets,
|
|
411
|
+
findTargetsByNames,
|
|
412
|
+
pickTargetsByIndices,
|
|
96
413
|
resolveTargets,
|
|
414
|
+
resolveMultiTargets,
|
|
415
|
+
splitSelectionTokens,
|
|
97
416
|
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const colors = require("./colors");
|
|
2
|
+
const { getCurrentBranch, runGitCapture, isMergeOrRebaseInProgress } = require("./git");
|
|
3
|
+
const { t } = require("../../i18n");
|
|
4
|
+
|
|
5
|
+
const ICON = {
|
|
6
|
+
ok: "✅",
|
|
7
|
+
warn: "⚠️",
|
|
8
|
+
info: "ℹ️",
|
|
9
|
+
skip: "⏭️",
|
|
10
|
+
up: "↑",
|
|
11
|
+
down: "↓",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function computeAheadBehind(directory) {
|
|
15
|
+
const up = runGitCapture(["rev-parse", "--abbrev-ref", "@{u}"], { cwd: directory });
|
|
16
|
+
if (up.status !== 0 || !up.stdout) return { hasUpstream: false, ahead: 0, behind: 0 };
|
|
17
|
+
const rl = runGitCapture(["rev-list", "--left-right", "--count", "HEAD...@{u}"], { cwd: directory });
|
|
18
|
+
if (rl.status !== 0) return { hasUpstream: true, ahead: 0, behind: 0 };
|
|
19
|
+
const [ahead, behind] = rl.stdout.split(/\s+/).map((n) => parseInt(n, 10) || 0);
|
|
20
|
+
return { hasUpstream: true, ahead, behind };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getRepoStatusSummary(directory) {
|
|
24
|
+
const branch = getCurrentBranch(directory);
|
|
25
|
+
const result = runGitCapture(["status", "--porcelain"], { cwd: directory });
|
|
26
|
+
if (result.status !== 0) {
|
|
27
|
+
return {
|
|
28
|
+
branch,
|
|
29
|
+
error: result.stderr || "git status failed",
|
|
30
|
+
total: 0,
|
|
31
|
+
staged: 0,
|
|
32
|
+
unstaged: 0,
|
|
33
|
+
untracked: 0,
|
|
34
|
+
ahead: 0,
|
|
35
|
+
behind: 0,
|
|
36
|
+
hasUpstream: false,
|
|
37
|
+
inProgress: "",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lines = result.stdout
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map((line) => line.trimEnd())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
|
|
46
|
+
let staged = 0;
|
|
47
|
+
let unstaged = 0;
|
|
48
|
+
let untracked = 0;
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const index = line[0];
|
|
52
|
+
const worktree = line[1];
|
|
53
|
+
|
|
54
|
+
if (index === "?" && worktree === "?") {
|
|
55
|
+
untracked += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (index !== " ") {
|
|
60
|
+
staged += 1;
|
|
61
|
+
}
|
|
62
|
+
if (worktree !== " ") {
|
|
63
|
+
unstaged += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { hasUpstream, ahead, behind } = computeAheadBehind(directory);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
branch,
|
|
71
|
+
total: lines.length,
|
|
72
|
+
staged,
|
|
73
|
+
unstaged,
|
|
74
|
+
untracked,
|
|
75
|
+
ahead,
|
|
76
|
+
behind,
|
|
77
|
+
hasUpstream,
|
|
78
|
+
inProgress: isMergeOrRebaseInProgress(directory), // "merge"|"rebase"|""
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatFileBreakdown({ staged, unstaged, untracked }) {
|
|
83
|
+
const parts = [];
|
|
84
|
+
if (staged > 0) parts.push(t("BULK_BREAKDOWN_STAGED__N", { n: staged }));
|
|
85
|
+
if (unstaged > 0) parts.push(t("BULK_BREAKDOWN_UNSTAGED__N", { n: unstaged }));
|
|
86
|
+
if (untracked > 0) parts.push(t("BULK_BREAKDOWN_UNTRACKED__N", { n: untracked }));
|
|
87
|
+
return parts.join(" · ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatSync(item) {
|
|
91
|
+
if (item.error) return colors.red(t("BULK_STATUS_ERROR"));
|
|
92
|
+
if (!item.hasUpstream) return colors.dim(t("BULK_STATUS_NO_UPSTREAM"));
|
|
93
|
+
if (item.behind === 0 && item.ahead === 0) return colors.green(t("BULK_STATUS_IN_SYNC"));
|
|
94
|
+
const parts = [];
|
|
95
|
+
if (item.behind) parts.push(colors.yellow(`${ICON.down} ${item.behind}`));
|
|
96
|
+
if (item.ahead) parts.push(colors.cyan(`${ICON.up} ${item.ahead}`));
|
|
97
|
+
return parts.join(" ");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatState(item) {
|
|
101
|
+
if (item.inProgress === "merge") return colors.red(t("BULK_STATUS_MERGING"));
|
|
102
|
+
if (item.inProgress === "rebase") return colors.red(t("BULK_STATUS_REBASING"));
|
|
103
|
+
if (item.error) return colors.red(t("BULK_STATUS_ERROR"));
|
|
104
|
+
if (item.total === 0) return colors.green(t("BULK_STATUS_CLEAN"));
|
|
105
|
+
const label = item.total === 1 ? t("BULK_FILES_ONE") : t("BULK_FILES_MANY");
|
|
106
|
+
return colors.yellow(`${item.total} ${label}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function printChangeSummary({ title, results, totalRepos }) {
|
|
110
|
+
const dirty = results.filter((item) => item.total > 0 || item.inProgress);
|
|
111
|
+
const errors = results.filter((item) => item.error);
|
|
112
|
+
const shown = results.filter((item) => item.total > 0 || item.inProgress || item.error);
|
|
113
|
+
const rows = shown.map((item) => ({
|
|
114
|
+
repo: item.error ? colors.red(item.name) : colors.cyan(item.name),
|
|
115
|
+
branch: item.branch || colors.dim(t("GST_DETACHED")),
|
|
116
|
+
state: formatState(item),
|
|
117
|
+
breakdown: item.total > 0 ? colors.dim(formatFileBreakdown(item)) : colors.dim("—"),
|
|
118
|
+
sync: formatSync(item),
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
const totalFiles = dirty.reduce((sum, item) => sum + item.total, 0);
|
|
122
|
+
const cleanCount = totalRepos - dirty.length - errors.length;
|
|
123
|
+
|
|
124
|
+
console.log("");
|
|
125
|
+
console.log(colors.bold(`─── ${t("GST_TITLE")} ───`));
|
|
126
|
+
console.log(colors.dim(`${ICON.info} ${t("GST_FOOTER_COUNTS__TOTAL_DIRTY_FILES_CLEAN_ERRORS", {
|
|
127
|
+
total: totalRepos, dirty: dirty.length, files: totalFiles, clean: cleanCount, errors: errors.length,
|
|
128
|
+
})}`));
|
|
129
|
+
|
|
130
|
+
if (rows.length > 0) {
|
|
131
|
+
const { renderTable, bulkColumns } = require("./bulk-report");
|
|
132
|
+
console.log(renderTable(
|
|
133
|
+
bulkColumns([
|
|
134
|
+
{ key: "repo", labelKey: "BULK_COL_REPO" },
|
|
135
|
+
{ key: "branch", labelKey: "BULK_COL_BRANCH" },
|
|
136
|
+
{ key: "state", labelKey: "BULK_COL_STATE" },
|
|
137
|
+
{ key: "breakdown", labelKey: "BULK_COL_BREAKDOWN" },
|
|
138
|
+
{ key: "sync", labelKey: "BULK_COL_SYNC" },
|
|
139
|
+
]),
|
|
140
|
+
rows
|
|
141
|
+
));
|
|
142
|
+
} else {
|
|
143
|
+
console.log("");
|
|
144
|
+
console.log(colors.green(t("GST_ALL_CLEAN__N", { n: totalRepos })));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (errors.length > 0) {
|
|
148
|
+
console.log("");
|
|
149
|
+
console.log(colors.bold(colors.yellow(`${ICON.warn} ${t("GST_ERRORS_HEADER")}`)));
|
|
150
|
+
for (const item of errors) {
|
|
151
|
+
console.log(` • ${item.name}: ${item.error}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
ICON,
|
|
158
|
+
getRepoStatusSummary,
|
|
159
|
+
formatFileBreakdown,
|
|
160
|
+
printChangeSummary,
|
|
161
|
+
};
|
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 };
|