pan-stack-cli 0.1.4 → 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 (64) hide show
  1. package/README.md +572 -23
  2. package/bin/pan.js +206 -20
  3. package/lib/ai-skills.js +829 -0
  4. package/lib/create.js +22 -4
  5. package/lib/discover.js +69 -2
  6. package/lib/git/git-checkout.js +395 -0
  7. package/lib/git/git-clone.js +163 -28
  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 +334 -0
  15. package/lib/git/helpers/bulk-report.js +202 -0
  16. package/lib/git/helpers/cli.js +41 -5
  17. package/lib/git/helpers/colors.js +19 -0
  18. package/lib/git/helpers/git.js +74 -0
  19. package/lib/git/helpers/manifest.js +93 -0
  20. package/lib/git/helpers/repo-select.js +416 -0
  21. package/lib/git/helpers/repo-summary.js +161 -0
  22. package/lib/i18n.js +61 -0
  23. package/lib/inquirer-theme.js +20 -0
  24. package/lib/language.js +121 -0
  25. package/lib/languages.js +14 -0
  26. package/lib/manifest.js +115 -14
  27. package/lib/menu-prompt.js +201 -0
  28. package/lib/menu.js +134 -0
  29. package/lib/messages.js +647 -0
  30. package/lib/run-command.js +173 -52
  31. package/lib/select.js +0 -0
  32. package/lib/serve.js +75 -0
  33. package/lib/skill-registry-client.js +173 -0
  34. package/lib/stats/aggregate.js +51 -0
  35. package/lib/stats/identity.js +20 -0
  36. package/lib/stats/index.js +189 -0
  37. package/lib/stats/parse.js +46 -0
  38. package/lib/stats/range.js +38 -0
  39. package/lib/stats/report.js +264 -0
  40. package/lib/stats/review.js +157 -0
  41. package/lib/stats/scan.js +92 -0
  42. package/lib/stats/serve.js +25 -0
  43. package/lib/ui/api.js +202 -0
  44. package/lib/ui/clone.js +115 -0
  45. package/lib/ui/config.js +182 -0
  46. package/lib/ui/diff.js +128 -0
  47. package/lib/ui/export-gate.js +79 -0
  48. package/lib/ui/gen-react-mobile-code.js +6 -0
  49. package/lib/ui/gen-react-web-code.js +75 -0
  50. package/lib/ui/index.js +89 -0
  51. package/lib/ui/init.js +252 -0
  52. package/lib/ui/login.js +128 -0
  53. package/lib/ui/logout.js +22 -0
  54. package/lib/ui/palettes.js +103 -0
  55. package/lib/ui/published-html.js +24 -0
  56. package/lib/ui/sync.js +137 -0
  57. package/lib/ui/workspace.js +213 -0
  58. package/package.json +10 -3
  59. package/templates/workspace/.claude/skills/writing-guidelines/SKILL.md +0 -48
  60. package/templates/workspace/.codex/skills/writing-guidelines/SKILL.md +0 -48
  61. package/templates/workspace/.cursor/skills/writing-guidelines/SKILL.md +0 -48
  62. package/templates/workspace/.pan.yaml +0 -25
  63. package/templates/workspace/.project.yaml +0 -15
  64. package/templates/workspace/README.md +0 -35
package/lib/ui/init.js ADDED
@@ -0,0 +1,252 @@
1
+ // lib/ui/init.js — `pan ui init`: scaffold dự án panui mẫu.
2
+ // Flow: chọn loại (web landing / mobile / cms) → chọn template → chọn bảng màu →
3
+ // nhập tên/đường dẫn → (tồn tại→lỗi) → copy template + thay placeholder palette/tên.
4
+ // KHÔNG cần auth/backend — thuần local.
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+ const { PALETTES, PALETTE_BY_ID, paletteReplacements } = require("./palettes");
9
+ const { menuSelect } = require("../menu-prompt");
10
+ const { t } = require("../i18n");
11
+ const { getRegistryBaseUrl, fetchRegistry, downloadAndExtract } = require("../skill-registry-client");
12
+
13
+ // Template pan-ui (landing-glass, mobile-app, ...) không còn bundle local — đăng ký trên registry
14
+ // server với name prefix "pan-ui-" (xem apps/pan-agent-skill-registry/registry/templates/pan-ui/).
15
+
16
+ const CATEGORY_LABEL = {
17
+ landing: "Web — Landing page",
18
+ mobile: "Mobile — Ứng dụng điện thoại",
19
+ cms: "CMS — Trang quản trị",
20
+ };
21
+ const CATEGORY_ORDER = ["landing", "mobile", "cms"];
22
+
23
+ // Design System khai ở cấp app (field `designSystem` trong app.panui.yaml). shadcn = mặc định
24
+ // (đứng đầu danh sách), antd = lựa chọn thứ hai. Thiếu field → renderer coi như shadcn.
25
+ const DESIGN_SYSTEMS = [
26
+ { id: "shadcn", label: "shadcn ui (Recommended)" },
27
+ { id: "antd", label: "antd" },
28
+ ];
29
+
30
+ const TEXT_EXT = new Set([".yaml", ".yml", ".html", ".htm", ".md", ".json", ".txt", ".css"]);
31
+
32
+ /**
33
+ * Lấy danh sách template pan-ui từ registry server (KHÔNG tải zip — chỉ đọc metadata
34
+ * `extra.{category,label,platform}` + `description` để dựng picker). Cần server đang chạy
35
+ * (PAN_SKILL_REGISTRY_URL, mặc định localhost:4600) — xem apps/pan-agent-skill-registry.
36
+ * @returns {Promise<Array<{id, category, label, description, platform, item}>>}
37
+ */
38
+ async function discoverTemplates() {
39
+ const baseUrl = getRegistryBaseUrl();
40
+ const items = await fetchRegistry(baseUrl);
41
+ return items
42
+ .filter((i) => i.type === "template" && i.name.startsWith("pan-ui-"))
43
+ .map((item) => ({
44
+ id: item.name.slice("pan-ui-".length),
45
+ category: (item.extra && item.extra.category) || "",
46
+ label: (item.extra && item.extra.label) || item.name,
47
+ description: item.description || "",
48
+ platform: item.extra && item.extra.platform,
49
+ item, // giữ nguyên để downloadAndExtract sau khi user chọn xong (tải NGAY 1 cái, không tải hết)
50
+ }));
51
+ }
52
+
53
+ /** Copy đệ quy src→dest; file text thì thay placeholder theo `replacements`. */
54
+ function copyWithReplace(src, dest, replacements) {
55
+ const stat = fs.statSync(src);
56
+ if (stat.isDirectory()) {
57
+ fs.mkdirSync(dest, { recursive: true });
58
+ for (const name of fs.readdirSync(src)) {
59
+ if (name === "template.json") continue; // manifest không copy vào dự án
60
+ copyWithReplace(path.join(src, name), path.join(dest, name), replacements);
61
+ }
62
+ return;
63
+ }
64
+ const ext = path.extname(src).toLowerCase();
65
+ if (TEXT_EXT.has(ext)) {
66
+ let content = fs.readFileSync(src, "utf8");
67
+ for (const [token, value] of Object.entries(replacements)) {
68
+ content = content.split(token).join(value);
69
+ }
70
+ fs.writeFileSync(dest, content);
71
+ } else {
72
+ fs.copyFileSync(src, dest);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Ghi field `designSystem: <ds>` cấp app vào app.panui.yaml của dự án vừa sinh.
78
+ * Nếu file đã có dòng `designSystem:` → thay giá trị; chưa có → chèn ngay sau dòng `app:`.
79
+ * Không tìm thấy file/anchor → bỏ qua im lặng (renderer mặc định shadcn).
80
+ */
81
+ function applyDesignSystem(targetDir, ds) {
82
+ const file = path.join(targetDir, "app.panui.yaml");
83
+ if (!fs.existsSync(file)) return;
84
+ const content = fs.readFileSync(file, "utf8");
85
+ const lines = content.split("\n");
86
+ const dsIdx = lines.findIndex((l) => /^designSystem\s*:/.test(l));
87
+ if (dsIdx >= 0) {
88
+ lines[dsIdx] = `designSystem: ${ds}`;
89
+ } else {
90
+ const appIdx = lines.findIndex((l) => /^app\s*:/.test(l));
91
+ const at = appIdx >= 0 ? appIdx + 1 : 1; // sau `app:`, hoặc sau `panui:` nếu không có `app:`
92
+ lines.splice(at, 0, `designSystem: ${ds}`);
93
+ }
94
+ fs.writeFileSync(file, lines.join("\n"));
95
+ }
96
+
97
+ /** Chuẩn hóa tên nhập → { targetDir (tuyệt đối), projectName }. Cho tên đơn (pan-ui) hoặc path. */
98
+ function resolveTarget(input) {
99
+ const raw = String(input || "").trim();
100
+ const value = raw || "pan-ui";
101
+ const targetDir = path.resolve(process.cwd(), value);
102
+ const projectName = path.basename(targetDir);
103
+ return { targetDir, projectName };
104
+ }
105
+
106
+ // Chọn 1 item; ưu tiên menu-prompt (hotkey b/x nhấn-1-phím) → @inquirer/prompts select với
107
+ // item b/x ẩn ở cuối (không hotkey thật, vẫn chọn được bằng Enter) → lỗi nếu không có gì.
108
+ async function pick(inq, { message, choices, hotkeys = [] }) {
109
+ try {
110
+ return await menuSelect({ message, choices, hotkeys });
111
+ } catch (err) {
112
+ if (String(err && err.message) !== "no-core") throw err;
113
+ if (!inq || typeof inq.select !== "function") {
114
+ throw new Error("Cần terminal tương tác (@inquirer/prompts) để chạy `pan ui init`.");
115
+ }
116
+ const extra = hotkeys.map((h) => ({ name: `${h.key} ${h.label}`, value: h.value }));
117
+ const list = extra.length ? [...choices, { name: "──────────", disabled: true }, ...extra] : choices;
118
+ return inq.select({ message, choices: list, loop: false });
119
+ }
120
+ }
121
+
122
+ async function run(_args) {
123
+ const inq = await import("@inquirer/prompts").catch(() => null);
124
+
125
+ const baseUrl = getRegistryBaseUrl();
126
+ const templates = await discoverTemplates();
127
+ if (templates.length === 0) {
128
+ throw new Error(
129
+ `Không tìm thấy template pan-ui nào trên registry server (${baseUrl}). Kiểm tra server đang chạy và apps/pan-agent-skill-registry/registry/templates/pan-ui/ đã build chưa.`
130
+ );
131
+ }
132
+
133
+ const categories = CATEGORY_ORDER.filter((c) => templates.some((tpl) => tpl.category === c));
134
+
135
+ let designSystem = null;
136
+ let category = null;
137
+ let chosen = null;
138
+ let palette = null;
139
+
140
+ // Vòng lặp 4 cấp (Design System → loại → template → bảng màu); 'b' quay lại cấp trước, 'x' thoát hẳn.
141
+ designSystemLoop: for (;;) {
142
+ // 0) Design System — bước ĐẦU TIÊN. shadcn (Recommended) mặc định đứng đầu, antd thứ hai.
143
+ designSystem = await pick(inq, {
144
+ message: "Chọn Design System:",
145
+ choices: DESIGN_SYSTEMS.map((d) => ({ name: d.label, value: d.id })),
146
+ hotkeys: [{ key: "x", label: t("EXIT_LABEL"), value: "__exit__" }],
147
+ });
148
+ if (designSystem === "__exit__") {
149
+ console.log(t("EXITED"));
150
+ process.exit(0);
151
+ }
152
+
153
+ categoryLoop: for (;;) {
154
+ category = await pick(inq, {
155
+ message: "Loại dự án:",
156
+ choices: categories.map((c) => ({ name: CATEGORY_LABEL[c] || c, value: c })),
157
+ hotkeys: [
158
+ { key: "b", label: t("BACK_LABEL"), value: "__back__" },
159
+ { key: "x", label: t("EXIT_LABEL"), value: "__exit__" },
160
+ ],
161
+ });
162
+ if (category === "__exit__") {
163
+ console.log(t("EXITED"));
164
+ process.exit(0);
165
+ }
166
+ if (category === "__back__") continue designSystemLoop;
167
+
168
+ const inCat = templates.filter((tpl) => tpl.category === category);
169
+
170
+ templateLoop: for (;;) {
171
+ const template = await pick(inq, {
172
+ message: "Chọn giao diện mẫu:",
173
+ choices: inCat.map((tpl) => ({
174
+ name: `${tpl.label}${tpl.description ? ` — ${tpl.description}` : ""}`,
175
+ value: tpl.id,
176
+ })),
177
+ hotkeys: [
178
+ { key: "b", label: t("BACK_LABEL"), value: "__back__" },
179
+ { key: "x", label: t("EXIT_LABEL"), value: "__exit__" },
180
+ ],
181
+ });
182
+ if (template === "__exit__") {
183
+ console.log(t("EXITED"));
184
+ process.exit(0);
185
+ }
186
+ if (template === "__back__") continue categoryLoop;
187
+ chosen = inCat.find((tpl) => tpl.id === template);
188
+
189
+ for (;;) {
190
+ const paletteId = await pick(inq, {
191
+ message: "Bảng màu (chỉ đổi màu chủ đạo primary/accent...):",
192
+ choices: PALETTES.map((p) => ({ name: p.label, value: p.id })),
193
+ hotkeys: [
194
+ { key: "b", label: t("BACK_LABEL"), value: "__back__" },
195
+ { key: "x", label: t("EXIT_LABEL"), value: "__exit__" },
196
+ ],
197
+ });
198
+ if (paletteId === "__exit__") {
199
+ console.log(t("EXITED"));
200
+ process.exit(0);
201
+ }
202
+ if (paletteId === "__back__") continue templateLoop;
203
+ palette = PALETTE_BY_ID.get(paletteId);
204
+ break designSystemLoop;
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ if (!inq || typeof inq.input !== "function") {
211
+ throw new Error("Cần terminal tương tác (@inquirer/prompts) để chạy `pan ui init`.");
212
+ }
213
+
214
+ // 4) Tên / đường dẫn dự án.
215
+ const nameInput = await inq.input({
216
+ message: "Tên dự án hoặc đường dẫn lưu (vd: pan-ui, ./design/landing, /abs/duan):",
217
+ default: "pan-ui",
218
+ });
219
+ const { targetDir, projectName } = resolveTarget(nameInput);
220
+
221
+ if (fs.existsSync(targetDir)) {
222
+ throw new Error(
223
+ `Thư mục đã tồn tại: ${targetDir}\n→ Xóa thủ công (CLI không tự xóa) rồi chạy lại, hoặc chọn tên khác.`
224
+ );
225
+ }
226
+
227
+ // 5) Tải ĐÚNG 1 template đã chọn (zip) rồi scaffold: copy + thay placeholder (palette + tên dự án).
228
+ const replacements = {
229
+ ...paletteReplacements(palette),
230
+ __PROJECT_NAME__: projectName,
231
+ };
232
+ const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "pan-ui-init-"));
233
+ try {
234
+ const extractedDir = await downloadAndExtract(baseUrl, chosen.item, stagingDir);
235
+ copyWithReplace(extractedDir, targetDir, replacements);
236
+ } finally {
237
+ fs.rmSync(stagingDir, { recursive: true, force: true });
238
+ }
239
+
240
+ // Ghi Design System đã chọn vào app.panui.yaml cấp app.
241
+ applyDesignSystem(targetDir, designSystem);
242
+
243
+ const dsLabel = (DESIGN_SYSTEMS.find((d) => d.id === designSystem) || {}).label || designSystem;
244
+ console.log(`\n✓ Đã tạo dự án "${projectName}" (${dsLabel} · ${CATEGORY_LABEL[category]} · ${chosen.label} · màu ${palette.label.split(" —")[0]})`);
245
+ console.log(` ${targetDir}`);
246
+ console.log("\nBước tiếp theo:");
247
+ console.log(` • Xem thử: pnpm --dir apps/pan-stack-design-system panui:gen ${targetDir} -o /tmp/preview && pan serve /tmp/preview`);
248
+ console.log(" • Sửa nội dung: mở app.panui.yaml + screens/ + components/*.html");
249
+ console.log(" • Đưa lên Pan Studio: pan ui login → (tạo dự án) → pan ui sync");
250
+ }
251
+
252
+ module.exports = { run, discoverTemplates, resolveTarget, applyDesignSystem, DESIGN_SYSTEMS };
@@ -0,0 +1,128 @@
1
+ // lib/ui/login.js — device-flow auth (§4.3, OD-U1): POST /auth/device/start, mở browser
2
+ // verificationUriComplete, poll /auth/device/poll mỗi `interval`s, lưu credential.
3
+ const os = require("os");
4
+ const { spawn } = require("child_process");
5
+ const { createApiClient } = require("./api");
6
+ const { getApiConfig, saveCredential, loadCredential, isExpired } = require("./config");
7
+ const { findWorkspaceRoot } = require("./workspace");
8
+
9
+ /**
10
+ * Mở browser theo OS. Không throw — trả false nếu không mở được (fallback in URL).
11
+ * @param {string} url
12
+ * @returns {Promise<boolean>}
13
+ */
14
+ function openBrowser(url) {
15
+ return new Promise((resolve) => {
16
+ let cmd;
17
+ let args;
18
+ if (process.platform === "darwin") {
19
+ cmd = "open";
20
+ args = [url];
21
+ } else if (process.platform === "win32") {
22
+ cmd = "cmd";
23
+ args = ["/c", "start", '""', url];
24
+ } else {
25
+ cmd = "xdg-open";
26
+ args = [url];
27
+ }
28
+ try {
29
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
30
+ child.on("error", () => resolve(false));
31
+ child.unref();
32
+ resolve(true);
33
+ } catch {
34
+ resolve(false);
35
+ }
36
+ });
37
+ }
38
+
39
+ function sleep(ms) {
40
+ return new Promise((resolve) => setTimeout(resolve, ms));
41
+ }
42
+
43
+ async function run(args = []) {
44
+ const force = args.includes("--force") || args.includes("-f");
45
+
46
+ const workspaceRoot = findWorkspaceRoot() || process.cwd();
47
+ const { apiUrl } = getApiConfig(workspaceRoot);
48
+
49
+ // Đã có key CÒN HẠN cho đúng apiUrl này → báo info + hướng dẫn thay vì âm thầm login lại đè key.
50
+ const existing = loadCredential(apiUrl);
51
+ if (existing && !isExpired(existing) && !force) {
52
+ const email = (existing.user && existing.user.email) || "?";
53
+ const expDate = existing.expiresAt ? new Date(existing.expiresAt).toLocaleString() : "?";
54
+ console.log(`✓ Đã đăng nhập ${email} tại ${apiUrl} (key hết hạn ${expDate}).`);
55
+ console.log("");
56
+ console.log("Muốn đổi tài khoản / đăng nhập lại?");
57
+ console.log(" pan ui logout # đăng xuất trước, rồi 'pan ui login' lại");
58
+ console.log(" pan ui login --force # đăng nhập lại ngay, ghi đè key hiện tại");
59
+ return;
60
+ }
61
+
62
+ const api = createApiClient({ apiUrl });
63
+
64
+ console.log(`Đăng nhập Pan Studio (${apiUrl})...`);
65
+ const start = await api.deviceStart({
66
+ clientName: `pan-cli@${os.hostname()}`,
67
+ scope: "WRITE",
68
+ });
69
+ const { deviceCode, userCode, verificationUriComplete, interval, expiresIn } = start;
70
+
71
+ if (!deviceCode || !verificationUriComplete) {
72
+ throw new Error(
73
+ "Phản hồi /auth/device/start không hợp lệ (thiếu deviceCode/verificationUriComplete) — kiểm tra backend."
74
+ );
75
+ }
76
+
77
+ console.log(`Mã đăng nhập: ${userCode}`);
78
+ console.log(`Mở trình duyệt tại: ${verificationUriComplete}`);
79
+ const opened = await openBrowser(verificationUriComplete);
80
+ if (!opened) {
81
+ console.log(
82
+ "Không tự mở được trình duyệt — hãy dán URL trên vào trình duyệt và làm theo hướng dẫn."
83
+ );
84
+ }
85
+
86
+ const intervalMs = Math.max(1, Number(interval) || 4) * 1000;
87
+ const deadline = Date.now() + Math.max(1, Number(expiresIn) || 600) * 1000;
88
+
89
+ console.log("Đang chờ xác nhận trên trình duyệt...");
90
+ while (Date.now() < deadline) {
91
+ await sleep(intervalMs);
92
+ let poll;
93
+ try {
94
+ poll = await api.devicePoll(deviceCode);
95
+ } catch (err) {
96
+ console.log(`\nLỗi khi kiểm tra trạng thái: ${err.message}`);
97
+ continue;
98
+ }
99
+
100
+ if (poll.status === "approved") {
101
+ if (!poll.token) {
102
+ throw new Error(
103
+ "Trạng thái approved nhưng thiếu token trong phản hồi — không thể lưu credential."
104
+ );
105
+ }
106
+ saveCredential(apiUrl, {
107
+ token: poll.token,
108
+ tokenType: poll.tokenType || "pmcp",
109
+ expiresAt: poll.expiresAt,
110
+ user: poll.user,
111
+ });
112
+ const email = poll.user && poll.user.email;
113
+ const expDate = poll.expiresAt ? new Date(poll.expiresAt).toLocaleString() : "?";
114
+ console.log(`\nĐã đăng nhập ${email || ""} — key hết hạn ${expDate}.`);
115
+ return;
116
+ }
117
+ if (poll.status === "denied") {
118
+ throw new Error("Yêu cầu đăng nhập đã bị từ chối trên trình duyệt.");
119
+ }
120
+ if (poll.status === "expired") {
121
+ throw new Error("Yêu cầu đăng nhập đã hết hạn — chạy lại `pan ui login`.");
122
+ }
123
+ process.stdout.write(".");
124
+ }
125
+ throw new Error("Hết thời gian chờ xác nhận đăng nhập — chạy lại `pan ui login`.");
126
+ }
127
+
128
+ module.exports = { run };
@@ -0,0 +1,22 @@
1
+ // lib/ui/logout.js — xóa credential local (~/.pan/credentials.json) cho API hiện tại.
2
+ const { createApiClient } = require("./api");
3
+ const { getApiConfig, loadCredential, clearCredential } = require("./config");
4
+ const { findWorkspaceRoot } = require("./workspace");
5
+
6
+ async function run(_args) {
7
+ const workspaceRoot = findWorkspaceRoot() || process.cwd();
8
+ const { apiUrl } = getApiConfig(workspaceRoot);
9
+
10
+ const cred = loadCredential(apiUrl);
11
+ if (!cred) {
12
+ console.log(`Chưa đăng nhập Pan Studio (${apiUrl}) — không có gì để đăng xuất.`);
13
+ return;
14
+ }
15
+
16
+ clearCredential(apiUrl);
17
+ const email = cred.user && cred.user.email;
18
+ console.log(`Đã đăng xuất${email ? ` ${email}` : ""} khỏi ${apiUrl}.`);
19
+ console.log("Key local đã xóa. (Token trên backend vẫn còn hạn — thu hồi tại UI/mcp-tokens nếu cần.)");
20
+ }
21
+
22
+ module.exports = { run };
@@ -0,0 +1,103 @@
1
+ // lib/ui/palettes.js — bảng màu cho `pan ui init` scaffold.
2
+ // Mục tiêu: chỉ đổi màu chủ đạo (primary/secondary/accent...) — panui theme + inline HTML
3
+ // của template dùng placeholder __PRIMARY__ / __PRIMARY_DARK__ / ... init thay bằng hex bên dưới.
4
+ //
5
+ // theme app.panui.yaml chỉ nhận primary/sidebar/accent; các sắc còn lại (primaryDark, secondary,
6
+ // primarySoft, onPrimary) dùng cho gradient/nền trong HTML inline (html-ui) của template landing.
7
+
8
+ /** @typedef {{ id, label, primary, primaryDark, secondary, accent, sidebar, primarySoft, onPrimary }} Palette */
9
+
10
+ /** Danh sách placeholder init sẽ thay trong MỌI file template. */
11
+ const PALETTE_TOKENS = [
12
+ "primary",
13
+ "primaryDark",
14
+ "secondary",
15
+ "accent",
16
+ "sidebar",
17
+ "primarySoft",
18
+ "onPrimary",
19
+ ];
20
+
21
+ /** @type {Palette[]} */
22
+ const PALETTES = [
23
+ {
24
+ id: "indigo",
25
+ label: "Indigo — Xanh tím than (mặc định, hiện đại)",
26
+ primary: "#4f46e5",
27
+ primaryDark: "#4338ca",
28
+ secondary: "#0ea5e9",
29
+ accent: "#f59e0b",
30
+ sidebar: "#1e1b4b",
31
+ primarySoft: "#eef2ff",
32
+ onPrimary: "#ffffff",
33
+ },
34
+ {
35
+ id: "emerald",
36
+ label: "Emerald — Xanh lá (tươi, tin cậy)",
37
+ primary: "#059669",
38
+ primaryDark: "#047857",
39
+ secondary: "#14b8a6",
40
+ accent: "#f59e0b",
41
+ sidebar: "#064e3b",
42
+ primarySoft: "#ecfdf5",
43
+ onPrimary: "#ffffff",
44
+ },
45
+ {
46
+ id: "rose",
47
+ label: "Rose — Đỏ hồng (nổi bật, năng lượng)",
48
+ primary: "#e11d48",
49
+ primaryDark: "#be123c",
50
+ secondary: "#f43f5e",
51
+ accent: "#fb7185",
52
+ sidebar: "#4c0519",
53
+ primarySoft: "#fff1f2",
54
+ onPrimary: "#ffffff",
55
+ },
56
+ {
57
+ id: "violet",
58
+ label: "Violet — Tím (sáng tạo, cao cấp)",
59
+ primary: "#7c3aed",
60
+ primaryDark: "#6d28d9",
61
+ secondary: "#a855f7",
62
+ accent: "#ec4899",
63
+ sidebar: "#2e1065",
64
+ primarySoft: "#f5f3ff",
65
+ onPrimary: "#ffffff",
66
+ },
67
+ {
68
+ id: "amber",
69
+ label: "Amber — Cam hổ phách (ấm, thân thiện)",
70
+ primary: "#ea580c",
71
+ primaryDark: "#c2410c",
72
+ secondary: "#f59e0b",
73
+ accent: "#fbbf24",
74
+ sidebar: "#431407",
75
+ primarySoft: "#fff7ed",
76
+ onPrimary: "#ffffff",
77
+ },
78
+ {
79
+ id: "graphite",
80
+ label: "Graphite — Xám than (tối giản, sang trọng)",
81
+ primary: "#0f172a",
82
+ primaryDark: "#020617",
83
+ secondary: "#334155",
84
+ accent: "#38bdf8",
85
+ sidebar: "#020617",
86
+ primarySoft: "#f1f5f9",
87
+ onPrimary: "#ffffff",
88
+ },
89
+ ];
90
+
91
+ const PALETTE_BY_ID = new Map(PALETTES.map((p) => [p.id, p]));
92
+
93
+ /** Bản đồ placeholder `__TOKEN__` → hex cho 1 palette (dùng khi thay thế template). */
94
+ function paletteReplacements(palette) {
95
+ const map = {};
96
+ for (const token of PALETTE_TOKENS) {
97
+ const upper = token.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase();
98
+ map[`__${upper}__`] = palette[token];
99
+ }
100
+ return map; // vd { "__PRIMARY__": "#4f46e5", "__PRIMARY_DARK__": "#4338ca", ... }
101
+ }
102
+
103
+ module.exports = { PALETTES, PALETTE_BY_ID, PALETTE_TOKENS, paletteReplacements };
@@ -0,0 +1,24 @@
1
+ // lib/ui/published-html.js — GATE sync (§4.6, OD-U2) rồi POST /projects/:id/export/html,
2
+ // poll export/versions tới done, in URL công khai <apiBase>/p/<urlPublicPrefix>.
3
+ const { ensureSynced, pollExportDone } = require("./export-gate");
4
+
5
+ async function run(_args) {
6
+ const { api, project, backend } = await ensureSynced();
7
+
8
+ console.log(`Đang build & publish HTML cho '${project.name || project.id}'...`);
9
+ await api.exportHtml(backend.id);
10
+ console.log("Đang chờ build hoàn tất...");
11
+ const version = await pollExportDone(api, backend.id);
12
+
13
+ const prefix = project.urlPublicPrefix || (version && version.urlPublicPrefix);
14
+ if (!prefix) {
15
+ console.log(
16
+ "\nBuild xong nhưng không xác định được urlPublicPrefix — kiểm tra thủ công trên `GET /projects/:id`."
17
+ );
18
+ return;
19
+ }
20
+ const url = `${backend.apiUrl}/p/${prefix}`;
21
+ console.log(`\nĐã publish: ${url}`);
22
+ }
23
+
24
+ module.exports = { run };
package/lib/ui/sync.js ADDED
@@ -0,0 +1,137 @@
1
+ // lib/ui/sync.js — push local→backend + diff + cảnh báo tên/id dự án + xác nhận (§4.5,
2
+ // OD-U3). Xuất thêm `resolveProject`/`diffProject`/`pushDiff` để published-html.js và
3
+ // gen-react-web-code.js tái dùng làm GATE (§4.6, OD-U2) qua lib/ui/export-gate.js.
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { createApiClient } = require("./api");
7
+ const { getApiConfig, loadCredential, isExpired } = require("./config");
8
+ const {
9
+ findWorkspaceRoot,
10
+ scanPanuiProjects,
11
+ selectProject,
12
+ readBackendId,
13
+ } = require("./workspace");
14
+ const { computeDiff, isDiffClean, printDiff } = require("./diff");
15
+
16
+ async function loadInquirer() {
17
+ const inq = await import("@inquirer/prompts").catch(() => null);
18
+ if (!inq) {
19
+ throw new Error("Cần TTY (@inquirer/prompts) cho lệnh này.");
20
+ }
21
+ return inq;
22
+ }
23
+
24
+ function toList(res) {
25
+ if (Array.isArray(res)) return res;
26
+ if (res && Array.isArray(res.items)) return res.items;
27
+ if (res && Array.isArray(res.data)) return res.data;
28
+ return [];
29
+ }
30
+
31
+ /**
32
+ * Monorepo scan (§4.7) → chọn dự án panui → đọc backend.id. Thiếu backend.id → gợi ý clone.
33
+ * @returns {Promise<{ projectDir: string, appYamlPath: string, backend: {id:string, apiUrl:string} }>}
34
+ */
35
+ async function resolveProject() {
36
+ const workspaceRoot = findWorkspaceRoot() || process.cwd();
37
+ const candidates = scanPanuiProjects(workspaceRoot);
38
+ const picked = await selectProject(candidates);
39
+ const backend = readBackendId(picked.appYamlPath);
40
+ if (!backend || !backend.id || !backend.apiUrl) {
41
+ throw new Error(
42
+ `Dự án '${picked.name}' chưa liên kết backend (thiếu backend.id/apiUrl trong app.panui.yaml) — chạy \`pan ui clone\` để tạo liên kết.`
43
+ );
44
+ }
45
+ return { projectDir: picked.dir, appYamlPath: picked.appYamlPath, backend };
46
+ }
47
+
48
+ /**
49
+ * Chạy diff local↔backend cho 1 dự án đã resolve (yêu cầu credential hợp lệ).
50
+ * @param {{ projectDir: string, backend: {id:string, apiUrl:string} }} resolved
51
+ * @returns {Promise<{ api, project, backend, projectDir, diff }>}
52
+ */
53
+ async function diffProject({ projectDir, backend }) {
54
+ const cred = loadCredential(backend.apiUrl);
55
+ if (!cred || isExpired(cred)) {
56
+ throw new Error("Chưa đăng nhập hoặc key đã hết hạn — chạy `pan ui init` trước.");
57
+ }
58
+ const api = createApiClient({ apiUrl: backend.apiUrl, token: cred.token });
59
+ const project = await api.getProject(backend.id);
60
+ const treeRes = await api.listFiles(backend.id);
61
+ const backendFiles = toList(treeRes);
62
+ const diff = await computeDiff(projectDir, backendFiles, async (relPath) => {
63
+ const res = await api.getContent(backend.id, relPath);
64
+ return typeof res === "string" ? res : res && res.content;
65
+ });
66
+ return { api, project, backend, projectDir, diff };
67
+ }
68
+
69
+ /**
70
+ * Đẩy thay đổi lên backend theo kết quả diffProject(). Hỏi riêng trước khi xóa.
71
+ * @param {{ api, backend, projectDir, diff }} state
72
+ */
73
+ async function pushDiff({ api, backend, projectDir, diff }) {
74
+ for (const relPath of [...diff.added, ...diff.changed]) {
75
+ const content = fs.readFileSync(path.join(projectDir, relPath), "utf8");
76
+ await api.putFile(backend.id, {
77
+ path: relPath,
78
+ content,
79
+ editNote: "pan-cli sync",
80
+ });
81
+ console.log(` đã đẩy: ${relPath}`);
82
+ }
83
+
84
+ if (diff.removed.length) {
85
+ const inq = await loadInquirer();
86
+ const ok = await inq.confirm({
87
+ message: `Xóa ${diff.removed.length} file trên backend không còn ở local?`,
88
+ default: false,
89
+ });
90
+ if (ok) {
91
+ for (const relPath of diff.removed) {
92
+ await api.deleteFile(backend.id, relPath);
93
+ console.log(` đã xóa: ${relPath}`);
94
+ }
95
+ } else {
96
+ console.log("Bỏ qua xóa file trên backend.");
97
+ }
98
+ }
99
+ }
100
+
101
+ async function run(_args) {
102
+ const resolved = await resolveProject();
103
+ const state = await diffProject(resolved);
104
+ const { project, backend, diff } = state;
105
+
106
+ const inq = await loadInquirer();
107
+ console.log(
108
+ `Đồng bộ lên dự án '${project.name || project.id}' (id ${backend.id}) — đúng dự án?`
109
+ );
110
+ const proceed = await inq.confirm({ message: "Tiếp tục?", default: false });
111
+ if (!proceed) {
112
+ console.log("Đã hủy.");
113
+ return;
114
+ }
115
+
116
+ if (isDiffClean(diff)) {
117
+ console.log("Không có thay đổi để đồng bộ — local đã khớp backend.");
118
+ return;
119
+ }
120
+
121
+ console.log("Thay đổi phát hiện:");
122
+ printDiff(diff);
123
+
124
+ const ok = await inq.confirm({
125
+ message: "Đẩy các thay đổi trên lên backend?",
126
+ default: false,
127
+ });
128
+ if (!ok) {
129
+ console.log("Đã hủy.");
130
+ return;
131
+ }
132
+
133
+ await pushDiff(state);
134
+ console.log("Đồng bộ hoàn tất.");
135
+ }
136
+
137
+ module.exports = { run, resolveProject, diffProject, pushDiff };