docuking-mcp 2.9.3 → 2.10.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 CHANGED
@@ -540,9 +540,10 @@ docuking_init을 먼저 실행하세요.`,
540
540
  console.error('[DocuKing] Sync 시작 알림 실패:', e.message);
541
541
  }
542
542
 
543
- // 서버에서 파일 해시 조회 (Git 스타일 동기화용)
543
+ // 서버에서 파일 해시 및 메타 정보 조회 (Git 스타일 동기화용)
544
544
  let serverPathToHash = {};
545
545
  let serverHashToPath = {};
546
+ let serverPathToMeta = {}; // source, type, deletedAt 정보 포함
546
547
  let serverAllPaths = [];
547
548
  try {
548
549
  const hashResponse = await fetch(
@@ -557,6 +558,7 @@ docuking_init을 먼저 실행하세요.`,
557
558
  const hashData = await hashResponse.json();
558
559
  serverPathToHash = hashData.pathToHash || {};
559
560
  serverHashToPath = hashData.hashToPath || {};
561
+ serverPathToMeta = hashData.pathToMeta || {};
560
562
  serverAllPaths = hashData.allPaths || [];
561
563
  }
562
564
  } catch (e) {
@@ -734,24 +736,48 @@ docuking_init을 먼저 실행하세요.`,
734
736
  }
735
737
  }
736
738
 
739
+ // ========================================
737
740
  // 4. 서버에만 있고 로컬에 없는 파일 soft-delete (deleted_at 기반)
741
+ // ========================================
738
742
  // 단, 협업자 폴더(yy_Coworker_*)는 삭제하지 않음 (오너가 협업자 파일을 삭제하면 안됨)
739
- // 기존 hard delete(delete-batch) soft delete(soft-delete)로 변경
740
- // 3일 후 크론잡에서 hard delete 수행
743
+ // source: 'web' 파일은 MCP에서 삭제 시도하지 않음 (클라이언트에서 필터링)
741
744
  // 중요: localPathsBeforePull 사용 (Pull 전에 수집한 목록)
742
745
  // processedLocalPaths는 Pull로 내려받은 파일도 포함하므로 사용 금지
743
746
  let deleted = 0;
744
747
  const deletedFilePaths = [];
745
748
  let protectedFiles = []; // source: 'web' 파일 (보호됨)
749
+ const webProtectedPaths = []; // MCP에서 미리 필터링한 웹 파일
750
+
746
751
  if (serverAllPaths.length > 0 && !isCoworker) {
747
- // 코워커가 아닌 경우에만 삭제 수행 (오너 전용)
752
+ // 오너만 삭제 수행
748
753
  // yy_Coworker_*로 시작하는 경로는 삭제 대상에서 제외
749
754
  // localPathsBeforePull: Pull 전에 수집한 로컬 파일 목록 (좀비 파일 방지)
750
- const pathsToDelete = serverAllPaths.filter(p =>
751
- !localPathsBeforePull.has(p) && !p.startsWith('yy_Coworker_')
752
- );
755
+ //
756
+ // 핵심: source: 'web' 파일은 삭제 대상에서 미리 제외!
757
+ // 이렇게 하면 웹에서 생성한 파일이 절대 삭제되지 않음
758
+ const pathsToDelete = serverAllPaths.filter(p => {
759
+ // 1. 로컬에 있으면 제외 (삭제 대상 아님)
760
+ if (localPathsBeforePull.has(p)) return false;
761
+
762
+ // 2. 협업자 폴더는 제외
763
+ if (p.startsWith('yy_Coworker_')) return false;
764
+
765
+ // 3. source: 'web' 파일은 제외 (웹에서 생성된 파일 보호)
766
+ const meta = serverPathToMeta[p];
767
+ if (meta && meta.source === 'web') {
768
+ webProtectedPaths.push(p);
769
+ return false;
770
+ }
753
771
 
754
- console.error(`[DocuKing] soft-delete 대상: ${pathsToDelete.length}개 (서버에만 있고 Pull 전 로컬에 없던 파일)`);
772
+ return true;
773
+ });
774
+
775
+ // 웹 보호 파일 로그
776
+ if (webProtectedPaths.length > 0) {
777
+ console.error(`[DocuKing] source:web 파일 ${webProtectedPaths.length}개 삭제에서 제외:`, webProtectedPaths.slice(0, 5));
778
+ }
779
+
780
+ console.error(`[DocuKing] soft-delete 대상: ${pathsToDelete.length}개 (서버에만 있고 Pull 전 로컬에 없던 파일, source:local만)`);
755
781
 
756
782
  if (pathsToDelete.length > 0) {
757
783
  try {
@@ -774,15 +800,18 @@ docuking_init을 먼저 실행하세요.`,
774
800
  deletedFilePaths.push(...(deleteResult.deletedPaths || []));
775
801
  protectedFiles = deleteResult.protected || [];
776
802
 
777
- // 보호된 파일이 있으면 로그 출력
803
+ // 보호된 파일이 있으면 로그 출력 (Backend에서 추가로 보호한 파일)
778
804
  if (protectedFiles.length > 0) {
779
- console.error(`[DocuKing] 보호된 파일 ${protectedFiles.length} (source: web):`, protectedFiles.slice(0, 5));
805
+ console.error(`[DocuKing] Backend에서 추가 보호된 파일 ${protectedFiles.length}개:`, protectedFiles.slice(0, 5));
780
806
  }
781
807
  }
782
808
  } catch (e) {
783
809
  console.error('[DocuKing] 파일 soft-delete 실패:', e.message);
784
810
  }
785
811
  }
812
+
813
+ // MCP에서 미리 제외한 웹 파일도 보호 목록에 추가 (결과 표시용)
814
+ protectedFiles = [...protectedFiles, ...webProtectedPaths];
786
815
  }
787
816
 
788
817
  // 5. 빈 폴더 생성
@@ -1018,54 +1047,79 @@ export async function handlePullInternal(args) {
1018
1047
  fs.mkdirSync(mainFolderPath, { recursive: true });
1019
1048
  }
1020
1049
 
1021
- // 파일 목록 조회
1022
- let files = [];
1023
- let deletedFiles = []; // 삭제된 파일 목록 (deleted_at 기반)
1050
+ // ========================================
1051
+ // 서버 메타정보 조회 (pathToMeta: source, type, deletedAt 포함)
1052
+ // ========================================
1053
+ let serverPathToHash = {};
1054
+ let serverHashToPath = {};
1055
+ let serverPathToMeta = {};
1056
+ let serverAllPaths = [];
1057
+ let deletedFiles = [];
1024
1058
 
1025
1059
  try {
1026
- const response = await fetch(
1027
- `${API_ENDPOINT}/files/tree?projectId=${projectId}`,
1060
+ const hashResponse = await fetch(
1061
+ `${API_ENDPOINT}/files/hashes-for-sync?projectId=${projectId}`,
1028
1062
  {
1029
1063
  headers: {
1030
1064
  'Authorization': `Bearer ${apiKey}`,
1031
1065
  },
1032
1066
  }
1033
1067
  );
1034
-
1035
- if (!response.ok) {
1036
- throw new Error(await response.text());
1068
+ if (hashResponse.ok) {
1069
+ const hashData = await hashResponse.json();
1070
+ serverPathToHash = hashData.pathToHash || {};
1071
+ serverHashToPath = hashData.hashToPath || {};
1072
+ serverPathToMeta = hashData.pathToMeta || {};
1073
+ serverAllPaths = hashData.allPaths || [];
1074
+ deletedFiles = hashData.deletedFiles || [];
1037
1075
  }
1038
-
1039
- const data = await response.json();
1040
- files = flattenTree(data.tree || []);
1041
1076
  } catch (e) {
1042
- throw new Error(`파일 목록 조회 실패 - ${e.message}`);
1077
+ console.error('[DocuKing] 서버 메타정보 조회 실패:', e.message);
1043
1078
  }
1044
1079
 
1045
- // 서버에서 삭제된 파일 목록 조회 (deleted_at 기반 동기화)
1080
+ // 파일 트리 조회 (폴더 포함)
1081
+ let files = [];
1046
1082
  try {
1047
- const hashResponse = await fetch(
1048
- `${API_ENDPOINT}/files/hashes-for-sync?projectId=${projectId}`,
1083
+ const response = await fetch(
1084
+ `${API_ENDPOINT}/files/tree?projectId=${projectId}`,
1049
1085
  {
1050
1086
  headers: {
1051
1087
  'Authorization': `Bearer ${apiKey}`,
1052
1088
  },
1053
1089
  }
1054
1090
  );
1055
- if (hashResponse.ok) {
1056
- const hashData = await hashResponse.json();
1057
- deletedFiles = hashData.deletedFiles || [];
1091
+
1092
+ if (!response.ok) {
1093
+ throw new Error(await response.text());
1058
1094
  }
1095
+
1096
+ const data = await response.json();
1097
+ files = flattenTree(data.tree || [], '', true); // 폴더 포함
1059
1098
  } catch (e) {
1060
- console.error('[DocuKing] 삭제 파일 목록 조회 실패:', e.message);
1099
+ throw new Error(`파일 목록 조회 실패 - ${e.message}`);
1061
1100
  }
1062
1101
 
1063
1102
  if (filePath) {
1064
1103
  files = files.filter(f => f.path === filePath || f.path.startsWith(filePath + '/'));
1065
1104
  }
1066
1105
 
1067
- if (files.length === 0) {
1068
- // 파일은 에러가 아님 - 정상 결과 반환
1106
+ // ========================================
1107
+ // 완전판 Pull 로직
1108
+ // 1. 폴더 먼저 정렬 (상위 폴더 → 하위 폴더)
1109
+ // 2. 파일 다운로드 (source:web 파일도 Pull)
1110
+ // 3. mark-as-pulled 호출 (source: web → local 변경)
1111
+ // ========================================
1112
+
1113
+ // 폴더를 먼저 정렬 (상위 폴더부터 생성해야 함)
1114
+ const sortedFiles = [...files].sort((a, b) => {
1115
+ // 폴더 먼저
1116
+ if (a.type === 'folder' && b.type !== 'folder') return -1;
1117
+ if (a.type !== 'folder' && b.type === 'folder') return 1;
1118
+ // 경로 깊이 순 (상위 먼저)
1119
+ return a.path.split('/').length - b.path.split('/').length;
1120
+ });
1121
+
1122
+ if (sortedFiles.length === 0 && deletedFiles.length === 0) {
1069
1123
  return {
1070
1124
  text: 'Pull할 파일이 없습니다.',
1071
1125
  total: 0,
@@ -1076,20 +1130,64 @@ export async function handlePullInternal(args) {
1076
1130
  };
1077
1131
  }
1078
1132
 
1079
- // 파일 다운로드
1080
1133
  const results = [];
1081
- const total = files.length;
1134
+ const total = sortedFiles.length;
1082
1135
  let current = 0;
1083
1136
  let downloaded = 0; // 새로 다운로드
1084
1137
  let updated = 0; // 업데이트 (변경됨)
1085
1138
  let skipped = 0; // 스킵 (변경 없음)
1086
1139
  let failed = 0; // 실패
1140
+ let foldersCreated = 0; // 생성된 폴더
1087
1141
 
1088
- for (const file of files) {
1142
+ // Pull 후 mark-as-pulled할 파일 목록 (source: web 파일들)
1143
+ const filesToMarkAsPulled = [];
1144
+
1145
+ for (const file of sortedFiles) {
1089
1146
  current++;
1090
1147
  const progress = `${current}/${total}`;
1091
1148
 
1149
+ // 서버 메타 정보 조회
1150
+ const serverMeta = serverPathToMeta[file.path];
1151
+
1152
+ // 서버 경로에 따라 로컬 저장 위치 결정
1153
+ let fullPath;
1154
+ if (file.path.startsWith('xx_') ||
1155
+ file.path.startsWith('yy_All_Docu/') ||
1156
+ file.path.startsWith('yy_All_Docu') ||
1157
+ file.path.startsWith('yy_Coworker_') ||
1158
+ file.path.startsWith('zz_ai_')) {
1159
+ fullPath = path.join(localPath, file.path);
1160
+ } else {
1161
+ fullPath = path.join(mainFolderPath, file.path);
1162
+ }
1163
+
1092
1164
  try {
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++;
1179
+
1180
+ // source: 'web' 폴더면 mark-as-pulled 대상
1181
+ if (serverMeta && serverMeta.source === 'web') {
1182
+ filesToMarkAsPulled.push(file.path);
1183
+ }
1184
+ }
1185
+ continue;
1186
+ }
1187
+
1188
+ // ========================================
1189
+ // 파일 처리 (type === 'file')
1190
+ // ========================================
1093
1191
  const response = await fetch(
1094
1192
  `${API_ENDPOINT}/files?projectId=${projectId}&path=${encodeURIComponent(file.path)}`,
1095
1193
  {
@@ -1107,25 +1205,6 @@ export async function handlePullInternal(args) {
1107
1205
 
1108
1206
  const data = await response.json();
1109
1207
 
1110
- // 서버 경로에 따라 로컬 저장 위치 결정
1111
- // 프로젝트 루트 기준 절대경로 그대로 사용
1112
- // - xx_Infra_Config/xxx → xx_Infra_Config/xxx (시스템/인프라)
1113
- // - xx_Policy/xxx → xx_Policy/xxx (정책)
1114
- // - yy_All_Docu/xxx → yy_All_Docu/xxx (오너 문서)
1115
- // - yy_Coworker_xxx/yyy → yy_Coworker_xxx/yyy (협업자 폴더)
1116
- // - zz_ai_xxx/yyy → zz_ai_xxx/yyy (AI 폴더)
1117
- let fullPath;
1118
- if (file.path.startsWith('xx_') ||
1119
- file.path.startsWith('yy_All_Docu/') ||
1120
- file.path.startsWith('yy_Coworker_') ||
1121
- file.path.startsWith('zz_ai_')) {
1122
- // 서버 경로 그대로 로컬에 저장
1123
- fullPath = path.join(localPath, file.path);
1124
- } else {
1125
- // 기타 (구버전 호환): yy_All_Docu/ 안에 저장
1126
- fullPath = path.join(mainFolderPath, file.path);
1127
- }
1128
-
1129
1208
  // 인코딩에 따라 저장
1130
1209
  const content = data.file?.content || data.content || '';
1131
1210
  const encoding = data.file?.encoding || data.encoding || 'utf-8';
@@ -1145,6 +1224,13 @@ export async function handlePullInternal(args) {
1145
1224
 
1146
1225
  if (localExists) {
1147
1226
  try {
1227
+ const localStat = fs.statSync(fullPath);
1228
+ if (localStat.isDirectory()) {
1229
+ // 로컬에 같은 이름의 폴더가 있으면 건너뜀
1230
+ results.push({ type: 'skip', path: file.path, error: '같은 이름의 폴더 존재' });
1231
+ skipped++;
1232
+ continue;
1233
+ }
1148
1234
  const localBuffer = fs.readFileSync(fullPath);
1149
1235
  localHash = crypto.createHash('sha256').update(localBuffer).digest('hex');
1150
1236
  } catch (e) {
@@ -1171,20 +1257,49 @@ export async function handlePullInternal(args) {
1171
1257
  }
1172
1258
 
1173
1259
  if (localExists) {
1174
- // 기존 파일 업데이트
1175
1260
  results.push({ type: 'update', path: file.path });
1176
1261
  updated++;
1177
1262
  } else {
1178
- // 새로 다운로드
1179
1263
  results.push({ type: 'download', path: file.path });
1180
1264
  downloaded++;
1181
1265
  }
1266
+
1267
+ // source: 'web' 파일이면 mark-as-pulled 대상
1268
+ if (serverMeta && serverMeta.source === 'web') {
1269
+ filesToMarkAsPulled.push(file.path);
1270
+ }
1182
1271
  } catch (e) {
1183
1272
  results.push({ type: 'fail', path: file.path, error: e.message });
1184
1273
  failed++;
1185
1274
  }
1186
1275
  }
1187
1276
 
1277
+ // ========================================
1278
+ // mark-as-pulled 호출 (source: 'web' → 'local' 변경)
1279
+ // Pull된 웹 파일/폴더는 이제 로컬이 관리권한을 가짐
1280
+ // ========================================
1281
+ if (filesToMarkAsPulled.length > 0) {
1282
+ try {
1283
+ const markResponse = await fetch(`${API_ENDPOINT}/files/mark-as-pulled`, {
1284
+ method: 'POST',
1285
+ headers: {
1286
+ 'Content-Type': 'application/json',
1287
+ 'Authorization': `Bearer ${apiKey}`,
1288
+ },
1289
+ body: JSON.stringify({
1290
+ projectId,
1291
+ paths: filesToMarkAsPulled,
1292
+ }),
1293
+ });
1294
+
1295
+ if (markResponse.ok) {
1296
+ console.error(`[DocuKing] mark-as-pulled 완료: ${filesToMarkAsPulled.length}개`);
1297
+ }
1298
+ } catch (e) {
1299
+ console.error('[DocuKing] mark-as-pulled 실패:', e.message);
1300
+ }
1301
+ }
1302
+
1188
1303
  // 상세 결과 구성 (Push 스타일)
1189
1304
  let resultText = `✓ Pull 완료!
1190
1305
 
package/lib/files.js CHANGED
@@ -193,18 +193,33 @@ export function collectFiles(basePath, relativePath, results, excludedFiles = nu
193
193
  }
194
194
 
195
195
  /**
196
- * 트리 구조를 평탄화
196
+ * 트리 구조를 평탄화 (파일 + 폴더 모두 포함)
197
+ * @param {Array} tree - 트리 구조
198
+ * @param {string} prefix - 경로 접두어
199
+ * @param {boolean} includeEmpty - 빈 폴더도 포함 (기본: true)
200
+ * @returns {Array} - { path, name, type } 배열
197
201
  */
198
- export function flattenTree(tree, prefix = '') {
202
+ export function flattenTree(tree, prefix = '', includeEmpty = true) {
199
203
  const results = [];
200
204
 
201
205
  for (const item of tree) {
202
206
  const itemPath = prefix ? `${prefix}/${item.name}` : item.name;
203
207
 
204
208
  if (item.type === 'file') {
205
- results.push({ path: itemPath, name: item.name });
209
+ // 파일
210
+ results.push({ path: itemPath, name: item.name, type: 'file' });
211
+ } else if (item.type === 'folder') {
212
+ // 폴더도 결과에 추가 (빈 폴더 동기화를 위해)
213
+ if (includeEmpty) {
214
+ results.push({ path: itemPath, name: item.name, type: 'folder' });
215
+ }
216
+ // 자식 탐색
217
+ if (item.children && item.children.length > 0) {
218
+ results.push(...flattenTree(item.children, itemPath, includeEmpty));
219
+ }
206
220
  } else if (item.children) {
207
- results.push(...flattenTree(item.children, itemPath));
221
+ // 구버전 호환: type 없이 children 있는 경우
222
+ results.push(...flattenTree(item.children, itemPath, includeEmpty));
208
223
  }
209
224
  }
210
225
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docuking-mcp",
3
- "version": "2.9.3",
3
+ "version": "2.10.0",
4
4
  "description": "DocuKing MCP Server - AI 시대의 문서 협업 플랫폼",
5
5
  "type": "module",
6
6
  "main": "index.js",