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
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+
3
+ const { spawnSync } = require("child_process");
4
+ const { spawnOptions } = require("../../platform");
5
+ const colors = require("./colors");
6
+ const { runGitCapture, getCurrentBranch, listConflictedFiles } = require("./git");
7
+ const { t } = require("../../i18n");
8
+
9
+ const ICON = { ok: "✅", fail: "❌", warn: "⚠️", skip: "⏭️", info: "ℹ️", up: "↑", down: "↓" };
10
+
11
+ // ───────── shared helpers ─────────
12
+
13
+ /** Chạy git im lặng, capture stdout/stderr; trả { status, stdout, stderr }. */
14
+ function runGitSilent(gitArgs, cwd) {
15
+ const result = spawnSync("git", gitArgs, spawnOptions({ cwd, encoding: "utf8" }));
16
+ return {
17
+ status: result.status ?? 1,
18
+ stdout: (result.stdout || "").trim(),
19
+ stderr: (result.stderr || "").trim(),
20
+ };
21
+ }
22
+
23
+ /** ahead/behind so với upstream. Trả null nếu không có upstream. */
24
+ function aheadBehind(cwd) {
25
+ const upstream = runGitCapture(["rev-parse", "--abbrev-ref", "@{u}"], { cwd });
26
+ if (upstream.status !== 0 || !upstream.stdout) return null;
27
+ const rl = runGitCapture(["rev-list", "--left-right", "--count", "HEAD...@{u}"], { cwd });
28
+ if (rl.status !== 0) return null;
29
+ const [ahead, behind] = rl.stdout.split(/\s+/).map((n) => parseInt(n, 10) || 0);
30
+ return { ahead, behind, upstream: upstream.stdout };
31
+ }
32
+
33
+ /**
34
+ * Tổng line change của các commit sắp push (@{u}..HEAD).
35
+ * Cộng theo từng commit (`git log --numstat`), không dùng net-diff.
36
+ * Trả { insertions, deletions, filesChanged } — filesChanged = số path unique.
37
+ */
38
+ function aheadLineStats(cwd, { range } = {}) {
39
+ const empty = { insertions: 0, deletions: 0, filesChanged: 0 };
40
+ let revRange = range;
41
+ if (!revRange) {
42
+ const ab = aheadBehind(cwd);
43
+ if (!ab || ab.ahead === 0) return empty;
44
+ revRange = "@{u}..HEAD";
45
+ }
46
+ const res = runGitCapture(
47
+ ["log", "--numstat", "--pretty=tformat:", "--no-merges", revRange],
48
+ { cwd }
49
+ );
50
+ if (res.status !== 0) return empty;
51
+ let insertions = 0;
52
+ let deletions = 0;
53
+ const files = new Set();
54
+ for (const line of (res.stdout || "").split(/\r?\n/)) {
55
+ if (!line) continue;
56
+ // numstat: "<add>\t<del>\t<path>" — binary: "-\t-\t<path>"
57
+ const parts = line.split("\t");
58
+ if (parts.length < 3) continue;
59
+ const add = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
60
+ const del = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
61
+ insertions += add;
62
+ deletions += del;
63
+ files.add(parts.slice(2).join("\t"));
64
+ }
65
+ return { insertions, deletions, filesChanged: files.size };
66
+ }
67
+
68
+ // ───────── output parsers ─────────
69
+
70
+ /** Parse `git pull` output. */
71
+ function parsePullOutput(stdout, stderr) {
72
+ const text = `${stdout}\n${stderr}`;
73
+ const out = { mode: "unknown", filesChanged: 0, insertions: 0, deletions: 0, conflict: false, upToDate: false };
74
+ if (/Already up to date|Already up-to-date/i.test(text)) out.upToDate = true;
75
+ if (/Fast-forward/i.test(text)) out.mode = "fast-forward";
76
+ else if (/Merge made by/i.test(text)) out.mode = "merge";
77
+ else if (/Successfully rebased/i.test(text)) out.mode = "rebase";
78
+ else if (/CONFLICT/i.test(text)) { out.mode = "conflict"; out.conflict = true; }
79
+ // shortstat line: " 12 files changed, 234 insertions(+), 56 deletions(-)"
80
+ const stat = text.match(/(\d+)\s+files?\s+changed(?:,\s*(\d+)\s+insertions?\(\+\))?(?:,\s*(\d+)\s+deletions?\(-\))?/);
81
+ if (stat) {
82
+ out.filesChanged = parseInt(stat[1], 10);
83
+ out.insertions = parseInt(stat[2] || "0", 10);
84
+ out.deletions = parseInt(stat[3] || "0", 10);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** Parse `git push` output. GitLab/GitHub in gợi ý URL MR/PR ở stderr. */
90
+ function parsePushOutput(stdout, stderr) {
91
+ const text = `${stdout}\n${stderr}`;
92
+ const out = { pushed: false, upToDate: false, rejected: false, mrLink: "", refs: [] };
93
+ if (/Everything up-to-date|up to date/i.test(text)) out.upToDate = true;
94
+ if (/\[rejected\]|! \[remote rejected\]|failed to push some refs/i.test(text)) out.rejected = true;
95
+ // URL merge_requests hoặc pull/new
96
+ const url =
97
+ text.match(/https?:\/\/\S*merge_requests\/new\S*/) ||
98
+ text.match(/https?:\/\/\S*\/merge_requests\/\d+/) ||
99
+ text.match(/https?:\/\/\S*\/-\/merge_requests\S*/) ||
100
+ text.match(/https?:\/\/\S*\/pull\/new\/\S+/) ||
101
+ text.match(/https?:\/\/\S*\/pull\/\d+/);
102
+ if (url) out.mrLink = url[0].replace(/[.,)]+$/, "");
103
+ // refs pushed: "abcdef..123456 main -> main"
104
+ const refRe = /([0-9a-f]{7,}\.\.[0-9a-f]{7,})\s+(\S+)\s+->\s+(\S+)/g;
105
+ let m;
106
+ while ((m = refRe.exec(text)) !== null) {
107
+ out.refs.push({ range: m[1], local: m[2], remote: m[3] });
108
+ out.pushed = true;
109
+ }
110
+ // "* [new branch]"
111
+ if (/\*\s+\[new branch\]/.test(text)) out.pushed = true;
112
+ return out;
113
+ }
114
+
115
+ /** Parse `git fetch --all` output — số dòng update ref. */
116
+ function parseFetchOutput(stdout, stderr) {
117
+ const text = `${stdout}\n${stderr}`;
118
+ const out = { newBranches: 0, updatedBranches: 0, newTags: 0, prunedRefs: 0 };
119
+ out.newBranches = (text.match(/\*\s+\[new branch\]/g) || []).length;
120
+ out.updatedBranches = (text.match(/^\s*[0-9a-f]{6,}\.\.[0-9a-f]{6,}\s+/gm) || []).length;
121
+ out.newTags = (text.match(/\*\s+\[new tag\]/g) || []).length;
122
+ out.prunedRefs = (text.match(/\[deleted\]/g) || []).length;
123
+ return out;
124
+ }
125
+
126
+ // ───────── table renderer ─────────
127
+
128
+ /**
129
+ * @param {Array<{key:string,label:string,align?:'l'|'r'}>} columns
130
+ * @param {Array<Object>} rows
131
+ */
132
+ function renderTable(columns, rows) {
133
+ // strip ANSI để đo width thật
134
+ const strip = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*m/g, "");
135
+ const visLen = (s) => strip(s).length;
136
+ const widths = columns.map((c) => Math.max(c.label.length, ...rows.map((r) => visLen(r[c.key]))));
137
+
138
+ const pad = (s, w, align) => {
139
+ const len = visLen(s);
140
+ const spaces = " ".repeat(Math.max(0, w - len));
141
+ return align === "r" ? spaces + s : s + spaces;
142
+ };
143
+
144
+ const header = columns.map((c, i) => colors.bold(pad(c.label, widths[i], c.align))).join(" ");
145
+ const sep = columns.map((_, i) => colors.dim("─".repeat(widths[i]))).join(" ");
146
+ const body = rows.map((r) =>
147
+ columns.map((c, i) => pad(String(r[c.key] ?? ""), widths[i], c.align)).join(" ")
148
+ );
149
+ return [header, sep, ...body].join("\n");
150
+ }
151
+
152
+ function statusBadge(kind, text) {
153
+ if (kind === "ok") return colors.green(`${ICON.ok} ${text}`);
154
+ if (kind === "warn") return colors.yellow(`${ICON.warn} ${text}`);
155
+ if (kind === "fail") return colors.red(`${ICON.fail} ${text}`);
156
+ if (kind === "skip") return colors.dim(`${ICON.skip} ${text}`);
157
+ return text;
158
+ }
159
+
160
+ function printSummary({ title, rows, columns, footerLines = [] }) {
161
+ console.log("");
162
+ console.log(colors.bold(`─── ${title} ───`));
163
+ if (rows.length === 0) {
164
+ console.log(colors.dim("(no data)"));
165
+ return;
166
+ }
167
+ console.log(renderTable(columns, rows));
168
+ for (const line of footerLines) console.log(line);
169
+ }
170
+
171
+ /** Helper: cột bảng có label theo i18n. */
172
+ function bulkColumns(specs) {
173
+ return specs.map((s) => ({ key: s.key, label: t(s.labelKey), align: s.align }));
174
+ }
175
+
176
+ /** Dịch mode pull (fast-forward/merge/rebase/conflict) qua i18n. */
177
+ function pullModeLabel(mode) {
178
+ switch (mode) {
179
+ case "fast-forward": return t("BULK_MODE_FAST_FORWARD");
180
+ case "merge": return t("BULK_MODE_MERGE");
181
+ case "rebase": return t("BULK_MODE_REBASE");
182
+ case "conflict": return t("BULK_STATUS_CONFLICT");
183
+ default: return mode;
184
+ }
185
+ }
186
+
187
+ module.exports = {
188
+ ICON,
189
+ runGitSilent,
190
+ aheadBehind,
191
+ aheadLineStats,
192
+ parsePullOutput,
193
+ parsePushOutput,
194
+ parseFetchOutput,
195
+ renderTable,
196
+ statusBadge,
197
+ printSummary,
198
+ bulkColumns,
199
+ pullModeLabel,
200
+ listConflictedFiles,
201
+ getCurrentBranch,
202
+ };
@@ -1,7 +1,8 @@
1
1
  const path = require("path");
2
2
  const fs = require("fs");
3
- const { findNearestProjectManifest } = require("../../discover");
4
- const { parseManifest, manifestRepos, validateRepo } = require("./manifest");
3
+ const { findNearestProjectManifest, discoverGitRepos } = require("../../discover");
4
+ const { parseManifestWithLocal, manifestRepos, validateRepo } = require("./manifest");
5
+ const { t } = require("../../i18n");
5
6
 
6
7
  function parseCommonArgs(argv, extraFlags = {}) {
7
8
  const args = {
@@ -96,12 +97,46 @@ function resolveProjectWorkspace({
96
97
  };
97
98
  }
98
99
 
99
- function loadManifestRepos({ manifestPath, baseDir, startDir = process.cwd() }) {
100
- const workspace = resolveProjectWorkspace({ manifestPath, baseDir, startDir });
100
+ /**
101
+ * Fallback khi không có `.project.yaml`: coi `startDir` (nơi gõ lệnh) là gốc dự
102
+ * án, quét git repo con bên trong (giống `pan stats`) — dùng cho các lệnh git
103
+ * thuần (pull/push/fetch/checkout/sync-main/mr/grv/gst), KHÔNG áp dụng cho
104
+ * `clone` (cần url khai báo trong manifest, không tự suy ra được).
105
+ */
106
+ function fallbackReposFromScan(startDir) {
107
+ const found = discoverGitRepos(startDir);
108
+ if (!found.length) {
109
+ throw new Error(
110
+ t("MANIFEST_NOT_FOUND_NO_GIT_FALLBACK__DIR", { dir: startDir })
111
+ );
112
+ }
113
+ console.log(t("MANIFEST_NOT_FOUND_FALLBACK_SCAN__DIR", { dir: startDir }));
114
+ return found.map((repo) => ({
115
+ name: repo.name,
116
+ url: null,
117
+ repoPath: path.relative(startDir, repo.dir) || ".",
118
+ destination: repo.dir,
119
+ }));
120
+ }
121
+
122
+ function loadManifestRepos({ manifestPath, baseDir, startDir = process.cwd(), allowFallback = true }) {
123
+ let workspace;
124
+ try {
125
+ workspace = resolveProjectWorkspace({ manifestPath, baseDir, startDir });
126
+ } catch (error) {
127
+ if (!allowFallback) throw error;
128
+ return {
129
+ repos: fallbackReposFromScan(startDir),
130
+ manifestPath: "",
131
+ baseDir: startDir,
132
+ workspaceRoot: startDir,
133
+ };
134
+ }
135
+
101
136
  const { manifestPath: resolvedManifest, baseDir: resolvedBaseDir, workspaceRoot } =
102
137
  workspace;
103
138
 
104
- const data = parseManifest(resolvedManifest);
139
+ const data = parseManifestWithLocal(resolvedManifest);
105
140
  const repos = manifestRepos(data);
106
141
  if (repos.length === 0) {
107
142
  throw new Error("No repositories found in manifest.");
@@ -1,6 +1,8 @@
1
1
  const fs = require("fs");
2
+ const path = require("path");
2
3
 
3
4
  const PLACEHOLDER_VALUES = new Set(["", "repo_url_to_be_filled", "pan_stack_repo_url"]);
5
+ const LOCAL_MANIFEST_FILE = ".project.local.yaml";
4
6
 
5
7
  function cleanValue(value) {
6
8
  let result = String(value || "").trim();
@@ -79,6 +81,92 @@ function parseManifest(manifestPath) {
79
81
  return data;
80
82
  }
81
83
 
84
+ /**
85
+ * Parse `.project.yaml` rồi GỘP THÊM `repositories:` từ `.project.local.yaml` cùng thư mục
86
+ * (nếu có) — file cá nhân (gitignore), chỉ cần field `repositories:`. Mọi lệnh git built-in
87
+ * (clone/pull/push/fetch/grv/gst/sync-main/mr/checkout) đi qua `loadManifestRepos` (./cli.js) nên
88
+ * tự động thấy repo local — xem lib/manifest.js#parseManifestWithLocal (bản dùng cho .pan.yaml
89
+ * project commands) để biết thêm; 2 nơi khác parser (helpers riêng cho git built-in) nên duplicate
90
+ * logic merge ở đây, KHÔNG duplicate parser.
91
+ */
92
+ function parseManifestWithLocal(manifestPath) {
93
+ const data = parseManifest(manifestPath);
94
+ const localPath = path.join(path.dirname(manifestPath), LOCAL_MANIFEST_FILE);
95
+ if (fs.existsSync(localPath)) {
96
+ const localData = parseManifest(localPath);
97
+ const localRepos = Array.isArray(localData.repositories) ? localData.repositories : [];
98
+ if (localRepos.length) {
99
+ data.repositories = [...(data.repositories || []), ...localRepos];
100
+ console.error(`ℹ️ +${localRepos.length} repo từ ${LOCAL_MANIFEST_FILE}`);
101
+ }
102
+ }
103
+ return data;
104
+ }
105
+
106
+ // Nhận diện 1 tham số CÓ PHẢI git URL hay không (để `pan clone <url>` phân biệt với
107
+ // `pan clone` (không tham số = clone all) / `pan clone custom.project.yaml` (chỉ định manifest)).
108
+ function looksLikeGitUrl(arg) {
109
+ if (!arg) return false;
110
+ if (/^(https?|ssh|git):\/\//i.test(arg)) return true;
111
+ if (/^[\w.-]+@[\w.-]+:.+/.test(arg)) return true; // scp-like: git@host:group/repo.git
112
+ if (/\.git$/i.test(arg)) return true;
113
+ return false;
114
+ }
115
+
116
+ // Tách tên dự án từ git URL — bỏ đuôi ".git", lấy phần cuối sau "/" (hoặc ":" kiểu scp-like).
117
+ // https://host/group/my-repo.git -> my-repo
118
+ // git@github.com:user/my-repo.git -> my-repo
119
+ // https://github.com/user/my-repo -> my-repo
120
+ function repoNameFromUrl(url) {
121
+ let s = String(url || "").trim().replace(/\/+$/, "").replace(/\.git$/i, "");
122
+ const idx = Math.max(s.lastIndexOf("/"), s.lastIndexOf(":"));
123
+ return idx >= 0 ? s.slice(idx + 1) : s;
124
+ }
125
+
126
+ /**
127
+ * Thêm 1 entry mới vào cuối danh sách `repositories:` trong `.project.yaml` (ghi trực tiếp vào
128
+ * FILE, không phải `.project.local.yaml`) — dùng bởi `pan clone <url>`. Chèn ngay TRƯỚC section
129
+ * top-level kế tiếp (nếu `repositories:` không phải section cuối file), hoặc cuối file nếu là
130
+ * section cuối; nếu file chưa có `repositories:` thì thêm mới section này ở cuối.
131
+ * @param {string} manifestPath
132
+ * @param {{name:string, url:string, path:string, description?:string}} repo
133
+ */
134
+ function appendRepository(manifestPath, repo) {
135
+ const content = fs.readFileSync(manifestPath, "utf8");
136
+ const lines = content.split(/\r?\n/);
137
+ // Bỏ các dòng trống Ở CUỐI (do file gốc kết bằng newline) — tránh chèn nhầm sau dòng trống đó
138
+ // (sinh ra 1 dòng trống thừa) và đảm bảo luôn ghi lại với ĐÚNG 1 newline cuối file.
139
+ while (lines.length && lines[lines.length - 1].trim() === "") {
140
+ lines.pop();
141
+ }
142
+ const entryLines = [
143
+ ` - name: ${repo.name}`,
144
+ ...(repo.description ? [` description: ${repo.description}`] : []),
145
+ ` url: ${repo.url}`,
146
+ ` path: ${repo.path}`,
147
+ ];
148
+
149
+ const repoLineIdx = lines.findIndex((l) => /^repositories:\s*$/.test(l));
150
+ if (repoLineIdx === -1) {
151
+ const trimmed = content.replace(/\s+$/, "");
152
+ fs.writeFileSync(manifestPath, `${trimmed}\n\nrepositories:\n${entryLines.join("\n")}\n`, "utf8");
153
+ return;
154
+ }
155
+
156
+ let endIdx = lines.length;
157
+ for (let i = repoLineIdx + 1; i < lines.length; i += 1) {
158
+ const line = lines[i];
159
+ if (line.trim() === "") continue;
160
+ const indent = line.length - line.trimStart().length;
161
+ if (indent === 0) {
162
+ endIdx = i;
163
+ break;
164
+ }
165
+ }
166
+ const newLines = [...lines.slice(0, endIdx), ...entryLines, ...lines.slice(endIdx)];
167
+ fs.writeFileSync(manifestPath, `${newLines.join("\n")}\n`, "utf8");
168
+ }
169
+
82
170
  function uniqueRepos(repos) {
83
171
  const seen = new Set();
84
172
  const result = [];
@@ -121,9 +209,14 @@ function validateRepo(repo) {
121
209
 
122
210
  module.exports = {
123
211
  PLACEHOLDER_VALUES,
212
+ LOCAL_MANIFEST_FILE,
124
213
  cleanValue,
125
214
  splitKeyValue,
126
215
  parseManifest,
216
+ parseManifestWithLocal,
217
+ looksLikeGitUrl,
218
+ repoNameFromUrl,
219
+ appendRepository,
127
220
  uniqueRepos,
128
221
  manifestRepos,
129
222
  validateRepo,