docuking-mcp 3.0.0 → 3.6.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/README.md +4 -4
- package/handlers/docs.js +229 -16
- package/handlers/kingcast.js +6 -6
- package/handlers/sync.js +241 -99
- package/index.js +239 -139
- package/lib/config.js +4 -2
- package/lib/index-cache.js +393 -17
- package/lib/init.js +9 -9
- package/lib/utils.js +1 -1
- package/package.json +1 -1
package/handlers/sync.js
CHANGED
|
@@ -46,6 +46,12 @@ import {
|
|
|
46
46
|
updateIndexAfterSync,
|
|
47
47
|
computeFileHash,
|
|
48
48
|
getIndexStats,
|
|
49
|
+
// v2 추가
|
|
50
|
+
markAsDeleted,
|
|
51
|
+
wasDeletedLocally,
|
|
52
|
+
clearDeletedEntry,
|
|
53
|
+
classifyForPush,
|
|
54
|
+
classifyForPull,
|
|
49
55
|
} from '../lib/index-cache.js';
|
|
50
56
|
|
|
51
57
|
// 레포지토리 매핑 (메모리에 캐시)
|
|
@@ -74,6 +80,18 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
|
|
|
74
80
|
// Co-worker 권한은 API Key 형식에서 판단
|
|
75
81
|
const { isCoworker, coworkerFolder } = parseCoworkerFromApiKey(apiKey);
|
|
76
82
|
|
|
83
|
+
// 기존 config 확인 - 프로젝트 ID가 변경되면 인덱스 초기화
|
|
84
|
+
const existingConfig = getLocalConfig(localPath);
|
|
85
|
+
if (existingConfig && existingConfig.projectId && existingConfig.projectId !== projectId) {
|
|
86
|
+
// 프로젝트 ID가 변경됨 - 인덱스 파일 삭제
|
|
87
|
+
const indexPath = path.join(localPath, '.docuking', 'index.json');
|
|
88
|
+
if (fs.existsSync(indexPath)) {
|
|
89
|
+
fs.unlinkSync(indexPath);
|
|
90
|
+
console.error(`[DocuKing] 프로젝트 ID 변경 감지: ${existingConfig.projectId} → ${projectId}`);
|
|
91
|
+
console.error(`[DocuKing] 기존 인덱스 초기화됨`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
77
95
|
// .docuking/config.json에 설정 저장
|
|
78
96
|
saveLocalConfig(localPath, {
|
|
79
97
|
projectId,
|
|
@@ -84,7 +102,7 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
|
|
|
84
102
|
createdAt: new Date().toISOString(),
|
|
85
103
|
});
|
|
86
104
|
|
|
87
|
-
// 폴더 생성: 오너는 yy_All_Docu/, 협업자는
|
|
105
|
+
// 폴더 생성: 오너는 yy_All_Docu/, 협업자는 zz_Coworker_{폴더명}/ (별도)
|
|
88
106
|
const mainFolderName = 'yy_All_Docu';
|
|
89
107
|
const mainFolderPath = path.join(localPath, mainFolderName);
|
|
90
108
|
|
|
@@ -96,9 +114,10 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
|
|
|
96
114
|
let coworkerFolderName = null;
|
|
97
115
|
let coworkerFolderPath = null;
|
|
98
116
|
|
|
99
|
-
// AI 폴더 (오너: xy_TalkTodoPlan, 협업자:
|
|
117
|
+
// AI 폴더 (오너: xy_TalkTodoPlan, 협업자: {폴더명}_TalkTodoPlan)
|
|
100
118
|
const ownerAiFolder = 'xy_TalkTodoPlan';
|
|
101
|
-
|
|
119
|
+
// 협업자 AI 폴더는 동적으로 생성 (coworkerFolder가 있을 때만)
|
|
120
|
+
const coworkerAiFolder = coworkerFolder ? `${coworkerFolder}_TalkTodoPlan` : null;
|
|
102
121
|
|
|
103
122
|
// xx_ 시스템 폴더 목록 (오너 전용)
|
|
104
123
|
const systemFolders = ['xx_Infra_Config', 'xx_Policy'];
|
|
@@ -110,8 +129,8 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
|
|
|
110
129
|
}
|
|
111
130
|
|
|
112
131
|
if (isCoworker) {
|
|
113
|
-
// 협업자:
|
|
114
|
-
coworkerFolderName = `
|
|
132
|
+
// 협업자: zz_Coworker_{폴더명}/ 폴더를 yy_All_Docu/ 밖에 별도 생성
|
|
133
|
+
coworkerFolderName = `zz_Coworker_${coworkerFolder}`;
|
|
115
134
|
coworkerFolderPath = path.join(localPath, coworkerFolderName);
|
|
116
135
|
if (!fs.existsSync(coworkerFolderPath)) {
|
|
117
136
|
fs.mkdirSync(coworkerFolderPath, { recursive: true });
|
|
@@ -121,7 +140,7 @@ docuking_init 호출 시 apiKey 파라미터를 포함해주세요.`,
|
|
|
121
140
|
if (!fs.existsSync(coworkerPrivatePath)) {
|
|
122
141
|
fs.mkdirSync(coworkerPrivatePath, { recursive: true });
|
|
123
142
|
}
|
|
124
|
-
// 협업자 폴더 안에
|
|
143
|
+
// 협업자 폴더 안에 {폴더명}_TalkTodoPlan 폴더 생성
|
|
125
144
|
const coworkerAiFolderPath = path.join(coworkerFolderPath, coworkerAiFolder);
|
|
126
145
|
if (!fs.existsSync(coworkerAiFolderPath)) {
|
|
127
146
|
fs.mkdirSync(coworkerAiFolderPath, { recursive: true });
|
|
@@ -244,11 +263,11 @@ Git처럼 무엇을 변경했는지 명확히 작성해주세요.
|
|
|
244
263
|
|
|
245
264
|
// Co-worker 권한은 API Key 형식에서 판단
|
|
246
265
|
const { isCoworker, coworkerFolder } = parseCoworkerFromApiKey(apiKey);
|
|
247
|
-
const coworkerFolderName = isCoworker ? `
|
|
266
|
+
const coworkerFolderName = isCoworker ? `zz_Coworker_${coworkerFolder}` : null;
|
|
248
267
|
|
|
249
268
|
// 작업 폴더 결정: 프로젝트 루트 기준 절대경로 사용
|
|
250
|
-
// 오너:
|
|
251
|
-
// 협업자:
|
|
269
|
+
// 오너: xx_*/ + xy_*/ + yy_All_Docu/ 폴더들 Push
|
|
270
|
+
// 협업자: zz_Coworker_{폴더명}/ 폴더만 Push
|
|
252
271
|
const mainFolderPath = path.join(localPath, 'yy_All_Docu');
|
|
253
272
|
|
|
254
273
|
// Push 대상 폴더 목록 (프로젝트 루트 기준)
|
|
@@ -258,7 +277,7 @@ Git처럼 무엇을 변경했는지 명확히 작성해주세요.
|
|
|
258
277
|
console.error(`[DocuKing] Push 권한: isCoworker=${isCoworker}, coworkerFolder=${coworkerFolder}, coworkerFolderName=${coworkerFolderName}`);
|
|
259
278
|
|
|
260
279
|
if (isCoworker) {
|
|
261
|
-
// 협업자:
|
|
280
|
+
// 협업자: zz_Coworker_{폴더명}/ 폴더 + xx_Urgent/ 폴더 Push
|
|
262
281
|
const coworkerPath = path.join(localPath, coworkerFolderName);
|
|
263
282
|
console.error(`[DocuKing] 협업자 Push 대상 폴더: ${coworkerPath}`);
|
|
264
283
|
if (!fs.existsSync(coworkerPath)) {
|
|
@@ -294,13 +313,17 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
294
313
|
};
|
|
295
314
|
}
|
|
296
315
|
|
|
297
|
-
// 오너 Push 대상: xx_*/ +
|
|
316
|
+
// 오너 Push 대상: xx_*/ + xy_*/ + yy_All_Docu/ 폴더들
|
|
298
317
|
pushTargetFolders.push({ localPath: mainFolderPath, serverPrefix: 'yy_All_Docu' });
|
|
299
318
|
|
|
300
|
-
//
|
|
319
|
+
// 특수폴더 패턴으로 Push 대상 폴더 찾기
|
|
320
|
+
// 특수폴더: xx_*, xy_TalkTodoPlan, yy_All_Docu, zz_Coworker_*
|
|
301
321
|
const rootEntries = fs.readdirSync(localPath, { withFileTypes: true });
|
|
302
322
|
for (const entry of rootEntries) {
|
|
303
|
-
if (entry.isDirectory() && (
|
|
323
|
+
if (entry.isDirectory() && (
|
|
324
|
+
entry.name.startsWith('xx_') ||
|
|
325
|
+
entry.name === 'xy_TalkTodoPlan'
|
|
326
|
+
)) {
|
|
304
327
|
const folderPath = path.join(localPath, entry.name);
|
|
305
328
|
pushTargetFolders.push({ localPath: folderPath, serverPrefix: entry.name });
|
|
306
329
|
}
|
|
@@ -482,33 +505,13 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
482
505
|
}
|
|
483
506
|
|
|
484
507
|
// ========================================
|
|
485
|
-
//
|
|
486
|
-
// soft-delete에서 "로컬에 없는 파일" 판단에 사용
|
|
487
|
-
// Pull이 파일을 내려받아도 이 목록은 변하지 않음
|
|
508
|
+
// Pull 선행 제거 (2026-01-24)
|
|
488
509
|
// ========================================
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
// ========================================
|
|
493
|
-
// Pull 실행 (git pull && git push 패턴)
|
|
510
|
+
// 이전: Push 전에 Pull 실행 (git pull && git push 패턴)
|
|
511
|
+
// 문제: Pull이 로컬 변경사항(삭제, 수정)을 덮어씀 → 좀비 파일 부활
|
|
512
|
+
// 해결: Push는 Push만. Pull은 사용자가 별도로 호출.
|
|
494
513
|
// ========================================
|
|
495
|
-
|
|
496
|
-
try {
|
|
497
|
-
const pullResult = await handlePullInternal({ localPath, filePath });
|
|
498
|
-
pullResultText = pullResult.text;
|
|
499
|
-
} catch (e) {
|
|
500
|
-
return {
|
|
501
|
-
content: [
|
|
502
|
-
{
|
|
503
|
-
type: 'text',
|
|
504
|
-
text: `❌ Pull 실패로 Push를 중단합니다.
|
|
505
|
-
오류: ${e.message}
|
|
506
|
-
|
|
507
|
-
먼저 Pull 문제를 해결한 후 다시 시도하세요.`,
|
|
508
|
-
},
|
|
509
|
-
],
|
|
510
|
-
};
|
|
511
|
-
}
|
|
514
|
+
console.error(`[DocuKing] Push 시작 (Pull 선행 없음 - 로컬 우선)`);
|
|
512
515
|
|
|
513
516
|
if (filesToPush.length === 0 && emptyFolders.length === 0) {
|
|
514
517
|
return {
|
|
@@ -724,7 +727,7 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
724
727
|
},
|
|
725
728
|
body: JSON.stringify({
|
|
726
729
|
projectId,
|
|
727
|
-
path: file.serverPath, // 서버 경로 (코워커는
|
|
730
|
+
path: file.serverPath, // 서버 경로 (코워커는 zz_Coworker_{폴더명}/파일경로)
|
|
728
731
|
content,
|
|
729
732
|
encoding, // 'utf-8' 또는 'base64'
|
|
730
733
|
message, // 커밋 메시지
|
|
@@ -803,23 +806,122 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
803
806
|
}
|
|
804
807
|
|
|
805
808
|
// ========================================
|
|
806
|
-
// 4.
|
|
809
|
+
// 4. 인덱스 기반 삭제 전파 (v2: 2026-01-24 강화)
|
|
807
810
|
// ========================================
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
// - 사용자가 "이 파일 삭제해줘" 요청 시
|
|
814
|
-
// - docuking_delete MCP 도구 사용
|
|
811
|
+
// 3-way 비교:
|
|
812
|
+
// - 인덱스에 있음 + 로컬에 없음 + 서버에 있음 = 로컬 삭제 → 서버도 삭제
|
|
813
|
+
// - 인덱스에 있음 + 로컬에 없음 + 서버 수정됨 = 충돌 (로컬 삭제 vs 서버 수정)
|
|
814
|
+
// - 인덱스에 없음 + 로컬에 없음 + 서버에 있음 = 다른 곳에서 생성 → 무시
|
|
815
|
+
// - source: 'web' 파일은 삭제 보호
|
|
815
816
|
// ========================================
|
|
816
817
|
let deleted = 0;
|
|
817
818
|
const deletedFilePaths = [];
|
|
818
819
|
let protectedFiles = [];
|
|
820
|
+
const conflicts = [];
|
|
821
|
+
|
|
822
|
+
// 인덱스에서 삭제된 파일 찾기 (인덱스에 있었는데 로컬에 없는 파일)
|
|
823
|
+
const locallyDeletedFiles = [];
|
|
824
|
+
for (const indexPath of Object.keys(localIndex.files)) {
|
|
825
|
+
// Push 대상 폴더 내의 파일인지 확인
|
|
826
|
+
let isInPushTarget = false;
|
|
827
|
+
let fullPath = '';
|
|
828
|
+
|
|
829
|
+
for (const target of pushTargetFolders) {
|
|
830
|
+
if (indexPath.startsWith(target.serverPrefix)) {
|
|
831
|
+
isInPushTarget = true;
|
|
832
|
+
const relativePath = indexPath.slice(target.serverPrefix.length + 1);
|
|
833
|
+
fullPath = path.join(target.localPath, relativePath);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (!isInPushTarget) continue;
|
|
839
|
+
|
|
840
|
+
// 로컬에 파일이 없으면 = 삭제된 파일
|
|
841
|
+
if (!fs.existsSync(fullPath)) {
|
|
842
|
+
const serverMeta = serverPathToMeta[indexPath];
|
|
843
|
+
const indexEntry = localIndex.files[indexPath];
|
|
819
844
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
845
|
+
// 서버에 없으면 이미 삭제됨 → 스킵
|
|
846
|
+
if (!serverMeta) {
|
|
847
|
+
console.error(`[DocuKing] 이미 삭제됨: ${indexPath}`);
|
|
848
|
+
// deletedFiles 기록 정리
|
|
849
|
+
clearDeletedEntry(localIndex, indexPath);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// source: 'web' 파일은 삭제 보호
|
|
854
|
+
if (serverMeta.source === 'web') {
|
|
855
|
+
console.error(`[DocuKing] 삭제 보호 (source:web): ${indexPath}`);
|
|
856
|
+
protectedFiles.push(indexPath);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// 서버가 수정되었는지 확인 (충돌 감지)
|
|
861
|
+
const serverModified = serverMeta.hash !== (indexEntry.serverHash || indexEntry.hash);
|
|
862
|
+
if (serverModified) {
|
|
863
|
+
console.error(`[DocuKing] ⚠️ 충돌: ${indexPath} (로컬 삭제 vs 서버 수정)`);
|
|
864
|
+
conflicts.push({
|
|
865
|
+
path: indexPath,
|
|
866
|
+
type: 'delete-vs-modify',
|
|
867
|
+
localAction: 'delete',
|
|
868
|
+
serverHash: serverMeta.hash,
|
|
869
|
+
});
|
|
870
|
+
// 충돌 시에도 로컬 우선 정책으로 삭제 진행 (경고만 출력)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
locallyDeletedFiles.push(indexPath);
|
|
874
|
+
console.error(`[DocuKing] 삭제 감지: ${indexPath}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// 삭제된 파일을 서버에서도 삭제
|
|
879
|
+
if (locallyDeletedFiles.length > 0) {
|
|
880
|
+
console.error(`[DocuKing] 서버에서 ${locallyDeletedFiles.length}개 파일 삭제 요청`);
|
|
881
|
+
try {
|
|
882
|
+
const deleteResponse = await fetch(`${API_ENDPOINT}/files/soft-delete`, {
|
|
883
|
+
method: 'POST',
|
|
884
|
+
headers: {
|
|
885
|
+
'Content-Type': 'application/json',
|
|
886
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
887
|
+
},
|
|
888
|
+
body: JSON.stringify({
|
|
889
|
+
projectId,
|
|
890
|
+
paths: locallyDeletedFiles,
|
|
891
|
+
}),
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
if (deleteResponse.ok) {
|
|
895
|
+
const deleteResult = await deleteResponse.json();
|
|
896
|
+
deleted = deleteResult.deleted || 0;
|
|
897
|
+
deletedFilePaths.push(...(deleteResult.deletedPaths || []));
|
|
898
|
+
|
|
899
|
+
// 인덱스에서 삭제 처리 (v2: markAsDeleted 사용)
|
|
900
|
+
for (const deletedPath of locallyDeletedFiles) {
|
|
901
|
+
const entry = localIndex.files[deletedPath];
|
|
902
|
+
markAsDeleted(localIndex, deletedPath, entry?.hash);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
console.error(`[DocuKing] 서버 삭제 완료: ${deleted}개`);
|
|
906
|
+
if (protectedFiles.length > 0) {
|
|
907
|
+
console.error(`[DocuKing] 보호된 파일 (source:web): ${protectedFiles.join(', ')}`);
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
console.error(`[DocuKing] 서버 삭제 실패: ${await deleteResponse.text()}`);
|
|
911
|
+
}
|
|
912
|
+
} catch (e) {
|
|
913
|
+
console.error(`[DocuKing] 서버 삭제 요청 오류: ${e.message}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// 충돌 경고 출력
|
|
918
|
+
if (conflicts.length > 0) {
|
|
919
|
+
console.error(`\n[DocuKing] ⚠️ ${conflicts.length}개 충돌 발생 (로컬 우선 정책으로 처리됨):`);
|
|
920
|
+
for (const c of conflicts) {
|
|
921
|
+
console.error(` - ${c.path}: ${c.type}`);
|
|
922
|
+
}
|
|
923
|
+
console.error('');
|
|
924
|
+
}
|
|
823
925
|
|
|
824
926
|
// 5. 빈 폴더 생성
|
|
825
927
|
let createdEmptyFolders = 0;
|
|
@@ -851,12 +953,13 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
851
953
|
}
|
|
852
954
|
|
|
853
955
|
// ========================================
|
|
854
|
-
// 로컬 인덱스 업데이트 및 저장
|
|
956
|
+
// 로컬 인덱스 업데이트 및 저장 (v2)
|
|
855
957
|
// ========================================
|
|
856
|
-
if (syncedFiles.length > 0) {
|
|
857
|
-
|
|
958
|
+
if (syncedFiles.length > 0 || deletedFilePaths.length > 0) {
|
|
959
|
+
// syncType: 'push'로 lastPush 타임스탬프 업데이트
|
|
960
|
+
updateIndexAfterSync(localIndex, syncedFiles, deletedFilePaths, { syncType: 'push' });
|
|
858
961
|
if (saveIndex(localPath, localIndex)) {
|
|
859
|
-
console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개
|
|
962
|
+
console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 동기화, ${deletedFilePaths.length}개 삭제`);
|
|
860
963
|
}
|
|
861
964
|
}
|
|
862
965
|
|
|
@@ -948,29 +1051,13 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
948
1051
|
}
|
|
949
1052
|
|
|
950
1053
|
resultText += `\n\n🌐 웹 탐색기에서 커밋 히스토리를 확인할 수 있습니다: https://docuking.ai`;
|
|
951
|
-
|
|
952
|
-
// Pull 결과가 있으면 앞에 추가
|
|
953
|
-
let finalText = '';
|
|
954
|
-
if (pullResultText) {
|
|
955
|
-
// Pull에서 실제로 받은 파일이 있는 경우만 표시
|
|
956
|
-
const pullHasChanges = pullResultText.includes('다운로드 (신규):') && !pullResultText.includes('다운로드 (신규): 0개');
|
|
957
|
-
const pullHasUpdates = pullResultText.includes('업데이트 (변경됨):') && !pullResultText.includes('업데이트 (변경됨): 0개');
|
|
958
|
-
|
|
959
|
-
if (pullHasChanges || pullHasUpdates) {
|
|
960
|
-
finalText = `📥 [1단계] Pull (서버 → 로컬)\n${pullResultText}\n\n${'─'.repeat(50)}\n\n📤 [2단계] Push (로컬 → 서버)\n${resultText}`;
|
|
961
|
-
} else {
|
|
962
|
-
// Pull에서 변경 없으면 간단히 표시
|
|
963
|
-
finalText = `📥 Pull: 변경 없음 (최신 상태)\n\n📤 Push:\n${resultText}`;
|
|
964
|
-
}
|
|
965
|
-
} else {
|
|
966
|
-
finalText = resultText;
|
|
967
|
-
}
|
|
1054
|
+
resultText += `\n\n💡 서버에서 파일을 받으려면 별도로 Pull을 실행하세요.`;
|
|
968
1055
|
|
|
969
1056
|
return {
|
|
970
1057
|
content: [
|
|
971
1058
|
{
|
|
972
1059
|
type: 'text',
|
|
973
|
-
text:
|
|
1060
|
+
text: resultText,
|
|
974
1061
|
},
|
|
975
1062
|
],
|
|
976
1063
|
};
|
|
@@ -1012,8 +1099,8 @@ export async function handlePullInternal(args) {
|
|
|
1012
1099
|
// 오너 전용 폴더
|
|
1013
1100
|
const ownerFolders = ['xx_Infra_Config', 'xx_Policy', 'xy_TalkTodoPlan'];
|
|
1014
1101
|
|
|
1015
|
-
// 협업자 전용 폴더 (
|
|
1016
|
-
|
|
1102
|
+
// 협업자 전용 폴더 (zz_Coworker_{폴더명}/ 안에)
|
|
1103
|
+
// AI 폴더는 {폴더명}_TalkTodoPlan 형식 (coworkerFolder 기반 동적 생성)
|
|
1017
1104
|
|
|
1018
1105
|
// 공통 폴더 체크
|
|
1019
1106
|
for (const folder of commonFolders) {
|
|
@@ -1026,8 +1113,9 @@ export async function handlePullInternal(args) {
|
|
|
1026
1113
|
}
|
|
1027
1114
|
|
|
1028
1115
|
if (isCoworker) {
|
|
1029
|
-
// 협업자:
|
|
1030
|
-
const coworkerFolderName = `
|
|
1116
|
+
// 협업자: zz_Coworker_{폴더명}/ 안에 하위 폴더 체크
|
|
1117
|
+
const coworkerFolderName = `zz_Coworker_${coworkerFolder}`;
|
|
1118
|
+
const coworkerSubFolders = [`${coworkerFolder}_TalkTodoPlan`, '_Private'];
|
|
1031
1119
|
const coworkerBasePath = path.join(localPath, coworkerFolderName);
|
|
1032
1120
|
|
|
1033
1121
|
// 협업자 루트 폴더
|
|
@@ -1176,10 +1264,9 @@ export async function handlePullInternal(args) {
|
|
|
1176
1264
|
const serverMeta = serverPathToMeta[folder.path];
|
|
1177
1265
|
let fullPath;
|
|
1178
1266
|
if (folder.path.startsWith('xx_') ||
|
|
1179
|
-
folder.path.startsWith('
|
|
1267
|
+
folder.path.startsWith('xy_TalkTodoPlan') ||
|
|
1180
1268
|
folder.path.startsWith('yy_All_Docu') ||
|
|
1181
|
-
folder.path.startsWith('
|
|
1182
|
-
folder.path.startsWith('zz_ai_')) {
|
|
1269
|
+
folder.path.startsWith('zz_Coworker_')) {
|
|
1183
1270
|
fullPath = path.join(localPath, folder.path);
|
|
1184
1271
|
} else {
|
|
1185
1272
|
fullPath = path.join(mainFolderPath, folder.path);
|
|
@@ -1209,10 +1296,9 @@ export async function handlePullInternal(args) {
|
|
|
1209
1296
|
for (const file of files_only) {
|
|
1210
1297
|
let fullPath;
|
|
1211
1298
|
if (file.path.startsWith('xx_') ||
|
|
1212
|
-
file.path.startsWith('
|
|
1299
|
+
file.path.startsWith('xy_TalkTodoPlan') ||
|
|
1213
1300
|
file.path.startsWith('yy_All_Docu') ||
|
|
1214
|
-
file.path.startsWith('
|
|
1215
|
-
file.path.startsWith('zz_ai_')) {
|
|
1301
|
+
file.path.startsWith('zz_Coworker_')) {
|
|
1216
1302
|
fullPath = path.join(localPath, file.path);
|
|
1217
1303
|
} else {
|
|
1218
1304
|
fullPath = path.join(mainFolderPath, file.path);
|
|
@@ -1253,9 +1339,51 @@ export async function handlePullInternal(args) {
|
|
|
1253
1339
|
} catch (e) {
|
|
1254
1340
|
// 해시 계산 실패 시 다운로드 대상
|
|
1255
1341
|
}
|
|
1342
|
+
} else {
|
|
1343
|
+
// ========================================
|
|
1344
|
+
// 로컬에 파일이 없는 경우 (v2 좀비 방지 강화)
|
|
1345
|
+
// 1. files에 있음 = 로컬에서 삭제한 파일 → markAsDeleted 후 스킵
|
|
1346
|
+
// 2. deletedFiles에 있음 = 이미 삭제 표시됨 → 서버 변경 확인 후 처리
|
|
1347
|
+
// 3. 둘 다 없음 = 서버에서 새로 생성된 파일 → 다운로드
|
|
1348
|
+
// ========================================
|
|
1349
|
+
|
|
1350
|
+
// Case 1: files에 아직 있음 (첫 Pull 시점)
|
|
1351
|
+
if (localIndex.files && localIndex.files[file.path]) {
|
|
1352
|
+
console.error(`[DocuKing] 로컬 삭제 감지: ${file.path} → deletedFiles에 기록`);
|
|
1353
|
+
// deletedFiles에 기록
|
|
1354
|
+
const entry = localIndex.files[file.path];
|
|
1355
|
+
markAsDeleted(localIndex, file.path, entry.hash);
|
|
1356
|
+
results.push({ type: 'skip', path: file.path, reason: 'locally-deleted' });
|
|
1357
|
+
skipped++;
|
|
1358
|
+
current++;
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Case 2: deletedFiles에 있음 (이전에 삭제 표시됨)
|
|
1363
|
+
const deleted = wasDeletedLocally(localIndex, file.path);
|
|
1364
|
+
if (deleted.isDeleted) {
|
|
1365
|
+
// 서버가 수정되었는지 확인
|
|
1366
|
+
if (serverHash === deleted.entry.lastHash) {
|
|
1367
|
+
// 서버 변경 없음 → 스킵 (좀비 방지)
|
|
1368
|
+
console.error(`[DocuKing] 좀비 방지: ${file.path} (로컬 삭제 유지)`);
|
|
1369
|
+
results.push({ type: 'skip', path: file.path, reason: 'zombie-prevention' });
|
|
1370
|
+
} else {
|
|
1371
|
+
// 서버가 수정됨 → 충돌! (로컬 삭제 vs 서버 수정)
|
|
1372
|
+
console.error(`[DocuKing] ⚠️ 충돌: ${file.path} (로컬 삭제 vs 서버 수정) → 서버 버전 다운로드`);
|
|
1373
|
+
// 충돌 시 서버 버전 다운로드 (사용자가 결정할 수 있도록)
|
|
1374
|
+
filesToDownload.push({ ...file, fullPath, conflict: true });
|
|
1375
|
+
current++;
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
skipped++;
|
|
1379
|
+
current++;
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Case 3: 둘 다 없음 = 새 파일
|
|
1256
1384
|
}
|
|
1257
1385
|
|
|
1258
|
-
// 로컬에
|
|
1386
|
+
// 인덱스에 없고 로컬에 없음 = 서버에서 새로 생성된 파일 → 다운로드
|
|
1259
1387
|
filesToDownload.push({ ...file, fullPath });
|
|
1260
1388
|
}
|
|
1261
1389
|
|
|
@@ -1403,12 +1531,17 @@ export async function handlePullInternal(args) {
|
|
|
1403
1531
|
}
|
|
1404
1532
|
|
|
1405
1533
|
// ========================================
|
|
1406
|
-
// 로컬 인덱스 업데이트 및 저장
|
|
1534
|
+
// 로컬 인덱스 업데이트 및 저장 (v2)
|
|
1407
1535
|
// ========================================
|
|
1536
|
+
// Pull 중 발견된 삭제 파일들도 인덱스에 기록됨 (markAsDeleted 호출됨)
|
|
1537
|
+
// 따라서 syncedFiles가 0이어도 인덱스가 변경되었을 수 있음
|
|
1538
|
+
if (saveIndex(localPath, localIndex)) {
|
|
1539
|
+
console.error(`[DocuKing] 로컬 인덱스 업데이트 (lastPull 갱신)`);
|
|
1540
|
+
}
|
|
1408
1541
|
if (syncedFiles.length > 0) {
|
|
1409
|
-
updateIndexAfterSync(localIndex, syncedFiles, []);
|
|
1542
|
+
updateIndexAfterSync(localIndex, syncedFiles, [], { syncType: 'pull' });
|
|
1410
1543
|
if (saveIndex(localPath, localIndex)) {
|
|
1411
|
-
console.error(`[DocuKing] 로컬
|
|
1544
|
+
console.error(`[DocuKing] 로컬 인덱스: ${syncedFiles.length}개 파일 동기화됨`);
|
|
1412
1545
|
}
|
|
1413
1546
|
}
|
|
1414
1547
|
|
|
@@ -1490,9 +1623,9 @@ export async function handlePullInternal(args) {
|
|
|
1490
1623
|
// 서버 경로에 따라 로컬 경로 결정
|
|
1491
1624
|
let fullPath;
|
|
1492
1625
|
if (deletedFile.path.startsWith('xx_') ||
|
|
1493
|
-
deletedFile.path.startsWith('
|
|
1494
|
-
deletedFile.path.startsWith('
|
|
1495
|
-
deletedFile.path.startsWith('
|
|
1626
|
+
deletedFile.path.startsWith('xy_TalkTodoPlan') ||
|
|
1627
|
+
deletedFile.path.startsWith('yy_All_Docu') ||
|
|
1628
|
+
deletedFile.path.startsWith('zz_Coworker_')) {
|
|
1496
1629
|
fullPath = path.join(localPath, deletedFile.path);
|
|
1497
1630
|
} else {
|
|
1498
1631
|
fullPath = path.join(mainFolderPath, deletedFile.path);
|
|
@@ -1516,6 +1649,10 @@ export async function handlePullInternal(args) {
|
|
|
1516
1649
|
deletedLocallyPaths.push(deletedFile.path);
|
|
1517
1650
|
console.error(`[DocuKing] 로컬 삭제 (서버에서 삭제됨): ${deletedFile.path}`);
|
|
1518
1651
|
|
|
1652
|
+
// 인덱스에서도 제거 (v2)
|
|
1653
|
+
removeIndexEntry(localIndex, deletedFile.path);
|
|
1654
|
+
clearDeletedEntry(localIndex, deletedFile.path);
|
|
1655
|
+
|
|
1519
1656
|
// 빈 폴더 정리
|
|
1520
1657
|
const parentDir = path.dirname(fullPath);
|
|
1521
1658
|
try {
|
|
@@ -1529,6 +1666,8 @@ export async function handlePullInternal(args) {
|
|
|
1529
1666
|
}
|
|
1530
1667
|
} else {
|
|
1531
1668
|
console.error(`[DocuKing] 로컬 유지 (로컬 수정이 최신): ${deletedFile.path}`);
|
|
1669
|
+
// 다음 Push에서 부활할 것이므로 deletedFiles에서 제거
|
|
1670
|
+
clearDeletedEntry(localIndex, deletedFile.path);
|
|
1532
1671
|
}
|
|
1533
1672
|
} catch (e) {
|
|
1534
1673
|
console.error(`[DocuKing] 삭제 동기화 실패: ${deletedFile.path} - ${e.message}`);
|
|
@@ -1780,7 +1919,7 @@ export async function handleStatus(args) {
|
|
|
1780
1919
|
|
|
1781
1920
|
// Co-worker 권한은 API Key 형식에서 판단
|
|
1782
1921
|
const { isCoworker, coworkerFolder } = parseCoworkerFromApiKey(apiKey);
|
|
1783
|
-
const coworkerFolderName = isCoworker ? `
|
|
1922
|
+
const coworkerFolderName = isCoworker ? `zz_Coworker_${coworkerFolder}` : null;
|
|
1784
1923
|
|
|
1785
1924
|
// 권한 정보 구성
|
|
1786
1925
|
let permissionInfo = '';
|
|
@@ -1822,21 +1961,24 @@ export async function handleStatus(args) {
|
|
|
1822
1961
|
const mainFolderPath = path.join(localPath, 'yy_All_Docu');
|
|
1823
1962
|
|
|
1824
1963
|
if (isCoworker) {
|
|
1825
|
-
// 협업자:
|
|
1964
|
+
// 협업자: zz_Coworker_{폴더명}/ 폴더에서 파일 수집
|
|
1826
1965
|
const coworkerPath = path.join(localPath, coworkerFolderName);
|
|
1827
1966
|
if (fs.existsSync(coworkerPath)) {
|
|
1828
1967
|
collectFilesSimple(coworkerPath, '', localFiles);
|
|
1829
1968
|
}
|
|
1830
1969
|
pushableFiles = localFiles; // 협업자는 자기 폴더의 모든 파일 Push 가능
|
|
1831
1970
|
} else {
|
|
1832
|
-
// 오너: xx_*/ +
|
|
1971
|
+
// 오너: xx_*/ + xy_*/ + yy_All_Docu/ 폴더에서 파일 수집
|
|
1833
1972
|
if (fs.existsSync(mainFolderPath)) {
|
|
1834
1973
|
collectFilesSimple(mainFolderPath, '', localFiles);
|
|
1835
1974
|
}
|
|
1836
|
-
// xx_*,
|
|
1975
|
+
// 특수폴더들도 수집: xx_*, xy_TalkTodoPlan
|
|
1837
1976
|
const rootEntries = fs.readdirSync(localPath, { withFileTypes: true });
|
|
1838
1977
|
for (const entry of rootEntries) {
|
|
1839
|
-
if (entry.isDirectory() && (
|
|
1978
|
+
if (entry.isDirectory() && (
|
|
1979
|
+
entry.name.startsWith('xx_') ||
|
|
1980
|
+
entry.name === 'xy_TalkTodoPlan'
|
|
1981
|
+
)) {
|
|
1840
1982
|
const folderPath = path.join(localPath, entry.name);
|
|
1841
1983
|
collectFilesSimple(folderPath, '', localFiles);
|
|
1842
1984
|
}
|
|
@@ -1914,7 +2056,7 @@ export async function handleDelete(args) {
|
|
|
1914
2056
|
|
|
1915
2057
|
// 협업자는 자기 폴더만 삭제 가능
|
|
1916
2058
|
if (isCoworker) {
|
|
1917
|
-
const coworkerPrefix = `
|
|
2059
|
+
const coworkerPrefix = `zz_Coworker_${coworkerFolder}/`;
|
|
1918
2060
|
const invalidPaths = paths.filter(p => !p.startsWith(coworkerPrefix) && !p.startsWith('xx_Urgent/'));
|
|
1919
2061
|
if (invalidPaths.length > 0) {
|
|
1920
2062
|
return {
|
|
@@ -1929,7 +2071,7 @@ export async function handleDelete(args) {
|
|
|
1929
2071
|
|
|
1930
2072
|
// 오너도 협업자 폴더는 삭제 금지
|
|
1931
2073
|
if (!isCoworker) {
|
|
1932
|
-
const coworkerPaths = paths.filter(p => p.startsWith('
|
|
2074
|
+
const coworkerPaths = paths.filter(p => p.startsWith('zz_Coworker_'));
|
|
1933
2075
|
if (coworkerPaths.length > 0 && !force) {
|
|
1934
2076
|
return {
|
|
1935
2077
|
content: [{
|
|
@@ -2005,9 +2147,9 @@ export async function handleDelete(args) {
|
|
|
2005
2147
|
try {
|
|
2006
2148
|
let localFilePath;
|
|
2007
2149
|
if (serverPath.startsWith('xx_') ||
|
|
2008
|
-
serverPath.startsWith('
|
|
2009
|
-
serverPath.startsWith('
|
|
2010
|
-
serverPath.startsWith('
|
|
2150
|
+
serverPath.startsWith('xy_TalkTodoPlan') ||
|
|
2151
|
+
serverPath.startsWith('yy_All_Docu') ||
|
|
2152
|
+
serverPath.startsWith('zz_Coworker_')) {
|
|
2011
2153
|
localFilePath = path.join(localPath, serverPath);
|
|
2012
2154
|
} else {
|
|
2013
2155
|
localFilePath = path.join(localPath, 'yy_All_Docu', serverPath);
|