soulsync 1.0.0 → 1.0.5
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/CHANGES.md +120 -0
- package/DEPLOY_CHECKLIST.md +151 -0
- package/INSTALL.md +103 -0
- package/SPEC.md +170 -0
- package/TROUBLESHOOTING.md +199 -0
- package/{config.json → config.json.example} +3 -3
- package/debug.py +221 -0
- package/index.js +79 -0
- package/openclaw.plugin.json +18 -0
- package/package.json +7 -21
- package/setup.sh +91 -0
- package/src/__init__.py +1 -0
- package/src/client.py +104 -15
- package/src/interactive_auth.py +283 -0
- package/src/main.py +205 -81
- package/src/main_fixed.py +434 -0
- package/src/register.py +131 -0
- package/src/sync.py +172 -109
- package/workspace/MEMORY.md +0 -5
package/src/sync.py
CHANGED
|
@@ -1,140 +1,203 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import time
|
|
3
|
-
|
|
3
|
+
import shutil
|
|
4
|
+
import threading
|
|
5
|
+
from profiles import ProfilesClient, ConflictError
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
class ProfileSync:
|
|
7
9
|
"""多文件同步逻辑"""
|
|
8
|
-
|
|
10
|
+
|
|
9
11
|
def __init__(self, client: ProfilesClient, version_manager, workspace: str):
|
|
10
12
|
self.client = client
|
|
11
13
|
self.version_manager = version_manager
|
|
12
|
-
self.workspace =
|
|
13
|
-
self.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
self.
|
|
23
|
-
|
|
14
|
+
self.workspace = workspace
|
|
15
|
+
self._syncing_files = set()
|
|
16
|
+
self._sync_lock = threading.Lock()
|
|
17
|
+
|
|
18
|
+
def _get_absolute_path(self, relative_path: str) -> str:
|
|
19
|
+
"""获取文件的绝对路径"""
|
|
20
|
+
return os.path.normpath(os.path.join(self.workspace, relative_path))
|
|
21
|
+
|
|
22
|
+
def _is_syncing(self, file_path: str) -> bool:
|
|
23
|
+
"""检查文件是否正在同步"""
|
|
24
|
+
return file_path in self._syncing_files
|
|
25
|
+
|
|
26
|
+
def _mark_syncing(self, file_path: str):
|
|
27
|
+
"""标记文件正在同步"""
|
|
28
|
+
self._syncing_files.add(file_path)
|
|
29
|
+
|
|
30
|
+
def _unmark_syncing(self, file_path: str):
|
|
31
|
+
"""延迟取消同步标记"""
|
|
32
|
+
def remove():
|
|
33
|
+
time.sleep(2)
|
|
34
|
+
self._syncing_files.discard(file_path)
|
|
35
|
+
threading.Thread(target=remove, daemon=True).start()
|
|
36
|
+
|
|
37
|
+
def _read_local_file(self, file_path: str) -> str | None:
|
|
38
|
+
"""读取本地文件内容"""
|
|
39
|
+
absolute_path = self._get_absolute_path(file_path)
|
|
40
|
+
if os.path.exists(absolute_path):
|
|
41
|
+
try:
|
|
42
|
+
with open(absolute_path, 'r', encoding='utf-8') as f:
|
|
43
|
+
return f.read()
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"[Sync] Error reading local file {file_path}: {e}")
|
|
46
|
+
return None
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def _write_local_file(self, file_path: str, content: str):
|
|
50
|
+
"""写入本地文件"""
|
|
51
|
+
absolute_path = self._get_absolute_path(file_path)
|
|
24
52
|
try:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if not files:
|
|
29
|
-
print("[Sync] No files in cloud")
|
|
30
|
-
return 0
|
|
53
|
+
directory = os.path.dirname(absolute_path)
|
|
54
|
+
if directory and not os.path.exists(directory):
|
|
55
|
+
os.makedirs(directory, exist_ok=True)
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
with open(absolute_path, 'w', encoding='utf-8') as f:
|
|
58
|
+
f.write(content)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"[Sync] Error writing local file {file_path}: {e}")
|
|
61
|
+
|
|
62
|
+
def _create_conflict_backup(self, file_path: str, local_content: str, server_content: str):
|
|
63
|
+
"""创建冲突备份文件"""
|
|
64
|
+
absolute_path = self._get_absolute_path(file_path)
|
|
65
|
+
conflict_path = absolute_path + '.conflict'
|
|
66
|
+
try:
|
|
67
|
+
with open(conflict_path, 'w', encoding='utf-8') as f:
|
|
68
|
+
f.write(f"# Conflict: {file_path}\n")
|
|
69
|
+
f.write(f"# Created at: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
|
70
|
+
f.write("========== LOCAL VERSION ==========\n")
|
|
71
|
+
f.write(local_content or "(empty)")
|
|
72
|
+
f.write("\n\n========== SERVER VERSION ==========\n")
|
|
73
|
+
f.write(server_content or "(empty)")
|
|
74
|
+
print(f"[Sync] Conflict backup created: {conflict_path}")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print(f"[Sync] Error creating conflict backup: {e}")
|
|
77
|
+
|
|
78
|
+
def pull_all(self):
|
|
79
|
+
"""Pull all profiles from cloud"""
|
|
80
|
+
print("[Sync] Pulling all profiles from cloud...")
|
|
51
81
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
print(f"[Sync]
|
|
82
|
+
try:
|
|
83
|
+
result = self.client.get_profiles()
|
|
84
|
+
cloud_files = result.get('files', [])
|
|
85
|
+
except Exception as e:
|
|
86
|
+
print(f"[Sync] Error fetching cloud profiles: {e}")
|
|
57
87
|
return
|
|
58
88
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
content = self._read_local_file(file_path)
|
|
62
|
-
if content is None:
|
|
63
|
-
print(f"[Sync] File not found or read error: {file_path}")
|
|
89
|
+
if not cloud_files:
|
|
90
|
+
print("[Sync] No files on cloud")
|
|
64
91
|
return
|
|
65
92
|
|
|
66
|
-
|
|
93
|
+
pulled_count = 0
|
|
94
|
+
pushed_count = 0
|
|
95
|
+
skipped_count = 0
|
|
67
96
|
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
for cloud_file in cloud_files:
|
|
98
|
+
file_path = cloud_file.get('file_path')
|
|
99
|
+
cloud_version = cloud_file.get('version', 0)
|
|
100
|
+
cloud_content = cloud_file.get('content', '')
|
|
70
101
|
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
if not file_path:
|
|
103
|
+
continue
|
|
73
104
|
|
|
74
|
-
|
|
105
|
+
local_content = self._read_local_file(file_path)
|
|
106
|
+
local_version = self.version_manager.get_version(file_path)
|
|
75
107
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
if cloud_version > local_version:
|
|
109
|
+
self._mark_syncing(file_path)
|
|
110
|
+
try:
|
|
111
|
+
self._write_local_file(file_path, cloud_content)
|
|
112
|
+
self.version_manager.set_version(file_path, cloud_version)
|
|
113
|
+
pulled_count += 1
|
|
114
|
+
print(f"[Sync] Pulled: {file_path} (v{cloud_version})")
|
|
115
|
+
finally:
|
|
116
|
+
self._unmark_syncing(file_path)
|
|
117
|
+
elif local_version > cloud_version and local_content is not None:
|
|
118
|
+
try:
|
|
119
|
+
result = self.client.upload_profile(file_path, local_content, local_version)
|
|
120
|
+
self.version_manager.set_version(file_path, result.get('version', local_version))
|
|
121
|
+
pushed_count += 1
|
|
122
|
+
print(f"[Sync] Pushed: {file_path} (v{local_version})")
|
|
123
|
+
except ConflictError as e:
|
|
124
|
+
self._handle_conflict(file_path, local_content, e.server_content, e.server_version)
|
|
125
|
+
pushed_count += 1
|
|
126
|
+
except Exception as e:
|
|
127
|
+
print(f"[Sync] Error pushing {file_path}: {e}")
|
|
128
|
+
else:
|
|
129
|
+
skipped_count += 1
|
|
89
130
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"""
|
|
94
|
-
|
|
131
|
+
print(f"[Sync] Sync complete: {pulled_count} pulled, {pushed_count} pushed, {skipped_count} skipped")
|
|
132
|
+
|
|
133
|
+
def push_file(self, file_path: str):
|
|
134
|
+
"""Push a file to cloud"""
|
|
135
|
+
if self._is_syncing(file_path):
|
|
136
|
+
return
|
|
95
137
|
|
|
96
|
-
|
|
138
|
+
self._mark_syncing(file_path)
|
|
97
139
|
|
|
98
|
-
|
|
140
|
+
try:
|
|
141
|
+
local_content = self._read_local_file(file_path)
|
|
142
|
+
if local_content is None:
|
|
143
|
+
print(f"[Sync] File not found locally: {file_path}")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
local_version = self.version_manager.get_version(file_path)
|
|
147
|
+
|
|
99
148
|
try:
|
|
100
|
-
result = self.client.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
self._write_local_file(file_path, content)
|
|
108
|
-
self.version_manager.set_version(file_path, version)
|
|
109
|
-
|
|
110
|
-
print(f"[Sync] Pulled remote change: {file_path} (v{version})")
|
|
149
|
+
result = self.client.upload_profile(file_path, local_content, local_version)
|
|
150
|
+
new_version = result.get('version', local_version + 1)
|
|
151
|
+
self.version_manager.set_version(file_path, new_version)
|
|
152
|
+
print(f"[Sync] Pushed: {file_path} (v{new_version})")
|
|
153
|
+
except ConflictError as e:
|
|
154
|
+
self._handle_conflict(file_path, local_content, e.server_content, e.server_version)
|
|
111
155
|
except Exception as e:
|
|
112
|
-
print(f"[Sync]
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
156
|
+
print(f"[Sync] Error pushing {file_path}: {e}")
|
|
157
|
+
finally:
|
|
158
|
+
self._unmark_syncing(file_path)
|
|
159
|
+
|
|
160
|
+
def on_remote_change(self, file_path: str, version: int):
|
|
161
|
+
"""Handle remote file change"""
|
|
162
|
+
local_version = self.version_manager.get_version(file_path)
|
|
163
|
+
|
|
164
|
+
if version <= local_version:
|
|
165
|
+
print(f"[Sync] Remote version not newer, skipping: {file_path} (local: v{local_version}, remote: v{version})")
|
|
166
|
+
return
|
|
117
167
|
|
|
118
|
-
|
|
119
|
-
return None
|
|
168
|
+
self._mark_syncing(file_path)
|
|
120
169
|
|
|
121
170
|
try:
|
|
122
|
-
|
|
123
|
-
|
|
171
|
+
result = self.client.get_profiles(file_path)
|
|
172
|
+
files = result.get('files', [])
|
|
173
|
+
if not files:
|
|
174
|
+
print(f"[Sync] File not found on cloud: {file_path}")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
cloud_file = files[0]
|
|
178
|
+
cloud_content = cloud_file.get('content', '')
|
|
179
|
+
|
|
180
|
+
local_content = self._read_local_file(file_path)
|
|
181
|
+
|
|
182
|
+
if local_content is not None and local_content != cloud_content:
|
|
183
|
+
self._create_conflict_backup(file_path, local_content, cloud_content)
|
|
184
|
+
|
|
185
|
+
self._write_local_file(file_path, cloud_content)
|
|
186
|
+
self.version_manager.set_version(file_path, version)
|
|
187
|
+
print(f"[Sync] Pulled remote change: {file_path} (v{version})")
|
|
124
188
|
except Exception as e:
|
|
125
|
-
print(f"[Sync]
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
189
|
+
print(f"[Sync] Error handling remote change for {file_path}: {e}")
|
|
190
|
+
finally:
|
|
191
|
+
self._unmark_syncing(file_path)
|
|
192
|
+
|
|
193
|
+
def _handle_conflict(self, file_path: str, local_content: str, server_content: str, server_version: int):
|
|
194
|
+
"""Handle conflict when pushing file"""
|
|
195
|
+
print(f"[Sync] CONFLICT detected for {file_path}")
|
|
131
196
|
|
|
132
|
-
|
|
133
|
-
if directory and not os.path.exists(directory):
|
|
134
|
-
os.makedirs(directory, exist_ok=True)
|
|
197
|
+
self._create_conflict_backup(file_path, local_content, server_content)
|
|
135
198
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
199
|
+
self._write_local_file(file_path, server_content)
|
|
200
|
+
self.version_manager.set_version(file_path, server_version)
|
|
201
|
+
|
|
202
|
+
print(f"[Sync] Conflict resolved: server version used for {file_path}")
|
|
203
|
+
print(f"[Sync] Please manually merge the conflict backup file")
|