@hapico/cli 0.0.30 → 0.0.32

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hapico/cli",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "description": "A simple CLI tool for project management",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -27,11 +27,15 @@
27
27
  "express": "^5.2.1",
28
28
  "inquirer": "^12.9.6",
29
29
  "lodash": "^4.17.21",
30
+ "nativewind": "^4.2.2",
30
31
  "open": "^10.2.0",
31
32
  "ora": "^8.2.0",
32
33
  "pako": "^2.1.0",
34
+ "postcss": "^8.5.8",
33
35
  "prettier": "^3.6.2",
34
36
  "qrcode-terminal": "^0.12.0",
37
+ "react-native-css-interop": "^0.2.2",
38
+ "tailwindcss": "3.4.17",
35
39
  "unzipper": "^0.12.3",
36
40
  "uuid": "^13.0.0",
37
41
  "ws": "^8.18.3"
@@ -0,0 +1,87 @@
1
+ import postcss from "postcss";
2
+ import tailwindcss, { type Config } from "tailwindcss";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ // @ts-ignore
6
+ import nativewindPreset from "nativewind/preset";
7
+
8
+ // ==========================================
9
+ // FIX LỖI IMPORT REACT-NATIVE-CSS-INTEROP
10
+ // ==========================================
11
+ let compileToNative: (css: string, options?: any) => any;
12
+ try {
13
+ const compiler = require("react-native-css-interop/dist/compiler");
14
+ compileToNative = compiler.compile || compiler.default;
15
+ } catch (e) {
16
+ try {
17
+ const cssToRn = require("react-native-css-interop/dist/css-to-rn");
18
+ compileToNative = cssToRn.cssToReactNativeRuntime || cssToRn.default;
19
+ } catch (err) {
20
+ throw new Error("Không thể tìm thấy hàm compile. Kiểm tra lại node_modules.");
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Hàm biên dịch Tailwind Class sang Compiled Object
26
+ */
27
+ export async function generateNativeWindStyleMap(
28
+ contentPaths: string[],
29
+ rawCss: string = "@tailwind base;\n@tailwind components;\n@tailwind utilities;"
30
+ ) {
31
+ try {
32
+ const tailwindConfig: Config = {
33
+ content: contentPaths,
34
+ presets: [nativewindPreset],
35
+ theme: { extend: {} },
36
+ };
37
+
38
+ const processor = postcss([tailwindcss(tailwindConfig)]);
39
+ const result = await processor.process(rawCss, {
40
+ from: "tailwind.css",
41
+ to: "output.css",
42
+ });
43
+
44
+ return compileToNative(result.css, { native: true });
45
+ } catch (error) {
46
+ console.error("❌ Compile Style Map Error:", error);
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Hàm hỗ trợ: Quét 1 thư mục và Lưu kết quả vào 1 thư mục khác
53
+ */
54
+ export async function compileAndSaveToFolder(
55
+ folderToScan: string, // Thư mục chứa code cần quét (VD: "./src/components")
56
+ outputFilePath: string // Nơi lưu kết quả (VD: "./dist/styles.json")
57
+ ) {
58
+ try {
59
+ // 1. Tạo đường dẫn quét toàn bộ file js, jsx, ts, tsx trong thư mục
60
+ // path.resolve(process.cwd(), ...) giúp lấy đúng đường dẫn gốc của project
61
+ const scanPattern = path.resolve(process.cwd(), folderToScan, "**/*.{js,jsx,ts,tsx}");
62
+
63
+ // console.log(`🔍 Scanning files in ${scanPattern}`);
64
+
65
+ // 2. Chạy hàm biên dịch
66
+ const compiledObject = await generateNativeWindStyleMap([scanPattern]);
67
+
68
+ // 3. Xử lý thư mục lưu file
69
+ const absoluteOutputPath = path.resolve(process.cwd(), outputFilePath);
70
+ const outputDir = path.dirname(absoluteOutputPath); // Lấy tên thư mục chứa file đầu ra
71
+
72
+ // Nếu thư mục đầu ra chưa tồn tại, Node.js sẽ tự động tạo thư mục đó (recursive: true)
73
+ if (!fs.existsSync(outputDir)) {
74
+ fs.mkdirSync(outputDir, { recursive: true });
75
+ }
76
+
77
+ // 4. Ghi Object ra file JSON
78
+ fs.writeFileSync(absoluteOutputPath, JSON.stringify(compiledObject, null, 2), "utf-8");
79
+
80
+ // console.log(`✅ Sync CSS ${absoluteOutputPath}`);
81
+
82
+ } catch (error) {
83
+ console.error("❌ Lỗi khi xuất file:", error);
84
+ }
85
+ }
86
+
87
+
@@ -253,6 +253,11 @@ const parseLLMPatch = (patchContent: string): Record<string, PatchBlock[]> => {
253
253
  let searchLines: string[] = [];
254
254
  let replaceLines: string[] = [];
255
255
 
256
+ // State for full file replacement detection
257
+ let inCodeBlock = false;
258
+ let codeBlockLines: string[] = [];
259
+ let hasSeenMarkersInBlock = false;
260
+
256
261
  const isFileMarker = (line: string) => {
257
262
  const match = line.match(/^### File:\s*[`']?([^`'\s]+)[`']?/i);
258
263
  return match ? match[1] : null;
@@ -265,21 +270,28 @@ const parseLLMPatch = (patchContent: string): Record<string, PatchBlock[]> => {
265
270
  const fileName = isFileMarker(line);
266
271
  if (fileName) {
267
272
  currentFile = fileName;
273
+ // Reset tracking when switching files
274
+ inCodeBlock = false;
275
+ codeBlockLines = [];
276
+ hasSeenMarkersInBlock = false;
268
277
  continue;
269
278
  }
270
279
 
271
280
  if (isSearchMarker(line)) {
272
281
  inSearch = true;
273
282
  inReplace = false;
283
+ hasSeenMarkersInBlock = true;
274
284
  searchLines = [];
275
285
  replaceLines = [];
276
286
  continue;
277
287
  }
278
288
  if (isDividerMarker(line)) {
279
289
  if (inSearch) { inSearch = false; inReplace = true; }
290
+ hasSeenMarkersInBlock = true;
280
291
  continue;
281
292
  }
282
293
  if (isEndMarker(line)) {
294
+ hasSeenMarkersInBlock = true;
283
295
  if (inReplace || inSearch) {
284
296
  if (searchLines.length > 0 || replaceLines.length > 0) {
285
297
  if (!filePatches[currentFile]) filePatches[currentFile] = [];
@@ -292,8 +304,30 @@ const parseLLMPatch = (patchContent: string): Record<string, PatchBlock[]> => {
292
304
  inSearch = false; inReplace = false; searchLines = []; replaceLines = [];
293
305
  continue;
294
306
  }
307
+
308
+ // Detect triple backticks to identify potential full-file replacements
309
+ if (line.trim().startsWith("```")) {
310
+ if (!inCodeBlock) {
311
+ inCodeBlock = true;
312
+ codeBlockLines = [];
313
+ hasSeenMarkersInBlock = false;
314
+ } else {
315
+ inCodeBlock = false;
316
+ // If the code block ended without any SEARCH/REPLACE markers, treat it as full-file code
317
+ if (!hasSeenMarkersInBlock && codeBlockLines.length > 0 && currentFile !== "unknown") {
318
+ if (!filePatches[currentFile]) filePatches[currentFile] = [];
319
+ filePatches[currentFile].push({
320
+ search: "__FULL_FILE_REPLACEMENT__",
321
+ replace: codeBlockLines.join("\n")
322
+ });
323
+ }
324
+ }
325
+ continue;
326
+ }
327
+
295
328
  if (inSearch) searchLines.push(line);
296
329
  else if (inReplace) replaceLines.push(line);
330
+ else if (inCodeBlock) codeBlockLines.push(line);
297
331
  }
298
332
  return filePatches;
299
333
  };
@@ -314,6 +348,13 @@ export const applyPatch = (
314
348
  for (const patch of patches) {
315
349
  const { search, replace } = patch;
316
350
 
351
+ // Strategy 0: Full File Replacement (Overwrite)
352
+ if (search === "__FULL_FILE_REPLACEMENT__") {
353
+ console.log(`[ApplyPatch] Overwriting file with full content.`);
354
+ result = replace;
355
+ continue;
356
+ }
357
+
317
358
  // NẾU SEARCH RỖNG (INSERT/APPEND)
318
359
  if (search.trim() === "") {
319
360
  console.log(`[ApplyPatch] Inserting/appending block.`);
@@ -419,26 +460,54 @@ const startServer = async (rootDir: string) => {
419
460
  // 2. Scan project files
420
461
  const allFiles = await scanRecursively(rootDir, rootDir);
421
462
  const modifiedFiles: string[] = [];
463
+ const appliedNormalizedPaths = new Set<string>();
422
464
 
423
- // 3. Match patches to files and apply
465
+ // 3. Match patches to existing files and apply
424
466
  for (const file of allFiles) {
425
- // Find patches meant for this specific file path
426
- // Note: LLM might use different path separators, so we normalize
427
467
  const normalizedPath = file.path.replace(/\\/g, '/');
428
- const patchesForFile = Object.entries(filePatches).find(([path]) => {
468
+ const patchesEntry = Object.entries(filePatches).find(([path]) => {
429
469
  const normalizedKey = path.replace(/\\/g, '/');
430
470
  return normalizedKey === normalizedPath || normalizedPath.endsWith('/' + normalizedKey);
431
- })?.[1];
471
+ });
432
472
 
433
- // Only apply if the file was selected in UI AND has patches in the response
434
- if (selectedPaths.includes(file.path) && patchesForFile) {
435
- console.log(`[Apply] Processing ${patchesForFile.length} blocks for: ${file.path}`);
436
- const newContent = applyPatch(file.content, patchesForFile);
437
-
438
- if (newContent !== file.content) {
439
- await fs.writeFile(file.fullPath, newContent, "utf-8");
440
- modifiedFiles.push(file.path);
473
+ if (patchesEntry) {
474
+ const [rawKey, patchesForFile] = patchesEntry;
475
+ // Only apply if the file was selected in UI
476
+ if (selectedPaths.includes(file.path)) {
477
+ console.log(`[Apply] Processing ${patchesForFile.length} blocks for: ${file.path}`);
478
+ const newContent = applyPatch(file.content, patchesForFile);
479
+
480
+ if (newContent !== file.content) {
481
+ await fs.writeFile(file.fullPath, newContent, "utf-8");
482
+ modifiedFiles.push(file.path);
483
+ }
441
484
  }
485
+ appliedNormalizedPaths.add(rawKey.replace(/\\/g, '/'));
486
+ }
487
+ }
488
+
489
+ // 4. Handle New Files (patches for paths that don't exist yet)
490
+ for (const [rawPath, patches] of Object.entries(filePatches)) {
491
+ const normalizedKey = rawPath.replace(/\\/g, '/');
492
+ if (normalizedKey === "unknown" || appliedNormalizedPaths.has(normalizedKey)) continue;
493
+
494
+ // Double check it doesn't exist (safety)
495
+ const alreadyExists = allFiles.some(f => {
496
+ const np = f.path.replace(/\\/g, '/');
497
+ return np === normalizedKey || np.endsWith('/' + normalizedKey);
498
+ });
499
+ if (alreadyExists) continue;
500
+
501
+ console.log(`[Apply] Creating new file: ${rawPath}`);
502
+ const newContent = applyPatch("", patches);
503
+ const fullPath = path.resolve(rootDir, rawPath);
504
+
505
+ try {
506
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
507
+ await fs.writeFile(fullPath, newContent, "utf-8");
508
+ modifiedFiles.push(rawPath);
509
+ } catch (err) {
510
+ console.error(`[Apply] Failed to create file ${rawPath}:`, err);
442
511
  }
443
512
  }
444
513
 
@@ -0,0 +1,273 @@
1
+ import { diff_match_patch } from "diff-match-patch";
2
+
3
+ /**
4
+ * ------------------------------------------------------------------
5
+ * TYPES
6
+ * ------------------------------------------------------------------
7
+ */
8
+ export interface PatchBlock {
9
+ search: string;
10
+ replace: string;
11
+ }
12
+
13
+ /**
14
+ * ------------------------------------------------------------------
15
+ * HELPER: STRING SIMILARITY (Levenshtein based)
16
+ * Tính độ giống nhau giữa 2 chuỗi (0 -> 1)
17
+ * ------------------------------------------------------------------
18
+ */
19
+ const calculateSimilarity = (s1: string, s2: string): number => {
20
+ const longer = s1.length > s2.length ? s1 : s2;
21
+ const shorter = s1.length > s2.length ? s2 : s1;
22
+ const longerLength = longer.length;
23
+ if (longerLength === 0) {
24
+ return 1.0;
25
+ }
26
+
27
+ // Sử dụng dmp để tính Levenshtein distance cho chính xác và nhanh
28
+ const dmp = new diff_match_patch();
29
+ const diffs = dmp.diff_main(longer, shorter);
30
+ const levenshtein = dmp.diff_levenshtein(diffs);
31
+
32
+ return (longerLength - levenshtein) / longerLength;
33
+ };
34
+
35
+ /**
36
+ * ------------------------------------------------------------------
37
+ * HELPER: FUZZY LINE MATCHING
38
+ * Tìm đoạn code khớp nhất trong file gốc dựa trên điểm số (Confidence)
39
+ * ------------------------------------------------------------------
40
+ */
41
+ const applyFuzzyLineMatch = (
42
+ original: string,
43
+ search: string,
44
+ replace: string,
45
+ threshold: number = 0.85 // Ngưỡng chấp nhận (85% giống nhau)
46
+ ): { result: string | null; confidence: number } => {
47
+ const originalLines = original.split(/\r?\n/);
48
+ const searchLines = search.split(/\r?\n/);
49
+
50
+ // Lọc bỏ dòng trống ở đầu/cuối search block để match chính xác hơn
51
+ while (searchLines.length > 0 && searchLines[0].trim() === "") searchLines.shift();
52
+ while (searchLines.length > 0 && searchLines[searchLines.length - 1].trim() === "") searchLines.pop();
53
+
54
+ if (searchLines.length === 0) return { result: null, confidence: 0 };
55
+
56
+ let bestMatchIndex = -1;
57
+ let bestMatchScore = 0;
58
+
59
+ // Duyệt qua file gốc (Sliding Window)
60
+ // Chỉ cần duyệt đến vị trí mà số dòng còn lại đủ để chứa searchLines
61
+ for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
62
+ let currentScoreTotal = 0;
63
+ let possible = true;
64
+
65
+ for (let j = 0; j < searchLines.length; j++) {
66
+ const originalLine = originalLines[i + j].trim();
67
+ const searchLine = searchLines[j].trim();
68
+
69
+ // So sánh nhanh: Nếu giống hệt nhau
70
+ if (originalLine === searchLine) {
71
+ currentScoreTotal += 1;
72
+ continue;
73
+ }
74
+
75
+ // So sánh chậm: Tính độ tương đồng
76
+ const similarity = calculateSimilarity(originalLine, searchLine);
77
+
78
+ // Nếu một dòng quá khác biệt (< 60%), coi như block này không khớp
79
+ // (Giúp tối ưu hiệu năng, cắt nhánh sớm)
80
+ if (similarity < 0.6) {
81
+ possible = false;
82
+ break;
83
+ }
84
+ currentScoreTotal += similarity;
85
+ }
86
+
87
+ if (possible) {
88
+ const avgScore = currentScoreTotal / searchLines.length;
89
+ if (avgScore > bestMatchScore) {
90
+ bestMatchScore = avgScore;
91
+ bestMatchIndex = i;
92
+ }
93
+ }
94
+ }
95
+
96
+ // LOG CONFIDENCE ĐỂ DEBUG
97
+ if (bestMatchScore > 0) {
98
+ console.log(`[FuzzyMatch] Best match score: ${(bestMatchScore * 100).toFixed(2)}% at line ${bestMatchIndex + 1}`);
99
+ }
100
+
101
+ // Nếu tìm thấy vị trí có độ tin cậy cao hơn ngưỡng
102
+ if (bestMatchIndex !== -1 && bestMatchScore >= threshold) {
103
+ const before = originalLines.slice(0, bestMatchIndex);
104
+ // Lưu ý: Đoạn replace thay thế đúng số lượng dòng của searchLines
105
+ // (Giả định search và match trong original có cùng số dòng hiển thị)
106
+ const after = originalLines.slice(bestMatchIndex + searchLines.length);
107
+
108
+ return {
109
+ result: [...before, replace, ...after].join("\n"),
110
+ confidence: bestMatchScore
111
+ };
112
+ }
113
+
114
+ return { result: null, confidence: bestMatchScore };
115
+ };
116
+
117
+ /**
118
+ * ------------------------------------------------------------------
119
+ * 1. STATE MACHINE PARSER (GIỮ NGUYÊN)
120
+ * ------------------------------------------------------------------
121
+ */
122
+ export const parseLLMPatch = (patchContent: string): PatchBlock[] => {
123
+ // ... (Code parse giữ nguyên như cũ của bạn)
124
+ const lines = patchContent.split(/\r?\n/);
125
+ const patches: PatchBlock[] = [];
126
+ let inSearch = false;
127
+ let inReplace = false;
128
+ let searchLines: string[] = [];
129
+ let replaceLines: string[] = [];
130
+ const isSearchMarker = (line: string) => /^\s*<{7,}\s*SEARCH\s*$/.test(line);
131
+ const isDividerMarker = (line: string) => /^\s*={7,}\s*$/.test(line);
132
+ const isEndMarker = (line: string) => /^\s*>{7,}\s*REPLACE\s*$/.test(line);
133
+
134
+ for (const line of lines) {
135
+ if (isSearchMarker(line)) {
136
+ inSearch = true;
137
+ inReplace = false;
138
+ searchLines = [];
139
+ replaceLines = [];
140
+ continue;
141
+ }
142
+ if (isDividerMarker(line)) {
143
+ if (inSearch) { inSearch = false; inReplace = true; }
144
+ continue;
145
+ }
146
+ if (isEndMarker(line)) {
147
+ if (inReplace || inSearch) {
148
+ // CẬP NHẬT: Cho phép lưu patch nếu có search HOẶC có replace
149
+ // (Trường hợp Search rỗng nhưng Replace có nội dung = Insert/Append)
150
+ if (searchLines.length > 0 || replaceLines.length > 0) {
151
+ patches.push({ search: searchLines.join("\n"), replace: replaceLines.join("\n") });
152
+ }
153
+ }
154
+ inSearch = false; inReplace = false; searchLines = []; replaceLines = [];
155
+ continue;
156
+ }
157
+ if (inSearch) searchLines.push(line);
158
+ else if (inReplace) replaceLines.push(line);
159
+ }
160
+ return patches;
161
+ };
162
+
163
+ /**
164
+ * ------------------------------------------------------------------
165
+ * 2. APPLY PATCH (UPDATED)
166
+ * ------------------------------------------------------------------
167
+ */
168
+ export const applyPatch = (
169
+ originalContent: string,
170
+ patchContent: string | PatchBlock[]
171
+ ): string => {
172
+
173
+ let patches: PatchBlock[];
174
+ if (typeof patchContent === 'string') {
175
+ if (!patchContent || !patchContent.includes("<<<<<<< SEARCH")) {
176
+ return patchContent || originalContent;
177
+ }
178
+ patches = parseLLMPatch(patchContent);
179
+ } else {
180
+ patches = patchContent;
181
+ }
182
+
183
+ if (!patches || patches.length === 0) return originalContent;
184
+
185
+ let result = originalContent;
186
+
187
+ for (const patch of patches) {
188
+ const { search, replace } = patch;
189
+
190
+ // Strategy 0: Full File Replacement (Overwrite)
191
+ if (search === "__FULL_FILE_REPLACEMENT__") {
192
+ console.log(`[ApplyPatch] Overwriting file with full content.`);
193
+ result = replace;
194
+ continue;
195
+ }
196
+
197
+ // Nếu search và replace giống hệt nhau, bỏ qua
198
+ if (search === replace) {
199
+ console.log(`[ApplyPatch] Search and replace are identical. Skipping.`);
200
+ continue;
201
+ }
202
+
203
+ // NẾU SEARCH RỖNG (INSERT/APPEND hoặc TẠO FILE MỚI)
204
+ if (search.trim() === "") {
205
+ console.log(`[ApplyPatch] Inserting/appending block.`);
206
+ if (result.trim() === "") {
207
+ // Nếu file hiện tại rỗng hoặc chỉ có khoảng trắng, gán luôn bằng nội dung replace (Hỗ trợ tạo file mới)
208
+ result = replace;
209
+ } else {
210
+ // Nếu file đã có nội dung, append vào cuối. Đảm bảo có ít nhất một dấu xuống dòng phân tách.
211
+ const separator = result.endsWith("\n") ? "" : "\n";
212
+ result += separator + replace;
213
+ }
214
+ continue;
215
+ }
216
+
217
+ // --- STRATEGY 1: EXACT MATCH ---
218
+ if (result.includes(search)) {
219
+ console.log(`[ApplyPatch] Exact match found.`);
220
+ result = result.replace(search, replace);
221
+ continue;
222
+ }
223
+
224
+ // --- STRATEGY 2: NORMALIZED MATCH (Trim) ---
225
+ const trimmedSearch = search.trim();
226
+ if (result.includes(trimmedSearch)) {
227
+ console.log(`[ApplyPatch] Trimmed match found.`);
228
+ result = result.replace(trimmedSearch, replace.trim());
229
+ continue;
230
+ }
231
+
232
+ // --- STRATEGY 3: FUZZY LINE MATCH (BEST FOR CODE) ---
233
+ // Tìm vị trí tốt nhất dựa trên similarity score của từng dòng
234
+ const fuzzyMatch = applyFuzzyLineMatch(result, search, replace, 0.80); // 80% confidence
235
+ if (fuzzyMatch.result !== null) {
236
+ console.log(`[ApplyPatch] Fuzzy line match applied (Confidence: ${(fuzzyMatch.confidence * 100).toFixed(0)}%)`);
237
+ result = fuzzyMatch.result;
238
+ continue;
239
+ } else {
240
+ console.warn(`[ApplyPatch] Failed to match block. Max confidence was: ${(fuzzyMatch.confidence * 100).toFixed(0)}%`);
241
+ }
242
+
243
+ // --- STRATEGY 4: DMP FALLBACK (LAST RESORT) ---
244
+ // Nếu cả fuzzy line match cũng tạch (ví dụ do search block quá ngắn hoặc cấu trúc quá nát)
245
+ // thì mới dùng DMP patch_apply.
246
+ try {
247
+ const dmp = new diff_match_patch();
248
+ dmp.Match_Threshold = 0.5;
249
+ dmp.Match_Distance = 1000;
250
+
251
+ const dmpPatches = dmp.patch_make(search, replace);
252
+ const [newText, applyResults] = dmp.patch_apply(dmpPatches, result);
253
+
254
+ // Kiểm tra xem có apply được hết các hunk không
255
+ const successCount = applyResults.filter(Boolean).length;
256
+ const successRate = successCount / applyResults.length;
257
+
258
+ if (successRate === 1 && newText !== result) {
259
+ console.log(`[ApplyPatch] DMP Patch applied via fuzzy logic.`);
260
+ result = newText;
261
+ } else {
262
+ console.error(`[ApplyPatch] All strategies failed for block starting with: ${search.substring(0, 30)}...`);
263
+ console.log(`[ApplyPatch DATA]`, { search, replace, result });
264
+ // Tuỳ chọn: Throw error để báo cho LLM biết là apply thất bại
265
+ // throw new Error("Could not apply patch block");
266
+ }
267
+ } catch (e) {
268
+ console.error(`[ApplyPatch] DMP error:`, e);
269
+ }
270
+ }
271
+
272
+ return result;
273
+ };