docuking-mcp 2.11.1 → 2.13.0
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/handlers/sync.js +278 -106
- package/lib/index-cache.js +242 -0
- package/package.json +1 -1
package/handlers/sync.js
CHANGED
|
@@ -37,6 +37,17 @@ import {
|
|
|
37
37
|
|
|
38
38
|
import { executeKingcast } from './kingcast.js';
|
|
39
39
|
|
|
40
|
+
import {
|
|
41
|
+
loadIndex,
|
|
42
|
+
saveIndex,
|
|
43
|
+
detectChanges,
|
|
44
|
+
updateIndexEntry,
|
|
45
|
+
removeIndexEntry,
|
|
46
|
+
updateIndexAfterSync,
|
|
47
|
+
computeFileHash,
|
|
48
|
+
getIndexStats,
|
|
49
|
+
} from '../lib/index-cache.js';
|
|
50
|
+
|
|
40
51
|
// 레포지토리 매핑 (메모리에 캐시)
|
|
41
52
|
const repoMapping = {};
|
|
42
53
|
|
|
@@ -522,6 +533,16 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
522
533
|
let current = 0;
|
|
523
534
|
let skipped = 0;
|
|
524
535
|
|
|
536
|
+
// ========================================
|
|
537
|
+
// 로컬 인덱스 로드 (mtime 기반 변경 감지용)
|
|
538
|
+
// ========================================
|
|
539
|
+
const localIndex = loadIndex(localPath);
|
|
540
|
+
const indexStats = getIndexStats(localIndex);
|
|
541
|
+
console.error(`[DocuKing] 로컬 인덱스: ${indexStats.fileCount}개 파일 (${indexStats.totalSizeMB}MB)`);
|
|
542
|
+
|
|
543
|
+
// 동기화 후 인덱스 업데이트용 배열
|
|
544
|
+
const syncedFiles = [];
|
|
545
|
+
|
|
525
546
|
// 시작 안내 메시지 출력 (AI가 사용자에게 전달할 수 있도록)
|
|
526
547
|
console.error(`[DocuKing] Push 시작: ${total}개 파일 (총 ${totalSizeMB.toFixed(1)}MB)`);
|
|
527
548
|
console.error(`[DocuKing] 💡 실시간 진행상황은 DocuKing 웹(https://docuking.ai)에서 확인하세요`);
|
|
@@ -582,32 +603,75 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
582
603
|
}
|
|
583
604
|
|
|
584
605
|
try {
|
|
585
|
-
//
|
|
606
|
+
// ========================================
|
|
607
|
+
// 로컬 인덱스 기반 mtime 체크 (해시 계산 최소화)
|
|
608
|
+
// ========================================
|
|
609
|
+
const cached = localIndex.files[file.serverPath];
|
|
586
610
|
let fileHash;
|
|
587
611
|
let content;
|
|
588
612
|
let encoding = 'utf-8';
|
|
613
|
+
let usedCache = false;
|
|
589
614
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
615
|
+
// mtime 비교: 변경되지 않았으면 캐시된 해시 사용
|
|
616
|
+
if (cached) {
|
|
617
|
+
try {
|
|
618
|
+
const stat = fs.statSync(file.fullPath);
|
|
619
|
+
const currentMtime = stat.mtimeMs;
|
|
620
|
+
|
|
621
|
+
// mtime 동일 (1초 오차 허용) + 서버 해시와도 동일 → 완전 스킵
|
|
622
|
+
if (Math.abs(currentMtime - cached.mtime) < 1000) {
|
|
623
|
+
if (serverPathToHash[file.serverPath] === cached.hash) {
|
|
624
|
+
const resultText = `${progress} ⊘ ${file.serverPath} (변경 없음)`;
|
|
625
|
+
results.push(resultText);
|
|
626
|
+
skipped++;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
// mtime 동일하지만 서버 해시 다름 → 캐시 해시 사용, 업로드 필요
|
|
630
|
+
fileHash = cached.hash;
|
|
631
|
+
usedCache = true;
|
|
632
|
+
}
|
|
633
|
+
} catch (e) {
|
|
634
|
+
// stat 실패 시 해시 계산으로 폴백
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 캐시 사용 못하면 파일 읽어서 해시 계산
|
|
639
|
+
if (!usedCache) {
|
|
640
|
+
if (file.fileType === 'binary') {
|
|
641
|
+
// 바이너리 파일은 Base64로 인코딩
|
|
642
|
+
const buffer = fs.readFileSync(file.fullPath);
|
|
643
|
+
fileHash = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
644
|
+
content = buffer.toString('base64');
|
|
645
|
+
encoding = 'base64';
|
|
646
|
+
} else {
|
|
647
|
+
// 텍스트 파일은 UTF-8
|
|
648
|
+
content = fs.readFileSync(file.fullPath, 'utf-8');
|
|
649
|
+
fileHash = crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
650
|
+
}
|
|
600
651
|
}
|
|
601
652
|
|
|
602
653
|
// Git 스타일 동기화:
|
|
603
654
|
// 1. 같은 경로 + 같은 해시 → 스킵 (변경 없음)
|
|
604
655
|
if (serverPathToHash[file.serverPath] === fileHash) {
|
|
656
|
+
// 인덱스 업데이트 (다음 Push 때 스킵 가능하도록)
|
|
657
|
+
syncedFiles.push({ serverPath: file.serverPath, fullPath: file.fullPath, hash: fileHash });
|
|
605
658
|
const resultText = `${progress} ⊘ ${file.serverPath} (변경 없음)`;
|
|
606
659
|
results.push(resultText);
|
|
607
660
|
skipped++;
|
|
608
661
|
continue;
|
|
609
662
|
}
|
|
610
663
|
|
|
664
|
+
// 업로드 필요 → content가 없으면 파일 읽기
|
|
665
|
+
if (!content) {
|
|
666
|
+
if (file.fileType === 'binary') {
|
|
667
|
+
const buffer = fs.readFileSync(file.fullPath);
|
|
668
|
+
content = buffer.toString('base64');
|
|
669
|
+
encoding = 'base64';
|
|
670
|
+
} else {
|
|
671
|
+
content = fs.readFileSync(file.fullPath, 'utf-8');
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
611
675
|
// 2. 같은 해시가 다른 경로에 있음 → Move (경로 변경)
|
|
612
676
|
const existingPath = serverHashToPath[fileHash];
|
|
613
677
|
if (existingPath && existingPath !== file.serverPath) {
|
|
@@ -632,6 +696,9 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
632
696
|
// 이동된 파일의 해시 맵 업데이트
|
|
633
697
|
delete serverHashToPath[fileHash];
|
|
634
698
|
serverHashToPath[fileHash] = file.serverPath;
|
|
699
|
+
// 인덱스에 이동된 파일 기록 (기존 경로 삭제, 새 경로 추가)
|
|
700
|
+
removeIndexEntry(localIndex, existingPath);
|
|
701
|
+
syncedFiles.push({ serverPath: file.serverPath, fullPath: file.fullPath, hash: fileHash });
|
|
635
702
|
continue;
|
|
636
703
|
}
|
|
637
704
|
} catch (e) {
|
|
@@ -684,6 +751,8 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
684
751
|
: `${progress} ✓ ${file.path}`;
|
|
685
752
|
results.push(resultText);
|
|
686
753
|
success = true;
|
|
754
|
+
// 인덱스에 업로드된 파일 기록
|
|
755
|
+
syncedFiles.push({ serverPath: file.serverPath, fullPath: file.fullPath, hash: fileHash });
|
|
687
756
|
break; // 성공하면 재시도 중단
|
|
688
757
|
} else {
|
|
689
758
|
const error = await response.text();
|
|
@@ -843,6 +912,16 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
843
912
|
}
|
|
844
913
|
}
|
|
845
914
|
|
|
915
|
+
// ========================================
|
|
916
|
+
// 로컬 인덱스 업데이트 및 저장
|
|
917
|
+
// ========================================
|
|
918
|
+
if (syncedFiles.length > 0) {
|
|
919
|
+
updateIndexAfterSync(localIndex, syncedFiles, deletedFilePaths);
|
|
920
|
+
if (saveIndex(localPath, localIndex)) {
|
|
921
|
+
console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 파일`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
846
925
|
// Sync 완료 알림
|
|
847
926
|
try {
|
|
848
927
|
await fetch(`${API_ENDPOINT}/projects/${projectId}/sync/complete`, {
|
|
@@ -1142,14 +1221,54 @@ export async function handlePullInternal(args) {
|
|
|
1142
1221
|
// Pull 후 mark-as-pulled할 파일 목록 (source: web 파일들)
|
|
1143
1222
|
const filesToMarkAsPulled = [];
|
|
1144
1223
|
|
|
1145
|
-
|
|
1224
|
+
// ========================================
|
|
1225
|
+
// 로컬 인덱스 로드 (Pull 후 업데이트용)
|
|
1226
|
+
// ========================================
|
|
1227
|
+
const localIndex = loadIndex(localPath);
|
|
1228
|
+
const syncedFiles = []; // 다운로드/업데이트된 파일 목록
|
|
1229
|
+
|
|
1230
|
+
// ========================================
|
|
1231
|
+
// 폴더 먼저 처리
|
|
1232
|
+
// ========================================
|
|
1233
|
+
const folders = sortedFiles.filter(f => f.type === 'folder');
|
|
1234
|
+
const files_only = sortedFiles.filter(f => f.type !== 'folder');
|
|
1235
|
+
|
|
1236
|
+
for (const folder of folders) {
|
|
1146
1237
|
current++;
|
|
1147
|
-
const
|
|
1238
|
+
const serverMeta = serverPathToMeta[folder.path];
|
|
1239
|
+
let fullPath;
|
|
1240
|
+
if (folder.path.startsWith('xx_') ||
|
|
1241
|
+
folder.path.startsWith('yy_All_Docu/') ||
|
|
1242
|
+
folder.path.startsWith('yy_All_Docu') ||
|
|
1243
|
+
folder.path.startsWith('yy_Coworker_') ||
|
|
1244
|
+
folder.path.startsWith('zz_ai_')) {
|
|
1245
|
+
fullPath = path.join(localPath, folder.path);
|
|
1246
|
+
} else {
|
|
1247
|
+
fullPath = path.join(mainFolderPath, folder.path);
|
|
1248
|
+
}
|
|
1148
1249
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1250
|
+
if (fs.existsSync(fullPath)) {
|
|
1251
|
+
results.push({ type: 'skip', path: folder.path, itemType: 'folder' });
|
|
1252
|
+
skipped++;
|
|
1253
|
+
} else {
|
|
1254
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
1255
|
+
results.push({ type: 'download', path: folder.path, itemType: 'folder' });
|
|
1256
|
+
foldersCreated++;
|
|
1257
|
+
downloaded++;
|
|
1258
|
+
if (serverMeta && serverMeta.source === 'web') {
|
|
1259
|
+
filesToMarkAsPulled.push(folder.path);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1151
1263
|
|
|
1152
|
-
|
|
1264
|
+
// ========================================
|
|
1265
|
+
// 파일 처리 - Batch API 사용 (200개씩)
|
|
1266
|
+
// ========================================
|
|
1267
|
+
const BATCH_SIZE = 200;
|
|
1268
|
+
|
|
1269
|
+
// 먼저 로컬 해시 기반으로 스킵할 파일 필터링
|
|
1270
|
+
const filesToDownload = [];
|
|
1271
|
+
for (const file of files_only) {
|
|
1153
1272
|
let fullPath;
|
|
1154
1273
|
if (file.path.startsWith('xx_') ||
|
|
1155
1274
|
file.path.startsWith('yy_All_Docu/') ||
|
|
@@ -1161,116 +1280,159 @@ export async function handlePullInternal(args) {
|
|
|
1161
1280
|
fullPath = path.join(mainFolderPath, file.path);
|
|
1162
1281
|
}
|
|
1163
1282
|
|
|
1164
|
-
|
|
1165
|
-
// ========================================
|
|
1166
|
-
// 폴더 처리 (type === 'folder')
|
|
1167
|
-
// ========================================
|
|
1168
|
-
if (file.type === 'folder') {
|
|
1169
|
-
if (fs.existsSync(fullPath)) {
|
|
1170
|
-
// 이미 있음 → 스킵
|
|
1171
|
-
results.push({ type: 'skip', path: file.path, itemType: 'folder' });
|
|
1172
|
-
skipped++;
|
|
1173
|
-
} else {
|
|
1174
|
-
// 폴더 생성
|
|
1175
|
-
fs.mkdirSync(fullPath, { recursive: true });
|
|
1176
|
-
results.push({ type: 'download', path: file.path, itemType: 'folder' });
|
|
1177
|
-
foldersCreated++;
|
|
1178
|
-
downloaded++;
|
|
1283
|
+
const serverHash = serverPathToHash[file.path];
|
|
1179
1284
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1285
|
+
// 로컬 파일 해시 비교
|
|
1286
|
+
if (fs.existsSync(fullPath)) {
|
|
1287
|
+
try {
|
|
1288
|
+
const localStat = fs.statSync(fullPath);
|
|
1289
|
+
if (localStat.isDirectory()) {
|
|
1290
|
+
results.push({ type: 'skip', path: file.path, error: '같은 이름의 폴더 존재' });
|
|
1291
|
+
skipped++;
|
|
1292
|
+
current++;
|
|
1293
|
+
continue;
|
|
1184
1294
|
}
|
|
1185
|
-
|
|
1295
|
+
const localBuffer = fs.readFileSync(fullPath);
|
|
1296
|
+
const localHash = crypto.createHash('sha256').update(localBuffer).digest('hex');
|
|
1297
|
+
if (localHash === serverHash) {
|
|
1298
|
+
results.push({ type: 'skip', path: file.path });
|
|
1299
|
+
skipped++;
|
|
1300
|
+
current++;
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
} catch (e) {
|
|
1304
|
+
// 해시 계산 실패 시 다운로드 대상
|
|
1186
1305
|
}
|
|
1306
|
+
}
|
|
1187
1307
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
// ========================================
|
|
1191
|
-
const response = await fetch(
|
|
1192
|
-
`${API_ENDPOINT}/files?projectId=${projectId}&path=${encodeURIComponent(file.path)}`,
|
|
1193
|
-
{
|
|
1194
|
-
headers: {
|
|
1195
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1196
|
-
},
|
|
1197
|
-
}
|
|
1198
|
-
);
|
|
1308
|
+
filesToDownload.push({ ...file, fullPath });
|
|
1309
|
+
}
|
|
1199
1310
|
|
|
1200
|
-
|
|
1201
|
-
results.push({ type: 'fail', path: file.path, error: await response.text() });
|
|
1202
|
-
failed++;
|
|
1203
|
-
continue;
|
|
1204
|
-
}
|
|
1311
|
+
console.error(`[DocuKing] Batch Pull: ${filesToDownload.length}개 파일 다운로드 필요 (${Math.ceil(filesToDownload.length / BATCH_SIZE)}개 배치)`);
|
|
1205
1312
|
|
|
1206
|
-
|
|
1313
|
+
// Batch API로 파일 다운로드
|
|
1314
|
+
for (let i = 0; i < filesToDownload.length; i += BATCH_SIZE) {
|
|
1315
|
+
const batch = filesToDownload.slice(i, i + BATCH_SIZE);
|
|
1316
|
+
const batchPaths = batch.map(f => f.path);
|
|
1317
|
+
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
|
1318
|
+
const totalBatches = Math.ceil(filesToDownload.length / BATCH_SIZE);
|
|
1319
|
+
|
|
1320
|
+
console.error(`[DocuKing] Batch ${batchNum}/${totalBatches}: ${batch.length}개 파일 다운로드 중...`);
|
|
1207
1321
|
|
|
1208
|
-
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1322
|
+
try {
|
|
1323
|
+
const batchResponse = await fetch(`${API_ENDPOINT}/files/batch-pull`, {
|
|
1324
|
+
method: 'POST',
|
|
1325
|
+
headers: {
|
|
1326
|
+
'Content-Type': 'application/json',
|
|
1327
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
1328
|
+
},
|
|
1329
|
+
body: JSON.stringify({
|
|
1330
|
+
projectId,
|
|
1331
|
+
paths: batchPaths,
|
|
1332
|
+
}),
|
|
1333
|
+
});
|
|
1211
1334
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1335
|
+
if (!batchResponse.ok) {
|
|
1336
|
+
const errorText = await batchResponse.text();
|
|
1337
|
+
console.error(`[DocuKing] Batch Pull 실패: ${errorText}`);
|
|
1338
|
+
// 배치 실패 시 모든 파일 실패 처리
|
|
1339
|
+
for (const file of batch) {
|
|
1340
|
+
results.push({ type: 'fail', path: file.path, error: 'Batch request failed' });
|
|
1341
|
+
failed++;
|
|
1342
|
+
current++;
|
|
1343
|
+
}
|
|
1344
|
+
continue;
|
|
1218
1345
|
}
|
|
1219
|
-
const serverHash = crypto.createHash('sha256').update(serverBuffer).digest('hex');
|
|
1220
1346
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1347
|
+
const batchData = await batchResponse.json();
|
|
1348
|
+
const filesData = batchData.files || [];
|
|
1349
|
+
|
|
1350
|
+
// 파일별 처리
|
|
1351
|
+
for (const file of batch) {
|
|
1352
|
+
current++;
|
|
1353
|
+
const fileData = filesData.find(f => f.path === file.path);
|
|
1354
|
+
const serverMeta = serverPathToMeta[file.path];
|
|
1355
|
+
|
|
1356
|
+
if (!fileData || fileData.error) {
|
|
1357
|
+
results.push({ type: 'fail', path: file.path, error: fileData?.error || 'Not found' });
|
|
1358
|
+
failed++;
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1224
1361
|
|
|
1225
|
-
if (localExists) {
|
|
1226
1362
|
try {
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1363
|
+
const content = fileData.content || '';
|
|
1364
|
+
const encoding = fileData.encoding || 'utf-8';
|
|
1365
|
+
|
|
1366
|
+
// 서버 파일 버퍼
|
|
1367
|
+
let serverBuffer;
|
|
1368
|
+
if (encoding === 'base64') {
|
|
1369
|
+
serverBuffer = Buffer.from(content, 'base64');
|
|
1370
|
+
} else {
|
|
1371
|
+
serverBuffer = Buffer.from(content, 'utf-8');
|
|
1233
1372
|
}
|
|
1234
|
-
const
|
|
1235
|
-
localHash = crypto.createHash('sha256').update(localBuffer).digest('hex');
|
|
1236
|
-
} catch (e) {
|
|
1237
|
-
localExists = false;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1373
|
+
const serverHash = fileData.hash || crypto.createHash('sha256').update(serverBuffer).digest('hex');
|
|
1240
1374
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
// 변경 없음 - 스킵
|
|
1244
|
-
results.push({ type: 'skip', path: file.path });
|
|
1245
|
-
skipped++;
|
|
1246
|
-
continue;
|
|
1247
|
-
}
|
|
1375
|
+
// 로컬 존재 여부
|
|
1376
|
+
const localExists = fs.existsSync(file.fullPath);
|
|
1248
1377
|
|
|
1249
|
-
|
|
1250
|
-
|
|
1378
|
+
// 디렉토리 생성
|
|
1379
|
+
fs.mkdirSync(path.dirname(file.fullPath), { recursive: true });
|
|
1251
1380
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1381
|
+
// 파일 저장
|
|
1382
|
+
if (encoding === 'base64') {
|
|
1383
|
+
fs.writeFileSync(file.fullPath, serverBuffer);
|
|
1384
|
+
} else {
|
|
1385
|
+
fs.writeFileSync(file.fullPath, content, 'utf-8');
|
|
1386
|
+
}
|
|
1258
1387
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1388
|
+
if (localExists) {
|
|
1389
|
+
results.push({ type: 'update', path: file.path });
|
|
1390
|
+
updated++;
|
|
1391
|
+
} else {
|
|
1392
|
+
results.push({ type: 'download', path: file.path });
|
|
1393
|
+
downloaded++;
|
|
1394
|
+
}
|
|
1266
1395
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1396
|
+
// 인덱스에 기록
|
|
1397
|
+
syncedFiles.push({ serverPath: file.path, fullPath: file.fullPath, hash: serverHash });
|
|
1398
|
+
|
|
1399
|
+
// source: 'web' 파일이면 mark-as-pulled 대상
|
|
1400
|
+
if (serverMeta && serverMeta.source === 'web') {
|
|
1401
|
+
filesToMarkAsPulled.push(file.path);
|
|
1402
|
+
}
|
|
1403
|
+
} catch (e) {
|
|
1404
|
+
results.push({ type: 'fail', path: file.path, error: e.message });
|
|
1405
|
+
failed++;
|
|
1406
|
+
}
|
|
1270
1407
|
}
|
|
1271
1408
|
} catch (e) {
|
|
1272
|
-
|
|
1273
|
-
|
|
1409
|
+
console.error(`[DocuKing] Batch ${batchNum} 네트워크 오류: ${e.message}`);
|
|
1410
|
+
// 배치 실패 시 개별 요청으로 폴백
|
|
1411
|
+
for (const file of batch) {
|
|
1412
|
+
current++;
|
|
1413
|
+
try {
|
|
1414
|
+
const response = await fetch(
|
|
1415
|
+
`${API_ENDPOINT}/files?projectId=${projectId}&path=${encodeURIComponent(file.path)}`,
|
|
1416
|
+
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
|
|
1417
|
+
);
|
|
1418
|
+
if (!response.ok) {
|
|
1419
|
+
results.push({ type: 'fail', path: file.path, error: await response.text() });
|
|
1420
|
+
failed++;
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
const data = await response.json();
|
|
1424
|
+
const content = data.file?.content || data.content || '';
|
|
1425
|
+
const encoding = data.file?.encoding || data.encoding || 'utf-8';
|
|
1426
|
+
let serverBuffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8');
|
|
1427
|
+
fs.mkdirSync(path.dirname(file.fullPath), { recursive: true });
|
|
1428
|
+
fs.writeFileSync(file.fullPath, encoding === 'base64' ? serverBuffer : content, encoding === 'base64' ? undefined : 'utf-8');
|
|
1429
|
+
results.push({ type: 'download', path: file.path });
|
|
1430
|
+
downloaded++;
|
|
1431
|
+
} catch (fallbackErr) {
|
|
1432
|
+
results.push({ type: 'fail', path: file.path, error: fallbackErr.message });
|
|
1433
|
+
failed++;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1274
1436
|
}
|
|
1275
1437
|
}
|
|
1276
1438
|
|
|
@@ -1300,6 +1462,16 @@ export async function handlePullInternal(args) {
|
|
|
1300
1462
|
}
|
|
1301
1463
|
}
|
|
1302
1464
|
|
|
1465
|
+
// ========================================
|
|
1466
|
+
// 로컬 인덱스 업데이트 및 저장
|
|
1467
|
+
// ========================================
|
|
1468
|
+
if (syncedFiles.length > 0) {
|
|
1469
|
+
updateIndexAfterSync(localIndex, syncedFiles, []);
|
|
1470
|
+
if (saveIndex(localPath, localIndex)) {
|
|
1471
|
+
console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 파일`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1303
1475
|
// 상세 결과 구성 (Push 스타일)
|
|
1304
1476
|
let resultText = `✓ Pull 완료!
|
|
1305
1477
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocuKing MCP - 로컬 인덱스 캐시 모듈
|
|
3
|
+
*
|
|
4
|
+
* Git의 .git/index처럼 마지막 동기화 상태를 저장하여
|
|
5
|
+
* mtime 기반으로 변경된 파일만 빠르게 탐지합니다.
|
|
6
|
+
*
|
|
7
|
+
* 효과:
|
|
8
|
+
* - 변경되지 않은 파일은 해시 계산 스킵
|
|
9
|
+
* - Pull/Push 시 변경된 파일만 처리
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import crypto from 'crypto';
|
|
15
|
+
|
|
16
|
+
const INDEX_VERSION = 1;
|
|
17
|
+
const INDEX_FILENAME = 'index.json';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 인덱스 파일 경로 반환
|
|
21
|
+
*/
|
|
22
|
+
function getIndexPath(localPath) {
|
|
23
|
+
return path.join(localPath, '.docuking', INDEX_FILENAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 로컬 인덱스 로드
|
|
28
|
+
* @returns {{ version: number, lastSync: string, files: Object }} 인덱스 객체
|
|
29
|
+
*/
|
|
30
|
+
export function loadIndex(localPath) {
|
|
31
|
+
const indexPath = getIndexPath(localPath);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(indexPath)) {
|
|
35
|
+
const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
36
|
+
|
|
37
|
+
// 버전 체크 (호환되지 않으면 초기화)
|
|
38
|
+
if (data.version !== INDEX_VERSION) {
|
|
39
|
+
console.error('[DocuKing] 인덱스 버전 불일치, 재생성합니다.');
|
|
40
|
+
return createEmptyIndex();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error('[DocuKing] 인덱스 로드 실패:', e.message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return createEmptyIndex();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 빈 인덱스 생성
|
|
54
|
+
*/
|
|
55
|
+
function createEmptyIndex() {
|
|
56
|
+
return {
|
|
57
|
+
version: INDEX_VERSION,
|
|
58
|
+
lastSync: null,
|
|
59
|
+
files: {},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 인덱스 저장
|
|
65
|
+
*/
|
|
66
|
+
export function saveIndex(localPath, index) {
|
|
67
|
+
const indexPath = getIndexPath(localPath);
|
|
68
|
+
const indexDir = path.dirname(indexPath);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
if (!fs.existsSync(indexDir)) {
|
|
72
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
index.lastSync = new Date().toISOString();
|
|
76
|
+
fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf-8');
|
|
77
|
+
|
|
78
|
+
return true;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error('[DocuKing] 인덱스 저장 실패:', e.message);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 파일 해시 계산
|
|
87
|
+
*/
|
|
88
|
+
export function computeFileHash(filePath, isBinary = false) {
|
|
89
|
+
try {
|
|
90
|
+
const buffer = fs.readFileSync(filePath);
|
|
91
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* mtime 기반으로 변경된 파일 탐지
|
|
99
|
+
*
|
|
100
|
+
* @param {string} localPath - 프로젝트 루트 경로
|
|
101
|
+
* @param {Object} index - 로컬 인덱스
|
|
102
|
+
* @param {string[]} filePaths - 검사할 파일 경로 목록 (serverPath 형식)
|
|
103
|
+
* @param {Function} getFullPath - serverPath → fullPath 변환 함수
|
|
104
|
+
* @returns {{ changed: Array, unchanged: Array, newFiles: Array, deleted: Array }}
|
|
105
|
+
*/
|
|
106
|
+
export function detectChanges(localPath, index, filePaths, getFullPath) {
|
|
107
|
+
const result = {
|
|
108
|
+
changed: [], // mtime 변경됨 (해시 계산 필요)
|
|
109
|
+
unchanged: [], // mtime 동일 (해시 계산 스킵)
|
|
110
|
+
newFiles: [], // 인덱스에 없음 (새 파일)
|
|
111
|
+
deleted: [], // 로컬에 없음 (삭제됨)
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (const serverPath of filePaths) {
|
|
115
|
+
const fullPath = getFullPath(serverPath);
|
|
116
|
+
|
|
117
|
+
// 로컬 파일 존재 여부 확인
|
|
118
|
+
if (!fs.existsSync(fullPath)) {
|
|
119
|
+
result.deleted.push(serverPath);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 인덱스에 있는지 확인
|
|
124
|
+
const cached = index.files[serverPath];
|
|
125
|
+
if (!cached) {
|
|
126
|
+
result.newFiles.push(serverPath);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// mtime 비교
|
|
131
|
+
try {
|
|
132
|
+
const stat = fs.statSync(fullPath);
|
|
133
|
+
const currentMtime = stat.mtimeMs;
|
|
134
|
+
|
|
135
|
+
if (Math.abs(currentMtime - cached.mtime) < 1000) {
|
|
136
|
+
// mtime 동일 (1초 오차 허용) → 변경 없음
|
|
137
|
+
result.unchanged.push(serverPath);
|
|
138
|
+
} else {
|
|
139
|
+
// mtime 다름 → 변경됨
|
|
140
|
+
result.changed.push(serverPath);
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// stat 실패 → 변경됨으로 처리
|
|
144
|
+
result.changed.push(serverPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 인덱스에 파일 정보 업데이트
|
|
153
|
+
*/
|
|
154
|
+
export function updateIndexEntry(index, serverPath, fullPath, hash) {
|
|
155
|
+
try {
|
|
156
|
+
const stat = fs.statSync(fullPath);
|
|
157
|
+
|
|
158
|
+
index.files[serverPath] = {
|
|
159
|
+
hash,
|
|
160
|
+
mtime: stat.mtimeMs,
|
|
161
|
+
size: stat.size,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return true;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 인덱스에서 파일 정보 삭제
|
|
172
|
+
*/
|
|
173
|
+
export function removeIndexEntry(index, serverPath) {
|
|
174
|
+
delete index.files[serverPath];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 인덱스의 해시와 비교하여 실제 변경 여부 확인
|
|
179
|
+
* (mtime 변경됐지만 내용은 같을 수 있음)
|
|
180
|
+
*
|
|
181
|
+
* @returns {{ actuallyChanged: Array, sameContent: Array }}
|
|
182
|
+
*/
|
|
183
|
+
export function verifyChanges(index, changedPaths, getFullPath) {
|
|
184
|
+
const result = {
|
|
185
|
+
actuallyChanged: [], // 해시도 다름 (실제 변경)
|
|
186
|
+
sameContent: [], // 해시 동일 (mtime만 변경)
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
for (const serverPath of changedPaths) {
|
|
190
|
+
const fullPath = getFullPath(serverPath);
|
|
191
|
+
const cached = index.files[serverPath];
|
|
192
|
+
|
|
193
|
+
if (!cached) {
|
|
194
|
+
result.actuallyChanged.push(serverPath);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const currentHash = computeFileHash(fullPath);
|
|
199
|
+
|
|
200
|
+
if (currentHash === cached.hash) {
|
|
201
|
+
result.sameContent.push(serverPath);
|
|
202
|
+
} else {
|
|
203
|
+
result.actuallyChanged.push(serverPath);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 동기화 후 인덱스 일괄 업데이트
|
|
212
|
+
*
|
|
213
|
+
* @param {Object} index - 인덱스 객체
|
|
214
|
+
* @param {Array} syncedFiles - 동기화된 파일 목록 [{ serverPath, fullPath, hash }]
|
|
215
|
+
* @param {Array} deletedPaths - 삭제된 파일 경로 목록
|
|
216
|
+
*/
|
|
217
|
+
export function updateIndexAfterSync(index, syncedFiles, deletedPaths = []) {
|
|
218
|
+
// 동기화된 파일 업데이트
|
|
219
|
+
for (const file of syncedFiles) {
|
|
220
|
+
updateIndexEntry(index, file.serverPath, file.fullPath, file.hash);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 삭제된 파일 제거
|
|
224
|
+
for (const serverPath of deletedPaths) {
|
|
225
|
+
removeIndexEntry(index, serverPath);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 인덱스 통계 반환
|
|
231
|
+
*/
|
|
232
|
+
export function getIndexStats(index) {
|
|
233
|
+
const files = Object.keys(index.files);
|
|
234
|
+
const totalSize = Object.values(index.files).reduce((sum, f) => sum + (f.size || 0), 0);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
fileCount: files.length,
|
|
238
|
+
totalSize,
|
|
239
|
+
totalSizeMB: (totalSize / 1024 / 1024).toFixed(2),
|
|
240
|
+
lastSync: index.lastSync,
|
|
241
|
+
};
|
|
242
|
+
}
|