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,182 @@
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 ADDED
@@ -0,0 +1,128 @@
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 };
@@ -0,0 +1,79 @@
1
+ // lib/ui/export-gate.js — GATE dùng chung cho published-html.js & gen-react-web-code.js
2
+ // (§4.6, OD-U2): chạy diff (như sync), nếu local≠backend thì cảnh báo + hỏi sync trước;
3
+ // đồng thời cung cấp poll trạng thái export/build.
4
+ const sync = require("./sync");
5
+ const { isDiffClean, printDiff } = require("./diff");
6
+
7
+ async function loadInquirer() {
8
+ const inq = await import("@inquirer/prompts").catch(() => null);
9
+ if (!inq) {
10
+ throw new Error("Cần TTY (@inquirer/prompts) cho lệnh này.");
11
+ }
12
+ return inq;
13
+ }
14
+
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
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
+ /**
27
+ * Chọn dự án + đảm bảo local == backend trước khi build (sync nếu user đồng ý).
28
+ * @returns {Promise<{ api, project, backend, projectDir }>}
29
+ */
30
+ async function ensureSynced() {
31
+ const resolved = await sync.resolveProject();
32
+ let state = await sync.diffProject(resolved);
33
+
34
+ if (!isDiffClean(state.diff)) {
35
+ console.log("Local khác backend — cần đồng bộ trước khi build/publish:");
36
+ printDiff(state.diff);
37
+ const inq = await loadInquirer();
38
+ const doSync = await inq.confirm({
39
+ message: "Đồng bộ (sync) ngay bây giờ?",
40
+ default: true,
41
+ });
42
+ if (!doSync) {
43
+ throw new Error("Đã dừng — chạy `pan ui sync` trước khi publish/build.");
44
+ }
45
+ await sync.pushDiff(state);
46
+ state = await sync.diffProject(resolved);
47
+ if (!isDiffClean(state.diff)) {
48
+ throw new Error("Vẫn còn khác biệt sau khi sync — kiểm tra lại thủ công.");
49
+ }
50
+ }
51
+ return state;
52
+ }
53
+
54
+ /**
55
+ * Poll GET /projects/:id/export/versions tới khi phiên bản mới nhất done/failed.
56
+ * @param {*} api
57
+ * @param {string} projectId
58
+ * @param {{ intervalMs?: number, timeoutMs?: number }} [opts]
59
+ */
60
+ async function pollExportDone(api, projectId, opts = {}) {
61
+ const { intervalMs = 3000, timeoutMs = 5 * 60 * 1000 } = opts;
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ const versionsRes = await api.pollExport(projectId);
65
+ const list = toList(versionsRes);
66
+ const latest = list[0];
67
+ if (latest && ["done", "completed", "success"].includes(latest.status)) {
68
+ return latest;
69
+ }
70
+ if (latest && ["failed", "error"].includes(latest.status)) {
71
+ throw new Error(`Build thất bại: ${latest.error || latest.status}`);
72
+ }
73
+ process.stdout.write(".");
74
+ await sleep(intervalMs);
75
+ }
76
+ throw new Error("Hết thời gian chờ build (timeout).");
77
+ }
78
+
79
+ module.exports = { ensureSynced, pollExportDone };
@@ -0,0 +1,6 @@
1
+ // lib/ui/gen-react-mobile-code.js — STUB. Chưa có kế hoạch (chờ chốt) — chỉ báo "Đang phát triển".
2
+ async function run(_args) {
3
+ console.log("Đang phát triển");
4
+ }
5
+
6
+ module.exports = { run };
@@ -0,0 +1,75 @@
1
+ // lib/ui/gen-react-web-code.js — GATE sync (§4.6, OD-U2) rồi POST /projects/:id/export/react,
2
+ // poll, download .tar.gz (api.js download), giải nén ra thư mục đích (mặc định
3
+ // ./pan-ui-react, lỗi nếu đã tồn tại). Giải nén dùng lệnh `tar` hệ thống qua child_process
4
+ // (không thêm dep nén) — nếu không có `tar`, lưu file .tar.gz + in hướng dẫn (honest-TODO).
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { execFileSync } = require("child_process");
8
+ const { ensureSynced, pollExportDone } = require("./export-gate");
9
+
10
+ async function loadInquirer() {
11
+ const inq = await import("@inquirer/prompts").catch(() => null);
12
+ if (!inq) {
13
+ throw new Error("Cần TTY (@inquirer/prompts) cho lệnh này.");
14
+ }
15
+ return inq;
16
+ }
17
+
18
+ function hasTarBinary() {
19
+ try {
20
+ execFileSync("tar", ["--version"], { stdio: "ignore" });
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ async function run(_args) {
28
+ const { api, project, backend } = await ensureSynced();
29
+
30
+ console.log(`Đang build mã React web cho '${project.name || project.id}'...`);
31
+ await api.exportReact(backend.id);
32
+ console.log("Đang chờ build hoàn tất...");
33
+ const version = await pollExportDone(api, backend.id);
34
+
35
+ const inq = await loadInquirer();
36
+ const targetInput = await inq.input({
37
+ message: "Thư mục đích để giải nén mã React (vd: pan-ui-react, ./out/react, /abs/path):",
38
+ default: "pan-ui-react",
39
+ });
40
+ const targetDir = path.isAbsolute(targetInput)
41
+ ? targetInput
42
+ : path.resolve(process.cwd(), targetInput);
43
+
44
+ if (fs.existsSync(targetDir)) {
45
+ throw new Error(`Thư mục đã tồn tại: ${targetDir} — xóa thủ công rồi thử lại.`);
46
+ }
47
+
48
+ const downloadUrl =
49
+ version.downloadUrl ||
50
+ (version.id && `/export/versions/${version.id}/download`);
51
+ if (!downloadUrl) {
52
+ throw new Error(
53
+ "Build xong nhưng không xác định được downloadUrl/version.id — kiểm tra thủ công `GET /projects/:id/export/versions`."
54
+ );
55
+ }
56
+
57
+ console.log("Đang tải file build...");
58
+ const buffer = await api.download(downloadUrl);
59
+ const tarballPath = `${targetDir}.tar.gz`;
60
+ fs.writeFileSync(tarballPath, buffer);
61
+
62
+ if (hasTarBinary()) {
63
+ fs.mkdirSync(targetDir, { recursive: true });
64
+ execFileSync("tar", ["-xzf", tarballPath, "-C", targetDir]);
65
+ fs.unlinkSync(tarballPath);
66
+ console.log(`Đã giải nén mã React vào: ${targetDir}`);
67
+ } else {
68
+ console.log(
69
+ `[honest-TODO] Không tìm thấy lệnh 'tar' trên hệ thống — đã lưu file nén tại: ${tarballPath}`
70
+ );
71
+ console.log(`Giải nén thủ công: tar -xzf ${tarballPath} -C <thư mục đích>`);
72
+ }
73
+ }
74
+
75
+ module.exports = { run };
@@ -0,0 +1,89 @@
1
+ // lib/ui/index.js — router + menu cho `pan ui` (§4.1). Subcommand thật (Wave 2) sống trong
2
+ // lib/ui/<name>.js; ở Phase 2 mọi file đó CHỈ là stub.
3
+ const { menuSelect } = require("../menu-prompt");
4
+ const { t } = require("../i18n");
5
+
6
+ const SUBCOMMANDS = [
7
+ { name: "init", label: "init — tạo dự án panui mẫu (chọn loại + bảng màu)" },
8
+ { name: "login", label: "login — đăng nhập Pan Studio (mở trình duyệt, device-flow)" },
9
+ { name: "logout", label: "logout — đăng xuất, xóa key local" },
10
+ { name: "clone", label: "clone — tải dự án panui về local" },
11
+ { name: "sync", label: "sync — đẩy thay đổi local lên backend" },
12
+ { name: "published-html", label: "published-html — build & publish trang HTML" },
13
+ { name: "gen-react-web-code", label: "gen-react-web-code — sinh mã React web" },
14
+ { name: "gen-react-mobile-code", label: "gen-react-mobile-code — sinh mã React mobile" },
15
+ ];
16
+
17
+ const ALIASES = {};
18
+
19
+ const SUBCOMMAND_NAMES = new Set(SUBCOMMANDS.map((s) => s.name));
20
+
21
+ function requireHandler(name) {
22
+ // eslint-disable-next-line global-require, import/no-dynamic-require
23
+ return require(`./${name}`);
24
+ }
25
+
26
+ async function runSubcommand(name, args) {
27
+ const handler = requireHandler(name);
28
+ await handler.run(args);
29
+ }
30
+
31
+ async function promptMenu() {
32
+ const message = "pan ui — chọn hành động:";
33
+ const choices = SUBCOMMANDS.map((s) => ({ name: s.label, value: s.name }));
34
+ const hotkeys = [{ key: "x", label: t("EXIT_LABEL"), value: "exit" }];
35
+
36
+ // Menu gốc (không có cấp cha) — chỉ cần phím x thoát; nhất quán với các menu khác trong CLI
37
+ // (menuSelect ưu tiên; thiếu @inquirer/core → @inquirer/prompts select + item thoát ẩn).
38
+ try {
39
+ return await menuSelect({ message, choices, hotkeys });
40
+ } catch (err) {
41
+ if (String(err && err.message) !== "no-core") throw err;
42
+ }
43
+
44
+ const inq = await import("@inquirer/prompts").catch(() => null);
45
+ if (!inq || typeof inq.select !== "function") {
46
+ throw new Error(
47
+ "Không có TTY / @inquirer/prompts để hiện menu — chạy trực tiếp: pan ui <subcommand>."
48
+ );
49
+ }
50
+ return inq.select({
51
+ message,
52
+ choices: [
53
+ ...choices,
54
+ new inq.Separator(),
55
+ { name: `x ${t("EXIT_LABEL")}`, value: "exit" },
56
+ ],
57
+ loop: false,
58
+ });
59
+ }
60
+
61
+ /**
62
+ * @param {string[]} args - vd ["clone", "--foo"] hoặc [] (menu tương tác)
63
+ */
64
+ async function runUi(args) {
65
+ const [rawSub, ...rest] = args;
66
+ const maybeSub = rawSub && ALIASES[rawSub] ? ALIASES[rawSub] : rawSub;
67
+
68
+ if (maybeSub && SUBCOMMAND_NAMES.has(maybeSub)) {
69
+ await runSubcommand(maybeSub, rest);
70
+ return;
71
+ }
72
+
73
+ if (maybeSub) {
74
+ throw new Error(
75
+ `pan ui: subcommand không hợp lệ '${maybeSub}'. Các subcommand hợp lệ: ${[...SUBCOMMAND_NAMES].join(", ")}`
76
+ );
77
+ }
78
+
79
+ const choice = await promptMenu();
80
+ if (choice === "exit") {
81
+ return;
82
+ }
83
+ await runSubcommand(choice, []);
84
+ }
85
+
86
+ module.exports = {
87
+ SUBCOMMANDS,
88
+ runUi,
89
+ };