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/run-command.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const { spawn, spawnSync } = require("child_process");
|
|
4
|
-
const readline = require("readline");
|
|
5
4
|
const {
|
|
6
5
|
getCommandDefinition,
|
|
7
6
|
commandBaseDir,
|
|
8
7
|
resolveCommandName,
|
|
8
|
+
parseSequenceSteps,
|
|
9
9
|
} = require("./manifest");
|
|
10
10
|
const { sourceMatchesProject } = require("./discover");
|
|
11
11
|
const { runShellScript, runExecLine, spawnOptions } = require("./platform");
|
|
12
|
+
const { hasSelect, resolveChoices, runSelect, SELECT_BACK } = require("./select");
|
|
13
|
+
const { t } = require("./i18n");
|
|
12
14
|
|
|
13
15
|
function needDir(dir) {
|
|
14
16
|
if (!fs.existsSync(dir)) {
|
|
15
|
-
throw new Error(
|
|
17
|
+
throw new Error(t("DIRECTORY_NOT_FOUND__DIR", { dir }));
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -27,7 +29,7 @@ function resolveCwd(source, cwd) {
|
|
|
27
29
|
return path.resolve(base, cwd);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function buildPanEnv(source, forwardedArgs) {
|
|
32
|
+
function buildPanEnv(source, forwardedArgs, selected, extraEnv) {
|
|
31
33
|
const panEnv = {
|
|
32
34
|
...process.env,
|
|
33
35
|
PAN_REPO_ROOT: source.root,
|
|
@@ -38,49 +40,149 @@ function buildPanEnv(source, forwardedArgs) {
|
|
|
38
40
|
forwardedArgs.forEach((value, index) => {
|
|
39
41
|
panEnv[`PAN_ARG_${index + 1}`] = value;
|
|
40
42
|
});
|
|
43
|
+
// Kết quả picker (field select trong .pan.yaml) → script đọc qua $PAN_SELECTED (mỗi dòng 1 giá trị).
|
|
44
|
+
if (Array.isArray(selected)) {
|
|
45
|
+
panEnv.PAN_SELECTED = selected.join("\n");
|
|
46
|
+
panEnv.PAN_SELECTED_COUNT = String(selected.length);
|
|
47
|
+
}
|
|
48
|
+
// Biến riêng cho 1 bước trong `sequence` (vd "MODE=prod build-be") — đè lên env chung.
|
|
49
|
+
if (extraEnv && Object.keys(extraEnv).length) {
|
|
50
|
+
Object.assign(panEnv, extraEnv);
|
|
51
|
+
}
|
|
41
52
|
return panEnv;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
// Core chạy `exec:` — SYNC, KHÔNG process.exit (để sequence gọi lặp được). Trả {status}.
|
|
56
|
+
function runExecCore(source, def, forwardedArgs, selected, extraEnv) {
|
|
45
57
|
const cwd = resolveCwd(source, def.cwd);
|
|
46
58
|
needDir(cwd);
|
|
47
59
|
|
|
48
60
|
const execLine = [def.exec, ...forwardedArgs].filter(Boolean).join(" ").trim();
|
|
49
61
|
if (!execLine) {
|
|
50
|
-
throw new Error("
|
|
62
|
+
throw new Error(t("COMMAND_MISSING_EXEC_OR_SHELL"));
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
|
|
65
|
+
return runExecLine(execLine, {
|
|
54
66
|
cwd,
|
|
55
67
|
stdio: "inherit",
|
|
56
|
-
env: buildPanEnv(source, forwardedArgs),
|
|
68
|
+
env: buildPanEnv(source, forwardedArgs, selected, extraEnv),
|
|
57
69
|
});
|
|
58
|
-
|
|
59
|
-
process.exit(result.status ?? 1);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
// Core chạy `shell:` — SYNC, KHÔNG process.exit (để sequence gọi lặp được). Trả {status}.
|
|
73
|
+
function runShellCore(source, def, forwardedArgs, selected, extraEnv) {
|
|
63
74
|
const cwd = resolveCwd(source, def.cwd || ".");
|
|
64
75
|
if (def.cwd) {
|
|
65
76
|
needDir(cwd);
|
|
66
77
|
}
|
|
78
|
+
return runShellScript(def.shell, {
|
|
79
|
+
cwd,
|
|
80
|
+
stdio: "inherit",
|
|
81
|
+
env: buildPanEnv(source, forwardedArgs, selected, extraEnv),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function runExec(source, def, forwardedArgs, selected) {
|
|
86
|
+
const result = runExecCore(source, def, forwardedArgs, selected);
|
|
87
|
+
process.exit(result.status ?? 1);
|
|
88
|
+
}
|
|
67
89
|
|
|
90
|
+
function runShell(source, def, forwardedArgs, selected) {
|
|
68
91
|
let result;
|
|
69
92
|
try {
|
|
70
|
-
result =
|
|
71
|
-
cwd,
|
|
72
|
-
stdio: "inherit",
|
|
73
|
-
env: buildPanEnv(source, forwardedArgs),
|
|
74
|
-
});
|
|
93
|
+
result = runShellCore(source, def, forwardedArgs, selected);
|
|
75
94
|
} catch (error) {
|
|
76
95
|
console.error(error.message);
|
|
77
96
|
process.exit(1);
|
|
78
97
|
}
|
|
79
|
-
|
|
80
98
|
process.exit(result.status ?? 1);
|
|
81
99
|
}
|
|
82
100
|
|
|
83
|
-
|
|
101
|
+
// Chạy 1 command def (đã resolve target nếu có) mà KHÔNG process.exit — dùng trong sequence.
|
|
102
|
+
// Trả {status} (0 = ok). Ném lỗi nếu command thiếu cả exec/shell.
|
|
103
|
+
async function runOneStepDef(source, commandName, resolved, extraEnv) {
|
|
104
|
+
let selected = await resolveSelection(source, resolved, []);
|
|
105
|
+
// Bước trong sequence không có "menu cha" để quay lại — 'b' ở đây coi như bước này không chọn gì.
|
|
106
|
+
if (selected === SELECT_BACK) selected = [];
|
|
107
|
+
if (selected && selected.length === 0) {
|
|
108
|
+
console.error(t("NOTHING_SELECTED_EXITING"));
|
|
109
|
+
return { status: 0, skipped: true };
|
|
110
|
+
}
|
|
111
|
+
if (resolved.shell) {
|
|
112
|
+
return runShellCore(source, resolved, [], selected, extraEnv);
|
|
113
|
+
}
|
|
114
|
+
if (resolved.exec) {
|
|
115
|
+
return runExecCore(source, resolved, [], selected, extraEnv);
|
|
116
|
+
}
|
|
117
|
+
throw new Error(
|
|
118
|
+
t("COMMAND_MISSING_EXEC_OR_SHELL_IN_SOURCE__COMMAND_PATH", { command: commandName, path: source.path })
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// `sequence:` — chạy nhiều command ĐÃ khai báo trong CÙNG file .pan.yaml, TUẦN TỰ theo thứ tự,
|
|
123
|
+
// dừng ngay khi 1 bước lỗi (trừ bước có "?" cuối tên lệnh → cảnh báo rồi chạy tiếp).
|
|
124
|
+
async function runSequence(source, def, commandName) {
|
|
125
|
+
const steps = parseSequenceSteps(def.sequence);
|
|
126
|
+
if (steps.length === 0) {
|
|
127
|
+
throw new Error(`Command '${commandName}' has an empty sequence.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`▶ ${commandName}: ${steps.length} step(s)`);
|
|
131
|
+
for (let index = 0; index < steps.length; index += 1) {
|
|
132
|
+
const step = steps[index];
|
|
133
|
+
if (!source.commands[step.name]) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`sequence step ${index + 1}/${steps.length} in '${commandName}': command '${step.name}' not found in ${source.path}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const resolved = getCommandDefinition(source, step.name, step.target);
|
|
139
|
+
const label = step.target ? `${step.name}:${step.target}` : step.name;
|
|
140
|
+
const envHint = Object.keys(step.env).length ? ` (${Object.entries(step.env).map(([k, v]) => `${k}=${v}`).join(" ")})` : "";
|
|
141
|
+
console.log(`\n── [${index + 1}/${steps.length}] ${label}${envHint} ──`);
|
|
142
|
+
|
|
143
|
+
let result;
|
|
144
|
+
try {
|
|
145
|
+
result = await runOneStepDef(source, step.name, resolved, step.env);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
result = { status: 1, error };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const status = result.status ?? 1;
|
|
151
|
+
if (status !== 0) {
|
|
152
|
+
if (step.optional) {
|
|
153
|
+
console.warn(`⚠️ [${index + 1}/${steps.length}] ${label} failed (exit ${status}) — optional, continuing.${result.error ? " " + result.error.message : ""}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (result.error) console.error(result.error.message);
|
|
157
|
+
console.error(`✗ sequence '${commandName}' stopped at step ${index + 1}/${steps.length} (${label}), exit ${status}.`);
|
|
158
|
+
return status;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
console.log(`\n✓ sequence '${commandName}' — ${steps.length} step(s) done.`);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Nếu command khai báo select (selectFrom/selectChoices) → render picker, trả mảng value.
|
|
166
|
+
// 'all' arg / PAN_ALL=1 / non-TTY ⇒ chọn hết. Trả null nếu command không có select.
|
|
167
|
+
async function resolveSelection(source, def, forwardedArgs) {
|
|
168
|
+
if (!hasSelect(def)) return null;
|
|
169
|
+
const cwd = resolveCwd(source, def.cwd || ".");
|
|
170
|
+
const env = buildPanEnv(source, forwardedArgs);
|
|
171
|
+
const choices = resolveChoices(def, { cwd, env });
|
|
172
|
+
const forceAll =
|
|
173
|
+
forwardedArgs.includes("all") || process.env.PAN_ALL === "1" || !process.stdin.isTTY;
|
|
174
|
+
return runSelect(
|
|
175
|
+
{
|
|
176
|
+
message: def.selectMessage || "Chọn:",
|
|
177
|
+
multi: def.selectMulti !== "false",
|
|
178
|
+
all: def.selectAll !== "false",
|
|
179
|
+
choices,
|
|
180
|
+
},
|
|
181
|
+
{ forceAll }
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function runProjectCommand(source, commandName, args) {
|
|
84
186
|
let subArg = "";
|
|
85
187
|
let forwardedArgs = [...args];
|
|
86
188
|
|
|
@@ -93,20 +195,41 @@ function runProjectCommand(source, commandName, args) {
|
|
|
93
195
|
|
|
94
196
|
const resolved = getCommandDefinition(source, commandName, subArg);
|
|
95
197
|
if (!resolved) {
|
|
96
|
-
throw new Error(
|
|
198
|
+
throw new Error(t("COMMAND_NOT_FOUND_IN_SOURCE__COMMAND_PATH", { command: commandName, path: source.path }));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (resolved.sequence) {
|
|
202
|
+
const status = await runSequence(source, resolved, commandName);
|
|
203
|
+
process.exit(status);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const selected = await resolveSelection(source, resolved, forwardedArgs);
|
|
208
|
+
// 'b' ở bước chọn (selectFrom/selectChoices) — KHÔNG thoát hẳn tiến trình: trả tín hiệu để
|
|
209
|
+
// caller (menu tương tác `pan` trống) quay lại bước chọn TRƯỚC đó thay vì chạy lệnh/thoát.
|
|
210
|
+
// Lời gọi trực tiếp không qua menu (`pan <command>` / runWithProjectResolution) không có "menu
|
|
211
|
+
// cha" để quay lại nên coi tín hiệu này như huỷ lệnh êm (xem runWithProjectResolution bên dưới).
|
|
212
|
+
if (selected === SELECT_BACK) {
|
|
213
|
+
return { backed: true };
|
|
214
|
+
}
|
|
215
|
+
if (selected && selected.length === 0) {
|
|
216
|
+
console.error(t("NOTHING_SELECTED_EXITING"));
|
|
217
|
+
process.exit(0);
|
|
97
218
|
}
|
|
98
219
|
|
|
99
220
|
if (resolved.shell) {
|
|
100
|
-
runShell(source, resolved, forwardedArgs);
|
|
221
|
+
runShell(source, resolved, forwardedArgs, selected);
|
|
101
222
|
return;
|
|
102
223
|
}
|
|
103
224
|
|
|
104
225
|
if (resolved.exec) {
|
|
105
|
-
runExec(source, resolved, forwardedArgs);
|
|
226
|
+
runExec(source, resolved, forwardedArgs, selected);
|
|
106
227
|
return;
|
|
107
228
|
}
|
|
108
229
|
|
|
109
|
-
throw new Error(
|
|
230
|
+
throw new Error(
|
|
231
|
+
t("COMMAND_MISSING_EXEC_OR_SHELL_IN_SOURCE__COMMAND_PATH", { command: commandName, path: source.path })
|
|
232
|
+
);
|
|
110
233
|
}
|
|
111
234
|
|
|
112
235
|
function sourceLabel(source) {
|
|
@@ -116,31 +239,26 @@ function sourceLabel(source) {
|
|
|
116
239
|
return source.repoName || source.project;
|
|
117
240
|
}
|
|
118
241
|
|
|
119
|
-
function promptProjectChoice(matches, commandName) {
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
console.log("");
|
|
127
|
-
matches.forEach((source, index) => {
|
|
128
|
-
const def = source.commands[commandName];
|
|
129
|
-
const desc = def?.description ? ` — ${def.description}` : "";
|
|
130
|
-
console.log(` ${index + 1}. ${sourceLabel(source)} (${source.root})${desc}`);
|
|
131
|
-
});
|
|
132
|
-
console.log("");
|
|
133
|
-
|
|
134
|
-
rl.question("Pick a project (number): ", (answer) => {
|
|
135
|
-
rl.close();
|
|
136
|
-
const choice = Number.parseInt(answer, 10);
|
|
137
|
-
if (!Number.isFinite(choice) || choice < 1 || choice > matches.length) {
|
|
138
|
-
reject(new Error("Invalid choice."));
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
resolve(matches[choice - 1]);
|
|
142
|
-
});
|
|
242
|
+
async function promptProjectChoice(matches, commandName) {
|
|
243
|
+
const choices = matches.map((source) => {
|
|
244
|
+
const def = source.commands[commandName];
|
|
245
|
+
const desc = def?.description ? ` — ${def.description}` : "";
|
|
246
|
+
return { name: `${sourceLabel(source)}${desc}`, value: source };
|
|
143
247
|
});
|
|
248
|
+
const picked = await runSelect(
|
|
249
|
+
{
|
|
250
|
+
message: t("COMMAND_MULTIPLE_PLACES_SELECT_PROJECT__COMMAND", { command: commandName }),
|
|
251
|
+
multi: false,
|
|
252
|
+
all: false,
|
|
253
|
+
choices,
|
|
254
|
+
},
|
|
255
|
+
{ forceAll: false }
|
|
256
|
+
);
|
|
257
|
+
const source = Array.isArray(picked) ? picked[0] : picked;
|
|
258
|
+
if (!source || source === SELECT_BACK) {
|
|
259
|
+
throw new Error(t("NOTHING_SELECTED_EXITING"));
|
|
260
|
+
}
|
|
261
|
+
return source;
|
|
144
262
|
}
|
|
145
263
|
|
|
146
264
|
async function runWithProjectResolution(
|
|
@@ -153,9 +271,7 @@ async function runWithProjectResolution(
|
|
|
153
271
|
const resolvedName = resolveCommandName(allSources, commandName);
|
|
154
272
|
|
|
155
273
|
if (matches.length === 0) {
|
|
156
|
-
throw new Error(
|
|
157
|
-
`Command '${commandName}' not found in any .pan.yaml file.`
|
|
158
|
-
);
|
|
274
|
+
throw new Error(t("COMMAND_NOT_FOUND_ANYWHERE__COMMAND", { command: commandName }));
|
|
159
275
|
}
|
|
160
276
|
|
|
161
277
|
let source = null;
|
|
@@ -163,7 +279,7 @@ async function runWithProjectResolution(
|
|
|
163
279
|
source = matches.find((item) => sourceMatchesProject(item, projectFlag));
|
|
164
280
|
if (!source) {
|
|
165
281
|
const names = matches.map((item) => sourceLabel(item)).join(", ");
|
|
166
|
-
throw new Error(
|
|
282
|
+
throw new Error(t("PROJECT_NOT_FOUND__PROJECT_NAMES", { project: projectFlag, names }));
|
|
167
283
|
}
|
|
168
284
|
} else if (matches.length === 1) {
|
|
169
285
|
source = matches[0];
|
|
@@ -181,18 +297,23 @@ async function runWithProjectResolution(
|
|
|
181
297
|
(item, index) => `${index + 1}. ${sourceLabel(item)} (${item.root})`
|
|
182
298
|
);
|
|
183
299
|
throw new Error(
|
|
184
|
-
|
|
300
|
+
t("COMMAND_MULTIPLE_PROJECTS__COMMAND_LINES", { command: commandName, lines: lines.join("\n") })
|
|
185
301
|
);
|
|
186
302
|
}
|
|
187
303
|
}
|
|
188
304
|
|
|
189
|
-
|
|
305
|
+
// Lời gọi trực tiếp (không qua menu tương tác) không có "bước trước" để quay lại — 'b' ở đây
|
|
306
|
+
// chỉ đơn giản là huỷ êm (giống 'không chọn gì'), KHÔNG lỗi.
|
|
307
|
+
const result = await runProjectCommand(source, resolvedName, args);
|
|
308
|
+
if (result && result.backed) {
|
|
309
|
+
console.log(t("NOTHING_SELECTED_EXITING"));
|
|
310
|
+
}
|
|
190
311
|
}
|
|
191
312
|
|
|
192
313
|
function runGitScript(scriptName, args) {
|
|
193
314
|
const scriptPath = path.join(__dirname, "git", scriptName);
|
|
194
315
|
if (!fs.existsSync(scriptPath)) {
|
|
195
|
-
throw new Error(
|
|
316
|
+
throw new Error(t("SCRIPT_NOT_FOUND__PATH", { path: scriptPath }));
|
|
196
317
|
}
|
|
197
318
|
|
|
198
319
|
const child = spawn(process.execPath, [scriptPath, ...args], spawnOptions({
|
package/lib/select.js
ADDED
|
Binary file
|
package/lib/serve.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// pan serve — chạy static HTTP server cho 1 thư mục build (vd output `panui:gen`).
|
|
2
|
+
// Bọc package `serve` (npm). Ưu tiên bin local (đã cài trong pan-stack-cli); nếu chưa có
|
|
3
|
+
// thì fallback `npx --yes serve` (tự tải). SPA mode (-s) để refresh/route sâu vẫn trả index.html.
|
|
4
|
+
const { spawn } = require("child_process");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
function printServeHelp() {
|
|
9
|
+
console.log(`usage: pan serve [dir] [options]
|
|
10
|
+
|
|
11
|
+
Chạy static server cho thư mục build (mặc định thư mục hiện tại).
|
|
12
|
+
Ví dụ:
|
|
13
|
+
pnpm panui:gen examples/blog-project -o panui-demo # build web demo tĩnh
|
|
14
|
+
pan serve panui-demo # xem demo (http://localhost:3000)
|
|
15
|
+
pan serve panui-demo -p 8080
|
|
16
|
+
|
|
17
|
+
options:
|
|
18
|
+
-p, --port <port> Cổng lắng nghe (mặc định 3000)
|
|
19
|
+
-h, --help Hiện trợ giúp này`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseServeArgs(args) {
|
|
23
|
+
let dir = ".";
|
|
24
|
+
let port = "";
|
|
25
|
+
let dirSet = false;
|
|
26
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
27
|
+
const a = args[i];
|
|
28
|
+
if (a === "-h" || a === "--help") {
|
|
29
|
+
printServeHelp();
|
|
30
|
+
process.exit(0);
|
|
31
|
+
} else if (a === "-p" || a === "--port") {
|
|
32
|
+
port = args[i + 1] || "";
|
|
33
|
+
i += 1;
|
|
34
|
+
} else if (a.startsWith("--port=")) {
|
|
35
|
+
port = a.slice("--port=".length);
|
|
36
|
+
} else if (!dirSet) {
|
|
37
|
+
dir = a;
|
|
38
|
+
dirSet = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { dir, port };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Bin `serve` cài cục bộ trong pan-stack-cli (nếu có). */
|
|
45
|
+
function localServeBin() {
|
|
46
|
+
const bin = path.join(__dirname, "..", "node_modules", ".bin", "serve");
|
|
47
|
+
return fs.existsSync(bin) ? bin : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runServe(args) {
|
|
51
|
+
const { dir, port } = parseServeArgs(args);
|
|
52
|
+
const target = path.resolve(dir);
|
|
53
|
+
if (!fs.existsSync(target)) {
|
|
54
|
+
console.error(`\n✗ Không tồn tại thư mục: ${target}\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `serve <dir> -s [-l <port>]` — `-s` single-page (fallback index.html).
|
|
59
|
+
const serveArgs = [target, "-s"];
|
|
60
|
+
if (port) serveArgs.push("-l", String(port));
|
|
61
|
+
|
|
62
|
+
const local = localServeBin();
|
|
63
|
+
const cmd = local || "npx";
|
|
64
|
+
const spawnArgs = local ? serveArgs : ["--yes", "serve", ...serveArgs];
|
|
65
|
+
|
|
66
|
+
const child = spawn(cmd, spawnArgs, { stdio: "inherit" });
|
|
67
|
+
child.on("error", (err) => {
|
|
68
|
+
console.error(`\n✗ Không chạy được 'serve': ${err.message}`);
|
|
69
|
+
console.error(" Cài: cd apps/pan-stack-tools/pan-stack-cli && pnpm install (hoặc dùng npx serve).\n");
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
72
|
+
child.on("close", (code) => process.exit(code || 0));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { runServe };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Client cho registry server pan-agent-skill-registry (apps/pan-agent-skill-registry):
|
|
2
|
+
// gọi HTTP /api/registry + /api/packages/*.zip, quản lý lockfile local
|
|
3
|
+
// <workspaceRoot>/.pan/skills-lock.json để biết cái gì đã cài (installed vs updatable).
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const { execFileSync } = require("child_process");
|
|
8
|
+
const { isWindows } = require("./platform");
|
|
9
|
+
|
|
10
|
+
const DEFAULT_REGISTRY_URL = "http://localhost:4600";
|
|
11
|
+
const LOCK_RELATIVE_PATH = path.join(".pan", "skills-lock.json");
|
|
12
|
+
|
|
13
|
+
function getRegistryBaseUrl() {
|
|
14
|
+
return (process.env.PAN_SKILL_REGISTRY_URL || DEFAULT_REGISTRY_URL).replace(/\/+$/, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function friendlyNetworkError(baseUrl, err) {
|
|
18
|
+
return new Error(
|
|
19
|
+
`✗ Không kết nối được tới registry server (${baseUrl}). Cần server đang chạy trước — xem apps/pan-agent-skill-registry/README.md.` +
|
|
20
|
+
(err && err.message ? ` (${err.message})` : "")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchJson(url) {
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(url);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw friendlyNetworkError(url, err);
|
|
30
|
+
}
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
throw new Error(`✗ Registry server trả lỗi HTTP ${res.status} cho ${url}`);
|
|
33
|
+
}
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchRegistry(baseUrl = getRegistryBaseUrl()) {
|
|
38
|
+
const data = await fetchJson(`${baseUrl}/api/registry`);
|
|
39
|
+
return Array.isArray(data.items) ? data.items : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function checkHealth(baseUrl = getRegistryBaseUrl()) {
|
|
43
|
+
try {
|
|
44
|
+
const data = await fetchJson(`${baseUrl}/health`);
|
|
45
|
+
return !!(data && data.ok);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function lockPath(workspaceRoot) {
|
|
52
|
+
return path.join(workspaceRoot, LOCK_RELATIVE_PATH);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function loadLock(workspaceRoot) {
|
|
56
|
+
const p = lockPath(workspaceRoot);
|
|
57
|
+
if (!fs.existsSync(p)) {
|
|
58
|
+
return { installed: {} };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
62
|
+
if (!raw || typeof raw !== "object") return { installed: {} };
|
|
63
|
+
if (!raw.installed || typeof raw.installed !== "object") raw.installed = {};
|
|
64
|
+
return raw;
|
|
65
|
+
} catch {
|
|
66
|
+
return { installed: {} };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function saveLock(workspaceRoot, lock) {
|
|
71
|
+
const dir = path.join(workspaceRoot, ".pan");
|
|
72
|
+
if (!fs.existsSync(dir)) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
fs.writeFileSync(lockPath(workspaceRoot), JSON.stringify(lock, null, 2) + "\n", "utf8");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// @returns { notInstalled: item[], updatable: item[], upToDate: item[] }
|
|
79
|
+
// (updatable/upToDate item objects are augmented with `oldVersion`).
|
|
80
|
+
function diffRegistry(items, lock) {
|
|
81
|
+
const installed = (lock && lock.installed) || {};
|
|
82
|
+
const notInstalled = [];
|
|
83
|
+
const updatable = [];
|
|
84
|
+
const upToDate = [];
|
|
85
|
+
for (const item of items) {
|
|
86
|
+
const entry = installed[item.name];
|
|
87
|
+
if (!entry) {
|
|
88
|
+
notInstalled.push(item);
|
|
89
|
+
} else if (entry.version !== item.version) {
|
|
90
|
+
updatable.push({ ...item, oldVersion: entry.version });
|
|
91
|
+
} else {
|
|
92
|
+
upToDate.push(item);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { notInstalled, updatable, upToDate };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function downloadToTemp(url) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
fetch(url)
|
|
101
|
+
.then(async (res) => {
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
reject(new Error(`✗ Tải zip thất bại (HTTP ${res.status}) từ ${url}`));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
107
|
+
const tmpFile = path.join(os.tmpdir(), `pan-skill-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`);
|
|
108
|
+
fs.writeFileSync(tmpFile, buf);
|
|
109
|
+
resolve(tmpFile);
|
|
110
|
+
})
|
|
111
|
+
.catch((err) => reject(friendlyNetworkError(url, err)));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractZip(zipFile, destDir) {
|
|
116
|
+
if (fs.existsSync(destDir)) {
|
|
117
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
120
|
+
|
|
121
|
+
if (isWindows()) {
|
|
122
|
+
try {
|
|
123
|
+
execFileSync(
|
|
124
|
+
"powershell",
|
|
125
|
+
["-NoProfile", "-Command", `Expand-Archive -Force -Path '${zipFile}' -DestinationPath '${destDir}'`],
|
|
126
|
+
{ stdio: "pipe" }
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`✗ Không giải nén được (PowerShell Expand-Archive). Cần PowerShell khả dụng trên máy. (${err.message})`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
execFileSync("unzip", ["-o", "-q", zipFile, "-d", destDir], { stdio: "pipe" });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`✗ Không giải nén được (thiếu lệnh 'unzip' hoặc file zip lỗi). Cài unzip rồi thử lại. (${err.message})`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Tải zip của `item` (từ baseUrl + item.zip) và giải nén vào `<destDir>/<item.name>/`
|
|
146
|
+
// (zip không có lớp thư mục bọc — ta tự tạo).
|
|
147
|
+
async function downloadAndExtract(baseUrl, item, destDir) {
|
|
148
|
+
const zipUrl = `${baseUrl}${item.zip}`;
|
|
149
|
+
const tmpFile = await downloadToTemp(zipUrl);
|
|
150
|
+
try {
|
|
151
|
+
const target = path.join(destDir, item.name);
|
|
152
|
+
extractZip(tmpFile, target);
|
|
153
|
+
return target;
|
|
154
|
+
} finally {
|
|
155
|
+
try {
|
|
156
|
+
fs.unlinkSync(tmpFile);
|
|
157
|
+
} catch {
|
|
158
|
+
/* bỏ qua */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
DEFAULT_REGISTRY_URL,
|
|
165
|
+
getRegistryBaseUrl,
|
|
166
|
+
fetchRegistry,
|
|
167
|
+
checkHealth,
|
|
168
|
+
lockPath,
|
|
169
|
+
loadLock,
|
|
170
|
+
saveLock,
|
|
171
|
+
diffRegistry,
|
|
172
|
+
downloadAndExtract,
|
|
173
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { canonicalEmail } = require("./identity");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gộp commit theo thành viên (email chuẩn) và theo dự án.
|
|
7
|
+
* (Commit đã được lọc theo range ở tầng git; range ở đây chỉ để chắc chắn.)
|
|
8
|
+
*/
|
|
9
|
+
function aggregate(commits, aliasMap = {}, range = {}) {
|
|
10
|
+
const { from = -Infinity, to = Infinity } = range;
|
|
11
|
+
const byEmail = new Map();
|
|
12
|
+
|
|
13
|
+
for (const c of commits) {
|
|
14
|
+
if (c.ts < from || c.ts > to) continue;
|
|
15
|
+
const email = canonicalEmail(c.email, aliasMap);
|
|
16
|
+
if (!byEmail.has(email)) {
|
|
17
|
+
byEmail.set(email, { email, name: c.name || email, commits: 0, additions: 0, deletions: 0, projects: new Map() });
|
|
18
|
+
}
|
|
19
|
+
const m = byEmail.get(email);
|
|
20
|
+
m.commits += 1;
|
|
21
|
+
m.additions += c.additions;
|
|
22
|
+
m.deletions += c.deletions;
|
|
23
|
+
if (c.name) m.name = c.name;
|
|
24
|
+
|
|
25
|
+
if (!m.projects.has(c.repoName)) {
|
|
26
|
+
m.projects.set(c.repoName, { name: c.repoName, commits: 0, additions: 0, deletions: 0 });
|
|
27
|
+
}
|
|
28
|
+
const p = m.projects.get(c.repoName);
|
|
29
|
+
p.commits += 1;
|
|
30
|
+
p.additions += c.additions;
|
|
31
|
+
p.deletions += c.deletions;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const members = [...byEmail.values()]
|
|
35
|
+
.map((m) => ({ ...m, projects: [...m.projects.values()].sort((a, b) => b.commits - a.commits) }))
|
|
36
|
+
.sort((a, b) => b.commits - a.commits);
|
|
37
|
+
|
|
38
|
+
const totals = members.reduce(
|
|
39
|
+
(acc, m) => {
|
|
40
|
+
acc.commits += m.commits;
|
|
41
|
+
acc.additions += m.additions;
|
|
42
|
+
acc.deletions += m.deletions;
|
|
43
|
+
return acc;
|
|
44
|
+
},
|
|
45
|
+
{ members: members.length, commits: 0, additions: 0, deletions: 0 }
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return { members, totals };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { aggregate };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
|
|
5
|
+
/** Đọc alias-map.json (email biến thể -> email chuẩn). Không có thì {}. */
|
|
6
|
+
function loadAliasMap(file) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
9
|
+
} catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Chuẩn hoá email theo alias-map (fallback chính nó). */
|
|
15
|
+
function canonicalEmail(email, aliasMap = {}) {
|
|
16
|
+
const key = (email || "").toLowerCase().trim();
|
|
17
|
+
return aliasMap[key] || key || "unknown@local";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { loadAliasMap, canonicalEmail };
|