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.
Files changed (62) hide show
  1. package/README.md +572 -24
  2. package/bin/pan.js +204 -21
  3. package/lib/ai-skills.js +829 -0
  4. package/lib/create.js +22 -4
  5. package/lib/discover.js +59 -1
  6. package/lib/git/git-checkout.js +395 -0
  7. package/lib/git/git-clone.js +162 -27
  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 +42 -28
  15. package/lib/git/helpers/bulk-report.js +202 -0
  16. package/lib/git/helpers/cli.js +40 -5
  17. package/lib/git/helpers/manifest.js +93 -0
  18. package/lib/git/helpers/repo-select.js +328 -9
  19. package/lib/git/helpers/repo-summary.js +161 -0
  20. package/lib/i18n.js +61 -0
  21. package/lib/inquirer-theme.js +20 -0
  22. package/lib/language.js +121 -0
  23. package/lib/languages.js +14 -0
  24. package/lib/manifest.js +115 -14
  25. package/lib/menu-prompt.js +201 -0
  26. package/lib/menu.js +134 -0
  27. package/lib/messages.js +647 -0
  28. package/lib/run-command.js +173 -52
  29. package/lib/select.js +0 -0
  30. package/lib/serve.js +75 -0
  31. package/lib/skill-registry-client.js +173 -0
  32. package/lib/stats/aggregate.js +51 -0
  33. package/lib/stats/identity.js +20 -0
  34. package/lib/stats/index.js +189 -0
  35. package/lib/stats/parse.js +46 -0
  36. package/lib/stats/range.js +38 -0
  37. package/lib/stats/report.js +264 -0
  38. package/lib/stats/review.js +157 -0
  39. package/lib/stats/scan.js +92 -0
  40. package/lib/stats/serve.js +25 -0
  41. package/lib/ui/api.js +202 -0
  42. package/lib/ui/clone.js +115 -0
  43. package/lib/ui/config.js +182 -0
  44. package/lib/ui/diff.js +128 -0
  45. package/lib/ui/export-gate.js +79 -0
  46. package/lib/ui/gen-react-mobile-code.js +6 -0
  47. package/lib/ui/gen-react-web-code.js +75 -0
  48. package/lib/ui/index.js +89 -0
  49. package/lib/ui/init.js +252 -0
  50. package/lib/ui/login.js +128 -0
  51. package/lib/ui/logout.js +22 -0
  52. package/lib/ui/palettes.js +103 -0
  53. package/lib/ui/published-html.js +24 -0
  54. package/lib/ui/sync.js +137 -0
  55. package/lib/ui/workspace.js +213 -0
  56. package/package.json +10 -3
  57. package/templates/workspace/.claude/skills/writing-guidelines/SKILL.md +0 -48
  58. package/templates/workspace/.codex/skills/writing-guidelines/SKILL.md +0 -48
  59. package/templates/workspace/.cursor/skills/writing-guidelines/SKILL.md +0 -48
  60. package/templates/workspace/.pan.yaml +0 -25
  61. package/templates/workspace/.project.yaml +0 -15
  62. package/templates/workspace/README.md +0 -35
@@ -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(`Directory not found: ${dir} (run 'pan clone' first?)`);
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
- function runExec(source, def, forwardedArgs) {
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("Command is missing 'exec' or 'shell'.");
62
+ throw new Error(t("COMMAND_MISSING_EXEC_OR_SHELL"));
51
63
  }
52
64
 
53
- const result = runExecLine(execLine, {
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
- function runShell(source, def, forwardedArgs) {
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 = runShellScript(def.shell, {
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
- function runProjectCommand(source, commandName, args) {
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(`Command '${commandName}' not found in ${source.path}`);
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(`Command '${commandName}' is missing 'exec' or 'shell' in ${source.path}`);
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
- return new Promise((resolve, reject) => {
121
- const rl = readline.createInterface({
122
- input: process.stdin,
123
- output: process.stdout,
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(`Project '${projectFlag}' not found. Available: ${names}`);
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
- `Command '${commandName}' is defined in multiple projects. Use --project <name>.\n${lines.join("\n")}`
300
+ t("COMMAND_MULTIPLE_PROJECTS__COMMAND_LINES", { command: commandName, lines: lines.join("\n") })
185
301
  );
186
302
  }
187
303
  }
188
304
 
189
- runProjectCommand(source, resolvedName, args);
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(`Script not found: ${scriptPath}`);
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 };