docuking-mcp 2.12.0 → 2.14.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.
Files changed (2) hide show
  1. package/handlers/sync.js +206 -189
  2. package/package.json +1 -1
package/handlers/sync.js CHANGED
@@ -806,82 +806,23 @@ docuking_init을 먼저 실행하세요.`,
806
806
  }
807
807
 
808
808
  // ========================================
809
- // 4. 서버에만 있고 로컬에 없는 파일 soft-delete (deleted_at 기반)
809
+ // 4. 자동 삭제 비활성화 (2026-01-17 버그 수정)
810
+ // ========================================
811
+ // 이전 로직: "서버에 있고 로컬에 없으면 삭제"
812
+ // 문제: 다른 프로젝트에서 Pull하면 서버 파일이 삭제됨
813
+ // 해결: 자동 삭제 제거, docuking_delete로만 명시적 삭제
814
+ //
815
+ // 삭제는 명시적으로만:
816
+ // - 사용자가 "이 파일 삭제해줘" 요청 시
817
+ // - docuking_delete MCP 도구 사용
810
818
  // ========================================
811
- // 단, 협업자 폴더(yy_Coworker_*)는 삭제하지 않음 (오너가 협업자 파일을 삭제하면 안됨)
812
- // source: 'web' 파일은 MCP에서 삭제 시도하지 않음 (클라이언트에서 필터링)
813
- // 중요: localPathsBeforePull 사용 (Pull 전에 수집한 목록)
814
- // processedLocalPaths는 Pull로 내려받은 파일도 포함하므로 사용 금지
815
819
  let deleted = 0;
816
820
  const deletedFilePaths = [];
817
- let protectedFiles = []; // source: 'web' 파일 (보호됨)
818
- const webProtectedPaths = []; // MCP에서 미리 필터링한 웹 파일
819
-
820
- if (serverAllPaths.length > 0 && !isCoworker) {
821
- // 오너만 삭제 수행
822
- // yy_Coworker_*로 시작하는 경로는 삭제 대상에서 제외
823
- // localPathsBeforePull: Pull 전에 수집한 로컬 파일 목록 (좀비 파일 방지)
824
- //
825
- // ★ 핵심: source: 'web' 파일은 삭제 대상에서 미리 제외!
826
- // 이렇게 하면 웹에서 생성한 파일이 절대 삭제되지 않음
827
- const pathsToDelete = serverAllPaths.filter(p => {
828
- // 1. 로컬에 있으면 제외 (삭제 대상 아님)
829
- if (localPathsBeforePull.has(p)) return false;
830
-
831
- // 2. 협업자 폴더는 제외
832
- if (p.startsWith('yy_Coworker_')) return false;
833
-
834
- // 3. source: 'web' 파일은 제외 (웹에서 생성된 파일 보호)
835
- const meta = serverPathToMeta[p];
836
- if (meta && meta.source === 'web') {
837
- webProtectedPaths.push(p);
838
- return false;
839
- }
840
-
841
- return true;
842
- });
843
-
844
- // 웹 보호 파일 로그
845
- if (webProtectedPaths.length > 0) {
846
- console.error(`[DocuKing] source:web 파일 ${webProtectedPaths.length}개 삭제에서 제외:`, webProtectedPaths.slice(0, 5));
847
- }
848
-
849
- console.error(`[DocuKing] soft-delete 대상: ${pathsToDelete.length}개 (서버에만 있고 Pull 전 로컬에 없던 파일, source:local만)`);
850
-
851
- if (pathsToDelete.length > 0) {
852
- try {
853
- // soft-delete API 호출 (deleted_at 설정, 3일 후 hard delete)
854
- const deleteResponse = await fetch(`${API_ENDPOINT}/files/soft-delete`, {
855
- method: 'POST',
856
- headers: {
857
- 'Content-Type': 'application/json',
858
- 'Authorization': `Bearer ${apiKey}`,
859
- },
860
- body: JSON.stringify({
861
- projectId,
862
- paths: pathsToDelete,
863
- }),
864
- });
865
-
866
- if (deleteResponse.ok) {
867
- const deleteResult = await deleteResponse.json();
868
- deleted = deleteResult.deleted || 0;
869
- deletedFilePaths.push(...(deleteResult.deletedPaths || []));
870
- protectedFiles = deleteResult.protected || [];
871
-
872
- // 보호된 파일이 있으면 로그 출력 (Backend에서 추가로 보호한 파일)
873
- if (protectedFiles.length > 0) {
874
- console.error(`[DocuKing] Backend에서 추가 보호된 파일 ${protectedFiles.length}개:`, protectedFiles.slice(0, 5));
875
- }
876
- }
877
- } catch (e) {
878
- console.error('[DocuKing] 파일 soft-delete 실패:', e.message);
879
- }
880
- }
821
+ let protectedFiles = [];
881
822
 
882
- // MCP에서 미리 제외한 파일도 보호 목록에 추가 (결과 표시용)
883
- protectedFiles = [...protectedFiles, ...webProtectedPaths];
884
- }
823
+ // 자동 삭제 로직 비활성화 - 아래 코드는 주석 처리
824
+ // "로컬에 없으면 삭제" 로직은 위험하므로 제거
825
+ console.error(`[DocuKing] 자동 삭제 비활성화됨 (명시적 삭제만 허용: docuking_delete 사용)`);
885
826
 
886
827
  // 5. 빈 폴더 생성
887
828
  let createdEmptyFolders = 0;
@@ -1227,14 +1168,48 @@ export async function handlePullInternal(args) {
1227
1168
  const localIndex = loadIndex(localPath);
1228
1169
  const syncedFiles = []; // 다운로드/업데이트된 파일 목록
1229
1170
 
1230
- for (const file of sortedFiles) {
1171
+ // ========================================
1172
+ // 폴더 먼저 처리
1173
+ // ========================================
1174
+ const folders = sortedFiles.filter(f => f.type === 'folder');
1175
+ const files_only = sortedFiles.filter(f => f.type !== 'folder');
1176
+
1177
+ for (const folder of folders) {
1231
1178
  current++;
1232
- const progress = `${current}/${total}`;
1179
+ const serverMeta = serverPathToMeta[folder.path];
1180
+ let fullPath;
1181
+ if (folder.path.startsWith('xx_') ||
1182
+ folder.path.startsWith('yy_All_Docu/') ||
1183
+ folder.path.startsWith('yy_All_Docu') ||
1184
+ folder.path.startsWith('yy_Coworker_') ||
1185
+ folder.path.startsWith('zz_ai_')) {
1186
+ fullPath = path.join(localPath, folder.path);
1187
+ } else {
1188
+ fullPath = path.join(mainFolderPath, folder.path);
1189
+ }
1190
+
1191
+ if (fs.existsSync(fullPath)) {
1192
+ results.push({ type: 'skip', path: folder.path, itemType: 'folder' });
1193
+ skipped++;
1194
+ } else {
1195
+ fs.mkdirSync(fullPath, { recursive: true });
1196
+ results.push({ type: 'download', path: folder.path, itemType: 'folder' });
1197
+ foldersCreated++;
1198
+ downloaded++;
1199
+ if (serverMeta && serverMeta.source === 'web') {
1200
+ filesToMarkAsPulled.push(folder.path);
1201
+ }
1202
+ }
1203
+ }
1233
1204
 
1234
- // 서버 메타 정보 조회
1235
- const serverMeta = serverPathToMeta[file.path];
1205
+ // ========================================
1206
+ // 파일 처리 - Batch API 사용 (200개씩)
1207
+ // ========================================
1208
+ const BATCH_SIZE = 200;
1236
1209
 
1237
- // 서버 경로에 따라 로컬 저장 위치 결정
1210
+ // 먼저 로컬 해시 기반으로 스킵할 파일 필터링
1211
+ const filesToDownload = [];
1212
+ for (const file of files_only) {
1238
1213
  let fullPath;
1239
1214
  if (file.path.startsWith('xx_') ||
1240
1215
  file.path.startsWith('yy_All_Docu/') ||
@@ -1246,146 +1221,188 @@ export async function handlePullInternal(args) {
1246
1221
  fullPath = path.join(mainFolderPath, file.path);
1247
1222
  }
1248
1223
 
1249
- try {
1250
- // ========================================
1251
- // 폴더 처리 (type === 'folder')
1252
- // ========================================
1253
- if (file.type === 'folder') {
1254
- if (fs.existsSync(fullPath)) {
1255
- // 이미 있음 → 스킵
1256
- results.push({ type: 'skip', path: file.path, itemType: 'folder' });
1257
- skipped++;
1258
- } else {
1259
- // 폴더 생성
1260
- fs.mkdirSync(fullPath, { recursive: true });
1261
- results.push({ type: 'download', path: file.path, itemType: 'folder' });
1262
- foldersCreated++;
1263
- downloaded++;
1224
+ const serverHash = serverPathToHash[file.path];
1264
1225
 
1265
- // source: 'web' 폴더면 mark-as-pulled 대상
1266
- if (serverMeta && serverMeta.source === 'web') {
1267
- filesToMarkAsPulled.push(file.path);
1268
- }
1226
+ // 로컬 파일 해시 비교
1227
+ if (fs.existsSync(fullPath)) {
1228
+ try {
1229
+ const localStat = fs.statSync(fullPath);
1230
+ if (localStat.isDirectory()) {
1231
+ results.push({ type: 'skip', path: file.path, error: '같은 이름의 폴더 존재' });
1232
+ skipped++;
1233
+ current++;
1234
+ continue;
1269
1235
  }
1270
- continue;
1271
- }
1272
-
1273
- // ========================================
1274
- // 파일 처리 (type === 'file')
1275
- // ========================================
1276
- const response = await fetch(
1277
- `${API_ENDPOINT}/files?projectId=${projectId}&path=${encodeURIComponent(file.path)}`,
1278
- {
1279
- headers: {
1280
- 'Authorization': `Bearer ${apiKey}`,
1281
- },
1236
+ const localBuffer = fs.readFileSync(fullPath);
1237
+ const localHash = crypto.createHash('sha256').update(localBuffer).digest('hex');
1238
+ if (localHash === serverHash) {
1239
+ results.push({ type: 'skip', path: file.path });
1240
+ skipped++;
1241
+ current++;
1242
+ continue;
1282
1243
  }
1283
- );
1284
1244
 
1285
- if (!response.ok) {
1286
- results.push({ type: 'fail', path: file.path, error: await response.text() });
1287
- failed++;
1245
+ // ========================================
1246
+ // [버그 수정] 로컬 파일 우선 정책
1247
+ // 해시가 다르면 → 로컬 수정이 있다는 뜻
1248
+ // 서버 파일로 덮어쓰지 않고 로컬 유지
1249
+ // 다음 Push에서 로컬 → 서버로 업로드됨
1250
+ // ========================================
1251
+ console.error(`[DocuKing] 로컬 우선: ${file.path} (해시 다름 → 로컬 유지, Push 시 업로드됨)`);
1252
+ results.push({ type: 'skip', path: file.path, reason: 'local-modified' });
1253
+ skipped++;
1254
+ current++;
1288
1255
  continue;
1256
+ } catch (e) {
1257
+ // 해시 계산 실패 시 다운로드 대상
1289
1258
  }
1259
+ }
1290
1260
 
1291
- const data = await response.json();
1261
+ // 로컬에 파일이 없는 경우만 다운로드
1262
+ filesToDownload.push({ ...file, fullPath });
1263
+ }
1292
1264
 
1293
- // 인코딩에 따라 저장
1294
- const content = data.file?.content || data.content || '';
1295
- const encoding = data.file?.encoding || data.encoding || 'utf-8';
1265
+ console.error(`[DocuKing] Batch Pull: ${filesToDownload.length}개 파일 다운로드 필요 (${Math.ceil(filesToDownload.length / BATCH_SIZE)}개 배치)`);
1266
+
1267
+ // Batch API로 파일 다운로드
1268
+ for (let i = 0; i < filesToDownload.length; i += BATCH_SIZE) {
1269
+ const batch = filesToDownload.slice(i, i + BATCH_SIZE);
1270
+ const batchPaths = batch.map(f => f.path);
1271
+ const batchNum = Math.floor(i / BATCH_SIZE) + 1;
1272
+ const totalBatches = Math.ceil(filesToDownload.length / BATCH_SIZE);
1273
+
1274
+ console.error(`[DocuKing] Batch ${batchNum}/${totalBatches}: ${batch.length}개 파일 다운로드 중...`);
1275
+
1276
+ try {
1277
+ const batchResponse = await fetch(`${API_ENDPOINT}/files/batch-pull`, {
1278
+ method: 'POST',
1279
+ headers: {
1280
+ 'Content-Type': 'application/json',
1281
+ 'Authorization': `Bearer ${apiKey}`,
1282
+ },
1283
+ body: JSON.stringify({
1284
+ projectId,
1285
+ paths: batchPaths,
1286
+ }),
1287
+ });
1296
1288
 
1297
- // 서버 파일 해시 계산
1298
- let serverBuffer;
1299
- if (encoding === 'base64') {
1300
- serverBuffer = Buffer.from(content, 'base64');
1301
- } else {
1302
- serverBuffer = Buffer.from(content, 'utf-8');
1289
+ if (!batchResponse.ok) {
1290
+ const errorText = await batchResponse.text();
1291
+ console.error(`[DocuKing] Batch Pull 실패: ${errorText}`);
1292
+ // 배치 실패 시 모든 파일 실패 처리
1293
+ for (const file of batch) {
1294
+ results.push({ type: 'fail', path: file.path, error: 'Batch request failed' });
1295
+ failed++;
1296
+ current++;
1297
+ }
1298
+ continue;
1303
1299
  }
1304
- const serverHash = crypto.createHash('sha256').update(serverBuffer).digest('hex');
1305
1300
 
1306
- // 로컬 파일 존재 여부 및 해시 비교
1307
- let localExists = fs.existsSync(fullPath);
1308
- let localHash = null;
1301
+ const batchData = await batchResponse.json();
1302
+ const filesData = batchData.files || [];
1303
+
1304
+ // 파일별 처리
1305
+ for (const file of batch) {
1306
+ current++;
1307
+ const fileData = filesData.find(f => f.path === file.path);
1308
+ const serverMeta = serverPathToMeta[file.path];
1309
+
1310
+ if (!fileData || fileData.error) {
1311
+ results.push({ type: 'fail', path: file.path, error: fileData?.error || 'Not found' });
1312
+ failed++;
1313
+ continue;
1314
+ }
1309
1315
 
1310
- if (localExists) {
1311
1316
  try {
1312
- const localStat = fs.statSync(fullPath);
1313
- if (localStat.isDirectory()) {
1314
- // 로컬에 같은 이름의 폴더가 있으면 건너뜀
1315
- results.push({ type: 'skip', path: file.path, error: '같은 이름의 폴더 존재' });
1316
- skipped++;
1317
- continue;
1317
+ const content = fileData.content || '';
1318
+ const encoding = fileData.encoding || 'utf-8';
1319
+
1320
+ // 서버 파일 버퍼
1321
+ let serverBuffer;
1322
+ if (encoding === 'base64') {
1323
+ serverBuffer = Buffer.from(content, 'base64');
1324
+ } else {
1325
+ serverBuffer = Buffer.from(content, 'utf-8');
1318
1326
  }
1319
- const localBuffer = fs.readFileSync(fullPath);
1320
- localHash = crypto.createHash('sha256').update(localBuffer).digest('hex');
1321
- } catch (e) {
1322
- localExists = false;
1323
- }
1324
- }
1327
+ const serverHash = fileData.hash || crypto.createHash('sha256').update(serverBuffer).digest('hex');
1325
1328
 
1326
- // 변경 감지
1327
- if (localExists && localHash === serverHash) {
1328
- // 변경 없음 - 스킵
1329
- results.push({ type: 'skip', path: file.path });
1330
- skipped++;
1331
- continue;
1332
- }
1329
+ // 로컬 존재 여부
1330
+ const localExists = fs.existsSync(file.fullPath);
1333
1331
 
1334
- // 디렉토리 생성
1335
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
1332
+ // 디렉토리 생성
1333
+ fs.mkdirSync(path.dirname(file.fullPath), { recursive: true });
1336
1334
 
1337
- // 파일 저장
1338
- if (encoding === 'base64') {
1339
- fs.writeFileSync(fullPath, serverBuffer);
1340
- } else {
1341
- fs.writeFileSync(fullPath, content, 'utf-8');
1342
- }
1335
+ // 파일 저장
1336
+ if (encoding === 'base64') {
1337
+ fs.writeFileSync(file.fullPath, serverBuffer);
1338
+ } else {
1339
+ fs.writeFileSync(file.fullPath, content, 'utf-8');
1340
+ }
1343
1341
 
1344
- if (localExists) {
1345
- results.push({ type: 'update', path: file.path });
1346
- updated++;
1347
- } else {
1348
- results.push({ type: 'download', path: file.path });
1349
- downloaded++;
1350
- }
1342
+ if (localExists) {
1343
+ results.push({ type: 'update', path: file.path });
1344
+ updated++;
1345
+ } else {
1346
+ results.push({ type: 'download', path: file.path });
1347
+ downloaded++;
1348
+ }
1351
1349
 
1352
- // 인덱스에 다운로드된 파일 기록
1353
- syncedFiles.push({ serverPath: file.path, fullPath, hash: serverHash });
1350
+ // 인덱스에 기록
1351
+ syncedFiles.push({ serverPath: file.path, fullPath: file.fullPath, hash: serverHash });
1354
1352
 
1355
- // source: 'web' 파일이면 mark-as-pulled 대상
1356
- if (serverMeta && serverMeta.source === 'web') {
1357
- filesToMarkAsPulled.push(file.path);
1353
+ // source: 'web' 파일이면 mark-as-pulled 대상
1354
+ if (serverMeta && serverMeta.source === 'web') {
1355
+ filesToMarkAsPulled.push(file.path);
1356
+ }
1357
+ } catch (e) {
1358
+ results.push({ type: 'fail', path: file.path, error: e.message });
1359
+ failed++;
1360
+ }
1358
1361
  }
1359
1362
  } catch (e) {
1360
- results.push({ type: 'fail', path: file.path, error: e.message });
1361
- failed++;
1363
+ console.error(`[DocuKing] Batch ${batchNum} 네트워크 오류: ${e.message}`);
1364
+ // 배치 실패 시 개별 요청으로 폴백
1365
+ for (const file of batch) {
1366
+ current++;
1367
+ try {
1368
+ const response = await fetch(
1369
+ `${API_ENDPOINT}/files?projectId=${projectId}&path=${encodeURIComponent(file.path)}`,
1370
+ { headers: { 'Authorization': `Bearer ${apiKey}` } }
1371
+ );
1372
+ if (!response.ok) {
1373
+ results.push({ type: 'fail', path: file.path, error: await response.text() });
1374
+ failed++;
1375
+ continue;
1376
+ }
1377
+ const data = await response.json();
1378
+ const content = data.file?.content || data.content || '';
1379
+ const encoding = data.file?.encoding || data.encoding || 'utf-8';
1380
+ let serverBuffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8');
1381
+ fs.mkdirSync(path.dirname(file.fullPath), { recursive: true });
1382
+ fs.writeFileSync(file.fullPath, encoding === 'base64' ? serverBuffer : content, encoding === 'base64' ? undefined : 'utf-8');
1383
+ results.push({ type: 'download', path: file.path });
1384
+ downloaded++;
1385
+ } catch (fallbackErr) {
1386
+ results.push({ type: 'fail', path: file.path, error: fallbackErr.message });
1387
+ failed++;
1388
+ }
1389
+ }
1362
1390
  }
1363
1391
  }
1364
1392
 
1365
1393
  // ========================================
1366
- // mark-as-pulled 호출 (source: 'web' → 'local' 변경)
1367
- // Pull된 웹 파일/폴더는 이제 로컬이 관리권한을 가짐
1394
+ // mark-as-pulled 비활성화 (2026-01-17 정책 변경)
1395
+ // ========================================
1396
+ // 이전 로직: source를 'web' → 'local'로 변경
1397
+ // 문제: source는 "출생신고"로, 한 번 정해지면 불변이어야 함
1398
+ // 변경하면 "출처 위조"가 됨
1399
+ //
1400
+ // 새 정책: source는 절대 변경하지 않음
1401
+ // - web에서 태어난 파일은 Pull해도 source: 'web' 유지
1402
+ // - local에서 태어난 파일은 Push해도 source: 'local' 유지
1368
1403
  // ========================================
1369
1404
  if (filesToMarkAsPulled.length > 0) {
1370
- try {
1371
- const markResponse = await fetch(`${API_ENDPOINT}/files/mark-as-pulled`, {
1372
- method: 'POST',
1373
- headers: {
1374
- 'Content-Type': 'application/json',
1375
- 'Authorization': `Bearer ${apiKey}`,
1376
- },
1377
- body: JSON.stringify({
1378
- projectId,
1379
- paths: filesToMarkAsPulled,
1380
- }),
1381
- });
1382
-
1383
- if (markResponse.ok) {
1384
- console.error(`[DocuKing] mark-as-pulled 완료: ${filesToMarkAsPulled.length}개`);
1385
- }
1386
- } catch (e) {
1387
- console.error('[DocuKing] mark-as-pulled 실패:', e.message);
1388
- }
1405
+ console.error(`[DocuKing] mark-as-pulled 비활성화됨 (source 불변 정책): ${filesToMarkAsPulled.length}개 파일의 source 유지`);
1389
1406
  }
1390
1407
 
1391
1408
  // ========================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docuking-mcp",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "DocuKing MCP Server - AI 시대의 문서 협업 플랫폼",
5
5
  "type": "module",
6
6
  "main": "index.js",