pan-stack-cli 0.2.1 → 0.2.2

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 (59) hide show
  1. package/README.md +37 -731
  2. package/bin/pan.js +4955 -269
  3. package/package.json +17 -5
  4. package/lib/ai-skills.js +0 -829
  5. package/lib/create.js +0 -105
  6. package/lib/discover.js +0 -294
  7. package/lib/git/git-checkout.js +0 -395
  8. package/lib/git/git-clone.js +0 -206
  9. package/lib/git/git-fetch-all.js +0 -129
  10. package/lib/git/git-grv.js +0 -125
  11. package/lib/git/git-gst.js +0 -165
  12. package/lib/git/git-mr.js +0 -546
  13. package/lib/git/git-pull.js +0 -137
  14. package/lib/git/git-push.js +0 -185
  15. package/lib/git/git-sync-main.js +0 -334
  16. package/lib/git/helpers/bulk-report.js +0 -202
  17. package/lib/git/helpers/cli.js +0 -188
  18. package/lib/git/helpers/colors.js +0 -19
  19. package/lib/git/helpers/git.js +0 -136
  20. package/lib/git/helpers/manifest.js +0 -223
  21. package/lib/git/helpers/repo-select.js +0 -416
  22. package/lib/git/helpers/repo-summary.js +0 -161
  23. package/lib/i18n.js +0 -61
  24. package/lib/inquirer-theme.js +0 -20
  25. package/lib/language.js +0 -121
  26. package/lib/languages.js +0 -14
  27. package/lib/manifest.js +0 -467
  28. package/lib/menu-prompt.js +0 -201
  29. package/lib/menu.js +0 -134
  30. package/lib/messages.js +0 -647
  31. package/lib/platform.js +0 -140
  32. package/lib/run-command.js +0 -334
  33. package/lib/select.js +0 -0
  34. package/lib/serve.js +0 -75
  35. package/lib/skill-registry-client.js +0 -173
  36. package/lib/stats/aggregate.js +0 -51
  37. package/lib/stats/identity.js +0 -20
  38. package/lib/stats/index.js +0 -189
  39. package/lib/stats/parse.js +0 -46
  40. package/lib/stats/range.js +0 -38
  41. package/lib/stats/report.js +0 -264
  42. package/lib/stats/review.js +0 -157
  43. package/lib/stats/scan.js +0 -92
  44. package/lib/stats/serve.js +0 -25
  45. package/lib/ui/api.js +0 -202
  46. package/lib/ui/clone.js +0 -115
  47. package/lib/ui/config.js +0 -182
  48. package/lib/ui/diff.js +0 -128
  49. package/lib/ui/export-gate.js +0 -79
  50. package/lib/ui/gen-react-mobile-code.js +0 -6
  51. package/lib/ui/gen-react-web-code.js +0 -75
  52. package/lib/ui/index.js +0 -89
  53. package/lib/ui/init.js +0 -252
  54. package/lib/ui/login.js +0 -128
  55. package/lib/ui/logout.js +0 -22
  56. package/lib/ui/palettes.js +0 -103
  57. package/lib/ui/published-html.js +0 -24
  58. package/lib/ui/sync.js +0 -137
  59. package/lib/ui/workspace.js +0 -213
package/lib/ui/api.js DELETED
@@ -1,202 +0,0 @@
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
- };
package/lib/ui/clone.js DELETED
@@ -1,115 +0,0 @@
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 };
package/lib/ui/config.js DELETED
@@ -1,182 +0,0 @@
1
- // lib/ui/config.js — cấu hình `pan ui`: API/WEB URL (.pan-env-build.yaml) + credential store
2
- // (~/.pan/credentials.json). Không phụ thuộc lib khác ngoài fs/path/os (giữ nhẹ, dễ test).
3
- const fs = require("fs");
4
- const path = require("path");
5
- const os = require("os");
6
-
7
- const CREDENTIALS_DIR = path.join(os.homedir(), ".pan");
8
- const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
9
-
10
- // Fallback mặc định khi KHÔNG set env / KHÔNG khai trong .pan-env-build.yaml — trỏ về Pan Studio
11
- // backend dev trên OpenShift, để `pan ui` dùng được ngay không cần config gì thêm. Ưu tiên (cao
12
- // → thấp): process.env > .pan-env-build.yaml > default này.
13
- //
14
- // URL được XOR obfuscate (KHÔNG phải bảo mật thật — key nằm ngay trong file) — chỉ để tránh lộ
15
- // domain trần khi grep/quét source code tìm URL literal. Cần đổi URL mặc định? Sửa `_DEFAULT_URL`
16
- // (chuỗi thường) rồi chạy `node -e` bên dưới để sinh lại mảng byte, KHÔNG gõ tay mảng byte.
17
- // node -e "const u='https://...';const K=0x2A;console.log(JSON.stringify([...u].map(c=>c.charCodeAt(0)^K)))"
18
- const _OBFUSCATE_KEY = 0x2a;
19
- const _DEFAULT_URL_BYTES = [
20
- 66, 94, 94, 90, 89, 16, 5, 5, 90, 75, 68, 7, 89, 94, 95, 78, 67, 69, 7, 75, 90, 67, 4, 78, 79, 92,
21
- 4, 73, 70, 95, 89, 94, 79, 88, 26, 24, 4, 76, 67, 89, 7, 73, 70, 69, 95, 78, 4, 82, 90, 70, 75, 94,
22
- 4, 69, 68, 70, 67, 68, 79,
23
- ];
24
- function _deobfuscate(bytes, key) {
25
- return bytes.map((b) => String.fromCharCode(b ^ key)).join("");
26
- }
27
- const DEFAULT_API_URL = _deobfuscate(_DEFAULT_URL_BYTES, _OBFUSCATE_KEY);
28
- const DEFAULT_WEB_URL = DEFAULT_API_URL;
29
-
30
- /**
31
- * Đọc "KEY: value" phẳng từ .pan-env-build.yaml (cùng quy ước với
32
- * lib/git/git-mr.js loadGitlabToken — KHÔNG phải YAML parser đầy đủ).
33
- * @param {string} envFile
34
- * @returns {Record<string,string>}
35
- */
36
- function readEnvFile(envFile) {
37
- const result = {};
38
- if (!fs.existsSync(envFile)) {
39
- return result;
40
- }
41
- const content = fs.readFileSync(envFile, "utf8");
42
- for (const rawLine of content.split(/\r?\n/)) {
43
- let line = rawLine;
44
- const commentIdx = line.indexOf("#");
45
- if (commentIdx >= 0) {
46
- line = line.slice(0, commentIdx);
47
- }
48
- line = line.trim();
49
- if (!line) {
50
- continue;
51
- }
52
- const idx = line.indexOf(":");
53
- if (idx < 0) {
54
- continue;
55
- }
56
- const key = line.slice(0, idx).trim();
57
- let val = line.slice(idx + 1).trim();
58
- if (
59
- (val.startsWith('"') && val.endsWith('"')) ||
60
- (val.startsWith("'") && val.endsWith("'"))
61
- ) {
62
- val = val.slice(1, -1);
63
- }
64
- if (key && val) {
65
- result[key] = val;
66
- }
67
- }
68
- return result;
69
- }
70
-
71
- /**
72
- * Đọc PAN_STUDIO_API_URL / PAN_STUDIO_WEB_URL. Ưu tiên (cao → thấp):
73
- * 1. process.env
74
- * 2. `<workspaceRoot>/.pan-env-build.yaml`
75
- * 3. DEFAULT_API_URL/DEFAULT_WEB_URL (Pan Studio backend dev — dùng được ngay, không cần config)
76
- * WEB_URL riêng thiếu ở cả 3 mức trên thì mặc định = API_URL đã resolve.
77
- * @param {string} workspaceRoot
78
- * @returns {{ apiUrl: string, webUrl: string }}
79
- */
80
- function getApiConfig(workspaceRoot) {
81
- const envFile = path.join(workspaceRoot || process.cwd(), ".pan-env-build.yaml");
82
- const fromFile = readEnvFile(envFile);
83
-
84
- const apiUrl =
85
- process.env.PAN_STUDIO_API_URL || fromFile.PAN_STUDIO_API_URL || DEFAULT_API_URL;
86
- // WEB_URL riêng thiếu → mirror theo apiUrl ĐÃ RESOLVE (không nhảy thẳng DEFAULT_WEB_URL) — vd
87
- // user chỉ set PAN_STUDIO_API_URL=http://localhost:3001 trong .pan-env-build.yaml thì WEB_URL
88
- // phải là localhost:3001 luôn, không phải URL cluster dev mặc định.
89
- const webUrl = process.env.PAN_STUDIO_WEB_URL || fromFile.PAN_STUDIO_WEB_URL || apiUrl;
90
-
91
- return { apiUrl: apiUrl.replace(/\/+$/, ""), webUrl: webUrl.replace(/\/+$/, "") };
92
- }
93
-
94
- function readCredentialsStore() {
95
- if (!fs.existsSync(CREDENTIALS_FILE)) {
96
- return {};
97
- }
98
- try {
99
- const raw = fs.readFileSync(CREDENTIALS_FILE, "utf8");
100
- const parsed = JSON.parse(raw);
101
- return parsed && typeof parsed === "object" ? parsed : {};
102
- } catch {
103
- return {};
104
- }
105
- }
106
-
107
- function writeCredentialsStore(store) {
108
- if (!fs.existsSync(CREDENTIALS_DIR)) {
109
- fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
110
- }
111
- fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(store, null, 2), {
112
- mode: 0o600,
113
- });
114
- try {
115
- fs.chmodSync(CREDENTIALS_FILE, 0o600);
116
- } catch {
117
- // best-effort trên hệ điều hành không hỗ trợ chmod (vd Windows)
118
- }
119
- }
120
-
121
- /**
122
- * @param {string} apiUrl
123
- * @returns {{ token: string, tokenType: string, expiresAt: string, user: object, savedAt: string } | null}
124
- */
125
- function loadCredential(apiUrl) {
126
- const store = readCredentialsStore();
127
- return store[apiUrl] || null;
128
- }
129
-
130
- /**
131
- * @param {string} apiUrl
132
- * @param {{ token: string, tokenType?: string, expiresAt: string, user?: object }} cred
133
- */
134
- function saveCredential(apiUrl, cred) {
135
- const store = readCredentialsStore();
136
- store[apiUrl] = {
137
- token: cred.token,
138
- tokenType: cred.tokenType || "pmcp",
139
- expiresAt: cred.expiresAt,
140
- user: cred.user || null,
141
- savedAt: new Date().toISOString(),
142
- };
143
- writeCredentialsStore(store);
144
- return store[apiUrl];
145
- }
146
-
147
- /**
148
- * @param {string} apiUrl
149
- */
150
- function clearCredential(apiUrl) {
151
- const store = readCredentialsStore();
152
- if (apiUrl in store) {
153
- delete store[apiUrl];
154
- writeCredentialsStore(store);
155
- }
156
- }
157
-
158
- /**
159
- * @param {{ expiresAt?: string } | null | undefined} cred
160
- * @returns {boolean}
161
- */
162
- function isExpired(cred) {
163
- if (!cred || !cred.expiresAt) {
164
- return true;
165
- }
166
- const expiresAt = Date.parse(cred.expiresAt);
167
- if (Number.isNaN(expiresAt)) {
168
- return true;
169
- }
170
- return expiresAt < Date.now();
171
- }
172
-
173
- module.exports = {
174
- CREDENTIALS_FILE,
175
- DEFAULT_API_URL,
176
- DEFAULT_WEB_URL,
177
- getApiConfig,
178
- loadCredential,
179
- saveCredential,
180
- clearCredential,
181
- isExpired,
182
- };
package/lib/ui/diff.js DELETED
@@ -1,128 +0,0 @@
1
- // lib/ui/diff.js — so sánh cây file local ↔ backend, dùng chung cho `sync` (§4.5) và
2
- // GATE của `published-html`/`gen-react-web-code` (§4.6). Không phụ thuộc lib khác ngoài
3
- // fs/path/crypto core.
4
- const fs = require("fs");
5
- const path = require("path");
6
- const crypto = require("crypto");
7
-
8
- const IGNORE_DIRS = new Set(["node_modules", ".git", "dist"]);
9
-
10
- /**
11
- * Quét đệ quy toàn bộ file (không giới hạn cấp — dùng cho 1 dự án panui, không phải
12
- * monorepo scan), bỏ node_modules/.git/dist.
13
- * @param {string} rootDir
14
- * @returns {Array<{ path: string, size: number, full: string }>}
15
- */
16
- function listLocalFiles(rootDir) {
17
- const results = [];
18
- function walk(dir) {
19
- let entries;
20
- try {
21
- entries = fs.readdirSync(dir, { withFileTypes: true });
22
- } catch {
23
- return;
24
- }
25
- for (const entry of entries) {
26
- if (entry.isDirectory()) {
27
- if (IGNORE_DIRS.has(entry.name)) continue;
28
- walk(path.join(dir, entry.name));
29
- continue;
30
- }
31
- if (entry.isFile()) {
32
- const full = path.join(dir, entry.name);
33
- const rel = path.relative(rootDir, full).split(path.sep).join("/");
34
- let stat;
35
- try {
36
- stat = fs.statSync(full);
37
- } catch {
38
- continue;
39
- }
40
- results.push({ path: rel, size: stat.size, full });
41
- }
42
- }
43
- }
44
- walk(rootDir);
45
- return results;
46
- }
47
-
48
- function sha256(content) {
49
- return crypto.createHash("sha256").update(content).digest("hex");
50
- }
51
-
52
- /**
53
- * So khớp local ↔ backend theo path tương đối. Sizes khác → changed ngay (rẻ).
54
- * Sizes bằng nhau (hoặc backend không có size) → nếu có `getBackendContent`, tải nội
55
- * dung backend và so hash để chắc chắn (chậm hơn nhưng chính xác — honest-TODO: gọi
56
- * 1 request/file, có thể chậm với dự án nhiều file bằng size nhau).
57
- * @param {string} localDir
58
- * @param {Array<{filePath?:string, path?:string, fileType?:string, fileSize?:number, size?:number}>} backendFiles
59
- * @param {(relPath:string)=>Promise<string>} [getBackendContent]
60
- * @returns {Promise<{ added: string[], changed: string[], removed: string[], unchanged: string[] }>}
61
- */
62
- async function computeDiff(localDir, backendFiles, getBackendContent) {
63
- const local = listLocalFiles(localDir);
64
- const localByPath = new Map(local.map((f) => [f.path, f]));
65
-
66
- const backendList = (backendFiles || []).filter(
67
- (f) => f.fileType !== "directory" && f.fileType !== "dir"
68
- );
69
- const backendByPath = new Map(
70
- backendList.map((f) => [f.filePath || f.path, f])
71
- );
72
-
73
- const added = [];
74
- const changed = [];
75
- const removed = [];
76
- const unchanged = [];
77
-
78
- for (const [relPath, lf] of localByPath) {
79
- const bf = backendByPath.get(relPath);
80
- if (!bf) {
81
- added.push(relPath);
82
- continue;
83
- }
84
- const backendSize = bf.fileSize ?? bf.size;
85
- if (typeof backendSize === "number" && backendSize !== lf.size) {
86
- changed.push(relPath);
87
- continue;
88
- }
89
- if (getBackendContent) {
90
- try {
91
- const backendContent = await getBackendContent(relPath);
92
- const localContent = fs.readFileSync(lf.full, "utf8");
93
- if (sha256(localContent) !== sha256(backendContent || "")) {
94
- changed.push(relPath);
95
- continue;
96
- }
97
- } catch {
98
- // Không tải được nội dung backend để so — best-effort: coi như không đổi.
99
- }
100
- }
101
- unchanged.push(relPath);
102
- }
103
-
104
- for (const relPath of backendByPath.keys()) {
105
- if (!localByPath.has(relPath)) {
106
- removed.push(relPath);
107
- }
108
- }
109
-
110
- return { added, changed, removed, unchanged };
111
- }
112
-
113
- function isDiffClean(diff) {
114
- return (
115
- diff.added.length === 0 && diff.changed.length === 0 && diff.removed.length === 0
116
- );
117
- }
118
-
119
- function printDiff(diff) {
120
- for (const p of diff.added) console.log(` + ${p}`);
121
- for (const p of diff.changed) console.log(` ~ ${p}`);
122
- for (const p of diff.removed) console.log(` - ${p}`);
123
- console.log(
124
- `Tổng: +${diff.added.length} thêm, ~${diff.changed.length} đổi, -${diff.removed.length} xóa, =${diff.unchanged.length} không đổi.`
125
- );
126
- }
127
-
128
- module.exports = { listLocalFiles, computeDiff, isDiffClean, printDiff };