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,157 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { execFileSync } = require("child_process");
6
+ const { toGitDate } = require("./range");
7
+
8
+ /**
9
+ * Xuất code change của 1 thành viên trong 1 dự án theo khoảng thời gian ra
10
+ * file Markdown để đưa AI review/chấm điểm.
11
+ * @param {{name,dir}} repo repo đã resolve (name + đường dẫn tuyệt đối)
12
+ * @param {string} member email hoặc tên (dùng làm --author regex của git)
13
+ * @param {{from,to,label}} range
14
+ * @param {{withDiff?:boolean, maxDiffLines?:number}} opts
15
+ * @returns {string} nội dung markdown
16
+ */
17
+ function buildReviewMarkdown(repo, member, range, opts = {}) {
18
+ const { withDiff = true, maxDiffLines = 6000, allBranches = true } = opts;
19
+ const since = toGitDate(range.from);
20
+ const until = toGitDate(range.to);
21
+
22
+ // Mặc định quét mọi nhánh; tắt bằng { allBranches: false }.
23
+ const baseArgs = ["-C", repo.dir, "log", "--no-merges", `--author=${member}`];
24
+ if (allBranches) baseArgs.push("--all");
25
+ if (since) baseArgs.push(`--since=${since}`);
26
+ if (until) baseArgs.push(`--until=${until}`);
27
+
28
+ // 1) Danh sách commit + stat tóm tắt
29
+ const listRaw = run([
30
+ ...baseArgs,
31
+ "--date=format:%Y-%m-%d %H:%M",
32
+ "--pretty=format:%h\x1f%ad\x1f%an\x1f%ae\x1f%s",
33
+ "--shortstat",
34
+ ]);
35
+ const commits = parseListWithStat(listRaw);
36
+
37
+ const totalAdd = commits.reduce((s, c) => s + c.additions, 0);
38
+ const totalDel = commits.reduce((s, c) => s + c.deletions, 0);
39
+
40
+ const lines = [];
41
+ lines.push(`# Code review — ${repo.name}`);
42
+ lines.push("");
43
+ lines.push(`- **Thành viên:** ${member}`);
44
+ lines.push(`- **Khoảng thời gian:** ${range.label}`);
45
+ lines.push(`- **Dự án:** ${repo.name}`);
46
+ lines.push(`- **Sinh lúc:** ${new Date().toISOString()}`);
47
+ lines.push("");
48
+ lines.push(`## Tóm tắt`);
49
+ lines.push("");
50
+ lines.push(`- Tổng commit: **${commits.length}**`);
51
+ lines.push(`- Dòng thêm: **+${totalAdd}** · Dòng xoá: **−${totalDel}**`);
52
+ lines.push("");
53
+
54
+ if (commits.length === 0) {
55
+ lines.push("> Không có commit nào khớp thành viên/khoảng thời gian này.");
56
+ return lines.join("\n");
57
+ }
58
+
59
+ lines.push(`## Danh sách commit`);
60
+ lines.push("");
61
+ lines.push("| Ngày | Commit | Mô tả | +/− |");
62
+ lines.push("|------|--------|-------|-----|");
63
+ for (const c of commits) {
64
+ lines.push(`| ${c.date} | \`${c.hash}\` | ${escapePipe(c.subject)} | +${c.additions}/−${c.deletions} |`);
65
+ }
66
+ lines.push("");
67
+
68
+ if (withDiff) {
69
+ lines.push(`## Nội dung thay đổi (diff)`);
70
+ lines.push("");
71
+ let emitted = 0;
72
+ for (const c of commits) {
73
+ if (emitted >= maxDiffLines) {
74
+ lines.push(`> …đã cắt bớt diff (vượt ${maxDiffLines} dòng). Dùng \`--no-diff\` hoặc thu hẹp khoảng thời gian.`);
75
+ break;
76
+ }
77
+ const diff = run([
78
+ "-C",
79
+ repo.dir,
80
+ "show",
81
+ c.hash,
82
+ "--no-color",
83
+ "--pretty=format:",
84
+ ]);
85
+ const body = diff.split("\n").slice(0, Math.max(0, maxDiffLines - emitted));
86
+ emitted += body.length;
87
+ lines.push(`### \`${c.hash}\` — ${c.subject}`);
88
+ lines.push("");
89
+ lines.push("```diff");
90
+ lines.push(body.join("\n").trim());
91
+ lines.push("```");
92
+ lines.push("");
93
+ }
94
+ }
95
+
96
+ lines.push(`---`);
97
+ lines.push("");
98
+ lines.push(`## Yêu cầu cho AI reviewer`);
99
+ lines.push("");
100
+ lines.push(`Hãy đánh giá phần code thay đổi ở trên và cho **điểm 1–10** kèm nhận xét theo các tiêu chí:`);
101
+ lines.push("");
102
+ lines.push(`1. **Tính đúng đắn** — logic có đúng, có bug tiềm ẩn không?`);
103
+ lines.push(`2. **Chất lượng code** — đặt tên, cấu trúc, trùng lặp, độ phức tạp.`);
104
+ lines.push(`3. **An toàn & xử lý lỗi** — validate input, edge case, rò rỉ dữ liệu.`);
105
+ lines.push(`4. **Kiểm thử** — có test kèm theo / có cần bổ sung không?`);
106
+ lines.push(`5. **Tính nhất quán** — bám theo quy ước dự án.`);
107
+ lines.push("");
108
+ lines.push(`Xuất kết quả: bảng điểm từng tiêu chí, điểm tổng, và top 3 điểm cần cải thiện.`);
109
+
110
+ return lines.join("\n");
111
+ }
112
+
113
+ function run(args) {
114
+ try {
115
+ return execFileSync("git", args, { encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
116
+ } catch (err) {
117
+ throw new Error(`git lỗi: ${(err.message || "").split("\n")[0]}`);
118
+ }
119
+ }
120
+
121
+ /** Parse output của `git log --shortstat --pretty=hash..subject`. */
122
+ function parseListWithStat(raw) {
123
+ const commits = [];
124
+ let cur = null;
125
+ for (const line of raw.split("\n")) {
126
+ if (line.includes("\x1f")) {
127
+ if (cur) commits.push(cur);
128
+ const [hash, date, name, email, subject] = line.split("\x1f");
129
+ cur = { hash, date, name, email, subject: subject || "", additions: 0, deletions: 0 };
130
+ continue;
131
+ }
132
+ const m = line.match(/(\d+) insertion[s]?\(\+\)|(\d+) deletion[s]?\(-\)/g);
133
+ if (m && cur) {
134
+ for (const part of m) {
135
+ const add = part.match(/(\d+) insertion/);
136
+ const del = part.match(/(\d+) deletion/);
137
+ if (add) cur.additions += parseInt(add[1], 10);
138
+ if (del) cur.deletions += parseInt(del[1], 10);
139
+ }
140
+ }
141
+ }
142
+ if (cur) commits.push(cur);
143
+ return commits;
144
+ }
145
+
146
+ function escapePipe(s) {
147
+ return String(s || "").replace(/\|/g, "\\|");
148
+ }
149
+
150
+ /** Ghi ra file, tạo thư mục nếu cần. */
151
+ function writeReview(outFile, content) {
152
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
153
+ fs.writeFileSync(outFile, content);
154
+ return outFile;
155
+ }
156
+
157
+ module.exports = { buildReviewMarkdown, writeReview };
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { execFileSync } = require("child_process");
6
+ const { parseGitLog, PRETTY_FORMAT } = require("./parse");
7
+ const { toGitDate } = require("./range");
8
+ const { validateRepo } = require("../manifest");
9
+ const { discoverGitRepos } = require("../discover");
10
+
11
+ /**
12
+ * Từ workspace (.project.yaml) → danh sách repo cần quét kèm đường dẫn tuyệt đối.
13
+ */
14
+ function reposFromWorkspace(workspace) {
15
+ const out = [];
16
+ for (const repo of workspace.repositories) {
17
+ let name;
18
+ let repoPath;
19
+ try {
20
+ ({ name, repoPath } = validateRepo(repo));
21
+ } catch {
22
+ continue; // entry lỗi trong yaml
23
+ }
24
+ out.push({ name, dir: path.resolve(workspace.root, repoPath) });
25
+ }
26
+ return out;
27
+ }
28
+
29
+ function isGitRepo(dir) {
30
+ try {
31
+ return fs.existsSync(path.join(dir, ".git"));
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Quét 1 repo trong khoảng thời gian. Trả trạng thái sẵn có (available/reason).
39
+ * pan-stack là vỏ bọc → repo có thể chưa clone / không đọc được.
40
+ */
41
+ function scanRepo(repo, range, { allBranches = true } = {}) {
42
+ if (!fs.existsSync(repo.dir)) {
43
+ return { name: repo.name, available: false, reason: "chưa clone (không có thư mục)", commits: [] };
44
+ }
45
+ if (!isGitRepo(repo.dir)) {
46
+ return { name: repo.name, available: false, reason: "không phải git repo", commits: [] };
47
+ }
48
+
49
+ // Mặc định `--all` → gộp commit trên MỌI nhánh (local + remote-tracking + tags),
50
+ // git tự dedupe theo hash. Tắt bằng { allBranches: false } (chỉ HEAD hiện tại).
51
+ const args = ["-C", repo.dir, "log", "--no-merges", "--numstat", "--date=unix", `--pretty=format:${PRETTY_FORMAT}`];
52
+ if (allBranches) args.push("--all");
53
+ const since = toGitDate(range.from);
54
+ const until = toGitDate(range.to);
55
+ if (since) args.push(`--since=${since}`);
56
+ if (until) args.push(`--until=${until}`);
57
+
58
+ let raw = "";
59
+ try {
60
+ raw = execFileSync("git", args, { encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
61
+ } catch (err) {
62
+ return {
63
+ name: repo.name,
64
+ available: false,
65
+ reason: `không đọc được: ${(err.message || "").split("\n")[0]}`,
66
+ commits: [],
67
+ };
68
+ }
69
+
70
+ const commits = parseGitLog(raw).map((c) => ({ ...c, repoName: repo.name }));
71
+ return { name: repo.name, available: true, reason: "", commits };
72
+ }
73
+
74
+ /**
75
+ * Quét toàn bộ repo. Trả { commits, repoStatus } — repoStatus gồm cả repo lỗi.
76
+ */
77
+ function scanAll(repos, range, { log = true, allBranches = true } = {}) {
78
+ const commits = [];
79
+ const repoStatus = [];
80
+ for (const repo of repos) {
81
+ const res = scanRepo(repo, range, { allBranches });
82
+ repoStatus.push({ name: res.name, available: res.available, reason: res.reason, commits: res.commits.length });
83
+ if (log) {
84
+ const tag = res.available ? `${res.commits.length} commit` : `⚠️ ${res.reason}`;
85
+ console.log(` • ${repo.name} ... ${tag}`);
86
+ }
87
+ commits.push(...res.commits);
88
+ }
89
+ return { commits, repoStatus };
90
+ }
91
+
92
+ module.exports = { reposFromWorkspace, discoverGitRepos, scanRepo, scanAll, isGitRepo };
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+
3
+ const http = require("http");
4
+ const { execFile } = require("child_process");
5
+
6
+ /** Serve HTML tại localhost và (tuỳ chọn) mở trình duyệt. */
7
+ function serveHtml(html, { port = 4321, open = true } = {}) {
8
+ const server = http.createServer((req, res) => {
9
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
10
+ res.end(html);
11
+ });
12
+ server.listen(port, () => {
13
+ const url = `http://localhost:${port}`;
14
+ console.log(`\n✅ Báo cáo đang chạy tại ${url} (Ctrl+C để dừng)`);
15
+ if (open) openBrowser(url);
16
+ });
17
+ return server;
18
+ }
19
+
20
+ function openBrowser(url) {
21
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
22
+ execFile(cmd, [url], () => {});
23
+ }
24
+
25
+ module.exports = { serveHtml };
package/lib/ui/api.js ADDED
@@ -0,0 +1,202 @@
1
+ // lib/ui/api.js — HTTP client cho Pan Studio backend (device-flow auth + project/file/export).
2
+ // Dùng Node18 `fetch` global (không thêm dep như axios) — xem docs §3, §4.2.
3
+ // Chỉ ĐỊNH NGHĨA hàm gọi endpoint theo solution §3/§5; backend device-flow/guard đang xây
4
+ // song song nên một số endpoint có thể chưa tồn tại/chưa test được (honest-TODO cuối file).
5
+
6
+ /**
7
+ * @typedef {{ apiUrl: string, token?: string }} ApiClientOptions
8
+ */
9
+
10
+ /**
11
+ * Lỗi HTTP có kèm status + body đã parse (nếu là JSON) để caller phân loại.
12
+ */
13
+ class ApiError extends Error {
14
+ constructor(message, { status, body } = {}) {
15
+ super(message);
16
+ this.name = "ApiError";
17
+ this.status = status;
18
+ this.body = body;
19
+ }
20
+ }
21
+
22
+ function messageForStatus(status, fallback) {
23
+ if (status === 401) {
24
+ return "Chưa đăng nhập hoặc key đã hết hạn — chạy `pan ui init` để đăng nhập lại.";
25
+ }
26
+ if (status === 403) {
27
+ return "Không đủ quyền truy cập tài nguyên này (kiểm tra scope key hoặc quyền dự án).";
28
+ }
29
+ if (status === 404) {
30
+ return "Không tìm thấy tài nguyên (404) — kiểm tra lại id/đường dẫn.";
31
+ }
32
+ if (status >= 500) {
33
+ return `Lỗi máy chủ Pan Studio (${status}) — thử lại sau.`;
34
+ }
35
+ return fallback;
36
+ }
37
+
38
+ /**
39
+ * @param {string} base - `<apiUrl>/api/v1`
40
+ * @param {string} pathName
41
+ * @param {{ method?: string, token?: string, body?: any, headers?: Record<string,string>, raw?: boolean }} [opts]
42
+ */
43
+ async function request(base, pathName, opts = {}) {
44
+ const { method = "GET", token, body, headers = {}, raw = false } = opts;
45
+ const url = `${base}${pathName}`;
46
+ const finalHeaders = { Accept: "application/json", ...headers };
47
+ if (token) {
48
+ finalHeaders.Authorization = `Bearer ${token}`;
49
+ }
50
+ let payload;
51
+ if (body !== undefined && !raw) {
52
+ finalHeaders["Content-Type"] = "application/json";
53
+ payload = JSON.stringify(body);
54
+ } else if (raw) {
55
+ payload = body;
56
+ }
57
+
58
+ let res;
59
+ try {
60
+ res = await fetch(url, { method, headers: finalHeaders, body: payload });
61
+ } catch (err) {
62
+ throw new ApiError(
63
+ `Không kết nối được Pan Studio API (${url}): ${err.message}`,
64
+ { status: 0 }
65
+ );
66
+ }
67
+
68
+ const contentType = res.headers.get("content-type") || "";
69
+ const isJson = contentType.includes("application/json");
70
+ const text = await res.text();
71
+ const parsed = isJson && text ? safeJsonParse(text) : text;
72
+
73
+ if (!res.ok) {
74
+ const fallback =
75
+ (parsed && typeof parsed === "object" && (parsed.message || parsed.error)) ||
76
+ `HTTP ${res.status} ${res.statusText}`;
77
+ throw new ApiError(messageForStatus(res.status, fallback), {
78
+ status: res.status,
79
+ body: parsed,
80
+ });
81
+ }
82
+
83
+ return parsed;
84
+ }
85
+
86
+ function safeJsonParse(text) {
87
+ try {
88
+ return JSON.parse(text);
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * @param {ApiClientOptions} options
96
+ */
97
+ function createApiClient(options) {
98
+ const apiUrl = String(options.apiUrl || "").replace(/\/+$/, "");
99
+ const base = `${apiUrl}/api/v1`;
100
+ const token = options.token;
101
+
102
+ const call = (pathName, opts) => request(base, pathName, { token, ...opts });
103
+
104
+ return {
105
+ /** POST /auth/device/start — { clientName, scope, expiresInDays } */
106
+ deviceStart(opts = {}) {
107
+ return call("/auth/device/start", { method: "POST", body: opts });
108
+ },
109
+
110
+ /** POST /auth/device/poll — { deviceCode } */
111
+ devicePoll(deviceCode) {
112
+ return call("/auth/device/poll", { method: "POST", body: { deviceCode } });
113
+ },
114
+
115
+ /** GET /projects */
116
+ listProjects() {
117
+ return call("/projects");
118
+ },
119
+
120
+ /** GET /projects/:id */
121
+ getProject(id) {
122
+ return call(`/projects/${encodeURIComponent(id)}`);
123
+ },
124
+
125
+ /** GET /projects/:id/files */
126
+ listFiles(id) {
127
+ return call(`/projects/${encodeURIComponent(id)}/files`);
128
+ },
129
+
130
+ /** GET /projects/:id/files/content?path= */
131
+ getContent(id, filePath) {
132
+ const qs = new URLSearchParams({ path: filePath }).toString();
133
+ return call(`/projects/${encodeURIComponent(id)}/files/content?${qs}`);
134
+ },
135
+
136
+ /** PUT /projects/:id/files — { path, content, editNote } */
137
+ putFile(id, { path: filePath, content, editNote }) {
138
+ return call(`/projects/${encodeURIComponent(id)}/files`, {
139
+ method: "PUT",
140
+ body: { path: filePath, content, editNote },
141
+ });
142
+ },
143
+
144
+ /** DELETE /projects/:id/files?path= */
145
+ deleteFile(id, filePath) {
146
+ const qs = new URLSearchParams({ path: filePath }).toString();
147
+ return call(`/projects/${encodeURIComponent(id)}/files?${qs}`, {
148
+ method: "DELETE",
149
+ });
150
+ },
151
+
152
+ /** POST /projects/:id/export/html */
153
+ exportHtml(id) {
154
+ return call(`/projects/${encodeURIComponent(id)}/export/html`, {
155
+ method: "POST",
156
+ });
157
+ },
158
+
159
+ /** POST /projects/:id/export/react */
160
+ exportReact(id) {
161
+ return call(`/projects/${encodeURIComponent(id)}/export/react`, {
162
+ method: "POST",
163
+ });
164
+ },
165
+
166
+ /** GET /projects/:id/export/versions — poll trạng thái build */
167
+ pollExport(id) {
168
+ return call(`/projects/${encodeURIComponent(id)}/export/versions`);
169
+ },
170
+
171
+ /**
172
+ * Tải file nhị phân (vd export/versions/:vid/download) — trả Buffer.
173
+ * `url` có thể là path tương đối (nối base) hoặc URL tuyệt đối.
174
+ */
175
+ async download(downloadUrl) {
176
+ const target = /^https?:\/\//i.test(downloadUrl)
177
+ ? downloadUrl
178
+ : `${base}${downloadUrl}`;
179
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
180
+ let res;
181
+ try {
182
+ res = await fetch(target, { headers });
183
+ } catch (err) {
184
+ throw new ApiError(`Không tải được file (${target}): ${err.message}`, {
185
+ status: 0,
186
+ });
187
+ }
188
+ if (!res.ok) {
189
+ throw new ApiError(messageForStatus(res.status, `HTTP ${res.status}`), {
190
+ status: res.status,
191
+ });
192
+ }
193
+ const arrayBuffer = await res.arrayBuffer();
194
+ return Buffer.from(arrayBuffer);
195
+ },
196
+ };
197
+ }
198
+
199
+ module.exports = {
200
+ ApiError,
201
+ createApiClient,
202
+ };
@@ -0,0 +1,115 @@
1
+ // lib/ui/clone.js — chọn dự án trên Pan Studio, tải cây file về local, ghi backend.id
2
+ // vào app.panui.yaml (§4.4). Không tự xóa thư mục đích nếu đã tồn tại.
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { createApiClient } = require("./api");
6
+ const { getApiConfig, loadCredential, isExpired } = require("./config");
7
+ const { findWorkspaceRoot, writeBackendId } = require("./workspace");
8
+ const { menuSelect } = require("../menu-prompt");
9
+ const { t } = require("../i18n");
10
+
11
+ async function loadInquirer() {
12
+ const inq = await import("@inquirer/prompts").catch(() => null);
13
+ if (!inq) {
14
+ throw new Error("Cần TTY (@inquirer/prompts) cho lệnh này.");
15
+ }
16
+ return inq;
17
+ }
18
+
19
+ function toList(res) {
20
+ if (Array.isArray(res)) return res;
21
+ if (res && Array.isArray(res.items)) return res.items;
22
+ if (res && Array.isArray(res.data)) return res.data;
23
+ return [];
24
+ }
25
+
26
+ async function run(_args) {
27
+ const workspaceRoot = findWorkspaceRoot() || process.cwd();
28
+ const { apiUrl } = getApiConfig(workspaceRoot);
29
+ const cred = loadCredential(apiUrl);
30
+ if (!cred || isExpired(cred)) {
31
+ throw new Error("Chưa đăng nhập hoặc key đã hết hạn — chạy `pan ui init` trước.");
32
+ }
33
+ const api = createApiClient({ apiUrl, token: cred.token });
34
+
35
+ const projectsRes = await api.listProjects();
36
+ const list = toList(projectsRes);
37
+ if (!list.length) {
38
+ console.log("Chưa có dự án nào trên Pan Studio — tạo dự án trước khi clone.");
39
+ return;
40
+ }
41
+
42
+ const message = "Chọn dự án để clone:";
43
+ const choices = list.map((p) => ({
44
+ name: `${p.name || p.id} (${p.id})`,
45
+ value: p.id,
46
+ }));
47
+ const hotkeys = [{ key: "x", label: t("EXIT_LABEL"), value: "__exit__" }];
48
+
49
+ let projectId;
50
+ try {
51
+ projectId = await menuSelect({ message, choices, hotkeys });
52
+ } catch (err) {
53
+ if (String(err && err.message) !== "no-core") throw err;
54
+ const inq = await loadInquirer();
55
+ projectId = await inq.select({
56
+ message,
57
+ choices: [...choices, new inq.Separator(), { name: `x ${t("EXIT_LABEL")}`, value: "__exit__" }],
58
+ loop: false,
59
+ });
60
+ }
61
+ if (projectId === "__exit__") {
62
+ console.log(t("EXITED"));
63
+ process.exit(0);
64
+ }
65
+ const project = list.find((p) => p.id === projectId) || { id: projectId };
66
+
67
+ // BUGFIX (2026-07-23): `inq` chỉ được gán trong nhánh catch (fallback khi menuSelect lỗi
68
+ // "no-core") — khi menuSelect (hệ thống menu mới) chạy THÀNH CÔNG, `inq` chưa từng tồn tại
69
+ // → "inq is not defined" ở bước nhập thư mục đích. Tự nạp riêng, không phụ thuộc nhánh trên.
70
+ const inqInput = await loadInquirer();
71
+ const targetInput = await inqInput.input({
72
+ message: "Thư mục đích để clone (vd: pan-ui, ./design/landing, /abs/path):",
73
+ default: "pan-ui",
74
+ });
75
+ const targetDir = path.isAbsolute(targetInput)
76
+ ? targetInput
77
+ : path.resolve(process.cwd(), targetInput);
78
+
79
+ if (fs.existsSync(targetDir)) {
80
+ throw new Error(`Thư mục đã tồn tại: ${targetDir} — xóa thủ công rồi thử lại.`);
81
+ }
82
+
83
+ console.log(`Tải dự án '${project.name || project.id}' về ${targetDir} ...`);
84
+ const treeRes = await api.listFiles(projectId);
85
+ const files = toList(treeRes);
86
+
87
+ fs.mkdirSync(targetDir, { recursive: true });
88
+ let count = 0;
89
+ for (const f of files) {
90
+ if (f.fileType === "directory" || f.fileType === "dir") {
91
+ continue;
92
+ }
93
+ const filePath = f.filePath || f.path;
94
+ if (!filePath) continue;
95
+ const contentRes = await api.getContent(projectId, filePath);
96
+ const content =
97
+ typeof contentRes === "string" ? contentRes : contentRes && contentRes.content;
98
+ const dest = path.join(targetDir, filePath);
99
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
100
+ fs.writeFileSync(dest, content ?? "", "utf8");
101
+ console.log(` + ${filePath}`);
102
+ count += 1;
103
+ }
104
+
105
+ const appYamlPath = path.join(targetDir, "app.panui.yaml");
106
+ if (!fs.existsSync(appYamlPath)) {
107
+ fs.mkdirSync(path.dirname(appYamlPath), { recursive: true });
108
+ fs.writeFileSync(appYamlPath, "", "utf8");
109
+ }
110
+ writeBackendId(appYamlPath, { id: projectId, apiUrl });
111
+
112
+ console.log(`Đã clone ${count} file vào: ${targetDir}`);
113
+ }
114
+
115
+ module.exports = { run };