@smilintux/skcapstone 0.1.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/.cursorrules +33 -0
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/publish.yml +52 -0
- package/AGENTS.md +74 -0
- package/CLAUDE.md +56 -0
- package/LICENSE +674 -0
- package/README.md +242 -0
- package/SKILL.md +36 -0
- package/bin/cli.js +18 -0
- package/docs/ARCHITECTURE.md +510 -0
- package/docs/SECURITY_DESIGN.md +315 -0
- package/docs/SOVEREIGN_SINGULARITY.md +371 -0
- package/docs/TOKEN_SYSTEM.md +201 -0
- package/index.d.ts +9 -0
- package/index.js +32 -0
- package/package.json +32 -0
- package/pyproject.toml +84 -0
- package/src/skcapstone/__init__.py +13 -0
- package/src/skcapstone/cli.py +1441 -0
- package/src/skcapstone/connectors/__init__.py +6 -0
- package/src/skcapstone/coordination.py +590 -0
- package/src/skcapstone/discovery.py +275 -0
- package/src/skcapstone/memory_engine.py +457 -0
- package/src/skcapstone/models.py +223 -0
- package/src/skcapstone/pillars/__init__.py +8 -0
- package/src/skcapstone/pillars/identity.py +91 -0
- package/src/skcapstone/pillars/memory.py +61 -0
- package/src/skcapstone/pillars/security.py +83 -0
- package/src/skcapstone/pillars/sync.py +486 -0
- package/src/skcapstone/pillars/trust.py +335 -0
- package/src/skcapstone/runtime.py +190 -0
- package/src/skcapstone/skills/__init__.py +1 -0
- package/src/skcapstone/skills/syncthing_setup.py +297 -0
- package/src/skcapstone/sync/__init__.py +14 -0
- package/src/skcapstone/sync/backends.py +330 -0
- package/src/skcapstone/sync/engine.py +301 -0
- package/src/skcapstone/sync/models.py +97 -0
- package/src/skcapstone/sync/vault.py +284 -0
- package/src/skcapstone/tokens.py +439 -0
- package/tests/__init__.py +0 -0
- package/tests/conftest.py +42 -0
- package/tests/test_coordination.py +299 -0
- package/tests/test_discovery.py +57 -0
- package/tests/test_memory_engine.py +391 -0
- package/tests/test_models.py +63 -0
- package/tests/test_pillars.py +87 -0
- package/tests/test_runtime.py +60 -0
- package/tests/test_sync.py +507 -0
- package/tests/test_syncthing_setup.py +76 -0
- package/tests/test_tokens.py +265 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Syncthing auto-setup skill for skcapstone / OpenClaw agents.
|
|
3
|
+
|
|
4
|
+
Detects, installs, and configures Syncthing for sovereign P2P
|
|
5
|
+
memory synchronization. Generates device IDs, shared folder config,
|
|
6
|
+
and optional QR codes for easy pairing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SYNC_DIR = Path.home() / ".skcapstone" / "sync"
|
|
21
|
+
SYNCTHING_CONFIG_DIR = Path.home() / ".config" / "syncthing"
|
|
22
|
+
SYNCTHING_CONFIG_FILE = SYNCTHING_CONFIG_DIR / "config.xml"
|
|
23
|
+
SHARED_FOLDER_ID = "skcapstone-sync"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def detect_syncthing() -> Optional[str]:
|
|
27
|
+
"""Check if Syncthing is installed and return its path.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Optional[str]: Path to syncthing binary, or None.
|
|
31
|
+
"""
|
|
32
|
+
return shutil.which("syncthing")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_install_instructions() -> str:
|
|
36
|
+
"""Return OS-appropriate install instructions for Syncthing.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
str: Human-readable install instructions.
|
|
40
|
+
"""
|
|
41
|
+
system = platform.system().lower()
|
|
42
|
+
if system == "linux":
|
|
43
|
+
distro = _detect_linux_distro()
|
|
44
|
+
if distro in ("ubuntu", "debian"):
|
|
45
|
+
return (
|
|
46
|
+
"Install Syncthing on Debian/Ubuntu:\n"
|
|
47
|
+
" sudo apt install syncthing\n"
|
|
48
|
+
"Or via official repo:\n"
|
|
49
|
+
" curl -s https://syncthing.net/release-key.gpg | "
|
|
50
|
+
"sudo gpg --dearmor -o /usr/share/keyrings/syncthing-archive-keyring.gpg\n"
|
|
51
|
+
' echo "deb [signed-by=/usr/share/keyrings/syncthing-archive-keyring.gpg] '
|
|
52
|
+
'https://apt.syncthing.net/ syncthing stable" | '
|
|
53
|
+
"sudo tee /etc/apt/sources.list.d/syncthing.list\n"
|
|
54
|
+
" sudo apt update && sudo apt install syncthing"
|
|
55
|
+
)
|
|
56
|
+
elif distro in ("arch", "manjaro"):
|
|
57
|
+
return "Install Syncthing on Arch/Manjaro:\n sudo pacman -S syncthing"
|
|
58
|
+
elif distro in ("fedora", "centos", "rhel"):
|
|
59
|
+
return "Install Syncthing on Fedora/RHEL:\n sudo dnf install syncthing"
|
|
60
|
+
else:
|
|
61
|
+
return "Install Syncthing:\n https://syncthing.net/downloads/"
|
|
62
|
+
elif system == "darwin":
|
|
63
|
+
return "Install Syncthing on macOS:\n brew install syncthing"
|
|
64
|
+
elif system == "windows":
|
|
65
|
+
return (
|
|
66
|
+
"Install Syncthing on Windows:\n"
|
|
67
|
+
" winget install SyncthingFoundation.Syncthing\n"
|
|
68
|
+
"Or download from: https://syncthing.net/downloads/"
|
|
69
|
+
)
|
|
70
|
+
return "Install Syncthing from: https://syncthing.net/downloads/"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _detect_linux_distro() -> str:
|
|
74
|
+
"""Detect the Linux distribution family.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
str: Distribution identifier (e.g., 'ubuntu', 'arch').
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
with open("/etc/os-release") as f:
|
|
81
|
+
for line in f:
|
|
82
|
+
if line.startswith("ID="):
|
|
83
|
+
return line.strip().split("=")[1].strip('"').lower()
|
|
84
|
+
if line.startswith("ID_LIKE="):
|
|
85
|
+
like = line.strip().split("=")[1].strip('"').lower()
|
|
86
|
+
if "arch" in like:
|
|
87
|
+
return "arch"
|
|
88
|
+
if "debian" in like:
|
|
89
|
+
return "debian"
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
pass
|
|
92
|
+
return "unknown"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_device_id() -> Optional[str]:
|
|
96
|
+
"""Get the local Syncthing device ID.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Optional[str]: The device ID string, or None if not available.
|
|
100
|
+
"""
|
|
101
|
+
st = detect_syncthing()
|
|
102
|
+
if not st:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
[st, "--device-id"],
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
timeout=10,
|
|
110
|
+
)
|
|
111
|
+
if result.returncode == 0:
|
|
112
|
+
return result.stdout.strip()
|
|
113
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# Fallback: parse config.xml
|
|
117
|
+
if SYNCTHING_CONFIG_FILE.exists():
|
|
118
|
+
try:
|
|
119
|
+
tree = ET.parse(SYNCTHING_CONFIG_FILE)
|
|
120
|
+
root = tree.getroot()
|
|
121
|
+
for device in root.iter("device"):
|
|
122
|
+
if device.get("name") == platform.node():
|
|
123
|
+
return device.get("id")
|
|
124
|
+
except ET.ParseError:
|
|
125
|
+
pass
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ensure_shared_folder() -> Path:
|
|
130
|
+
"""Create the skcapstone sync shared folder if it doesn't exist.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Path: The shared folder path.
|
|
134
|
+
"""
|
|
135
|
+
for subdir in ("outbox", "inbox", "archive"):
|
|
136
|
+
(SYNC_DIR / subdir).mkdir(parents=True, exist_ok=True)
|
|
137
|
+
return SYNC_DIR
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def configure_syncthing_folder() -> bool:
|
|
141
|
+
"""Add the skcapstone sync folder to Syncthing config.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
bool: True if configuration was added/updated.
|
|
145
|
+
"""
|
|
146
|
+
if not SYNCTHING_CONFIG_FILE.exists():
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
tree = ET.parse(SYNCTHING_CONFIG_FILE)
|
|
151
|
+
root = tree.getroot()
|
|
152
|
+
except ET.ParseError:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
for folder in root.iter("folder"):
|
|
156
|
+
if folder.get("id") == SHARED_FOLDER_ID:
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
folder_elem = ET.SubElement(root, "folder")
|
|
160
|
+
folder_elem.set("id", SHARED_FOLDER_ID)
|
|
161
|
+
folder_elem.set("label", "SKCapstone Sync")
|
|
162
|
+
folder_elem.set("path", str(SYNC_DIR))
|
|
163
|
+
folder_elem.set("type", "sendreceive")
|
|
164
|
+
folder_elem.set("rescanIntervalS", "60")
|
|
165
|
+
folder_elem.set("fsWatcherEnabled", "true")
|
|
166
|
+
folder_elem.set("fsWatcherDelayS", "10")
|
|
167
|
+
|
|
168
|
+
tree.write(str(SYNCTHING_CONFIG_FILE), xml_declaration=True)
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def start_syncthing() -> bool:
|
|
173
|
+
"""Start Syncthing as a background process or systemd service.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
bool: True if started successfully.
|
|
177
|
+
"""
|
|
178
|
+
# Reason: try systemd user service first, then fall back to direct launch
|
|
179
|
+
result = subprocess.run(
|
|
180
|
+
["systemctl", "--user", "start", "syncthing.service"],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
check=False,
|
|
183
|
+
)
|
|
184
|
+
if result.returncode == 0:
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
st = detect_syncthing()
|
|
188
|
+
if not st:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
subprocess.Popen(
|
|
192
|
+
[st, "--no-browser", "--no-restart"],
|
|
193
|
+
stdout=subprocess.DEVNULL,
|
|
194
|
+
stderr=subprocess.DEVNULL,
|
|
195
|
+
)
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def generate_qr_code(device_id: str) -> Optional[str]:
|
|
200
|
+
"""Generate a QR code for the device ID.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
device_id: Syncthing device ID string.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Optional[str]: ASCII QR code string, or None if qrcode not installed.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
import qrcode
|
|
210
|
+
from io import StringIO
|
|
211
|
+
|
|
212
|
+
qr = qrcode.QRCode(box_size=1, border=1)
|
|
213
|
+
qr.add_data(device_id)
|
|
214
|
+
qr.make(fit=True)
|
|
215
|
+
|
|
216
|
+
buf = StringIO()
|
|
217
|
+
qr.print_ascii(out=buf)
|
|
218
|
+
return buf.getvalue()
|
|
219
|
+
except ImportError:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def add_remote_device(device_id: str, name: str = "peer") -> bool:
|
|
224
|
+
"""Add a remote device to Syncthing config for pairing.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
device_id: The remote device's Syncthing device ID.
|
|
228
|
+
name: Friendly name for the device.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
bool: True if device was added.
|
|
232
|
+
"""
|
|
233
|
+
if not SYNCTHING_CONFIG_FILE.exists():
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
tree = ET.parse(SYNCTHING_CONFIG_FILE)
|
|
238
|
+
root = tree.getroot()
|
|
239
|
+
except ET.ParseError:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
for device in root.iter("device"):
|
|
243
|
+
if device.get("id") == device_id:
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
device_elem = ET.SubElement(root, "device")
|
|
247
|
+
device_elem.set("id", device_id)
|
|
248
|
+
device_elem.set("name", name)
|
|
249
|
+
device_elem.set("compression", "metadata")
|
|
250
|
+
device_elem.set("introducer", "false")
|
|
251
|
+
|
|
252
|
+
# Also add this device to the shared folder
|
|
253
|
+
for folder in root.iter("folder"):
|
|
254
|
+
if folder.get("id") == SHARED_FOLDER_ID:
|
|
255
|
+
dev_ref = ET.SubElement(folder, "device")
|
|
256
|
+
dev_ref.set("id", device_id)
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
tree.write(str(SYNCTHING_CONFIG_FILE), xml_declaration=True)
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def full_setup() -> dict:
|
|
264
|
+
"""Run the complete Syncthing setup flow.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
dict: Setup result with device_id, folder_path, status.
|
|
268
|
+
"""
|
|
269
|
+
result = {
|
|
270
|
+
"syncthing_installed": False,
|
|
271
|
+
"device_id": None,
|
|
272
|
+
"folder_path": str(SYNC_DIR),
|
|
273
|
+
"folder_configured": False,
|
|
274
|
+
"started": False,
|
|
275
|
+
"qr_code": None,
|
|
276
|
+
"install_instructions": None,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
st_path = detect_syncthing()
|
|
280
|
+
if not st_path:
|
|
281
|
+
result["install_instructions"] = get_install_instructions()
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
result["syncthing_installed"] = True
|
|
285
|
+
|
|
286
|
+
ensure_shared_folder()
|
|
287
|
+
result["folder_configured"] = configure_syncthing_folder()
|
|
288
|
+
|
|
289
|
+
result["started"] = start_syncthing()
|
|
290
|
+
|
|
291
|
+
device_id = get_device_id()
|
|
292
|
+
result["device_id"] = device_id
|
|
293
|
+
|
|
294
|
+
if device_id:
|
|
295
|
+
result["qr_code"] = generate_qr_code(device_id)
|
|
296
|
+
|
|
297
|
+
return result
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sovereign Sync -- encrypted agent state synchronization.
|
|
3
|
+
|
|
4
|
+
The vault never travels naked. Every push encrypts with CapAuth PGP.
|
|
5
|
+
Every pull verifies the signature before restoring.
|
|
6
|
+
|
|
7
|
+
Backends: Syncthing (real-time P2P), GitHub, Forgejo, Google Drive, local filesystem.
|
|
8
|
+
The human picks the pipe. The agent secures the payload.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .engine import SyncEngine
|
|
12
|
+
from .vault import Vault
|
|
13
|
+
|
|
14
|
+
__all__ = ["SyncEngine", "Vault"]
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sync storage backends -- where the vault travels.
|
|
3
|
+
|
|
4
|
+
Each backend knows how to push and pull vault archives.
|
|
5
|
+
The engine picks which one(s) to use based on config.
|
|
6
|
+
|
|
7
|
+
Syncthing: Real-time P2P. Vault lands in sync/outbox, peers grab it.
|
|
8
|
+
GitHub/Forgejo: Git-based. Vault is committed and pushed.
|
|
9
|
+
GDrive: Google Drive API. Vault uploaded to a folder.
|
|
10
|
+
Local: Plain filesystem copy. For USB drives, NAS, etc.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from .models import SyncBackendConfig, SyncBackendType
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("skcapstone.sync.backends")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SyncBackend(ABC):
|
|
31
|
+
"""Abstract sync transport backend."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def push(self, vault_path: Path, manifest_path: Path) -> bool:
|
|
35
|
+
"""Push a vault archive to the backend.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
vault_path: Path to the vault archive file.
|
|
39
|
+
manifest_path: Path to the accompanying manifest.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if push succeeded.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def pull(self, target_dir: Path) -> Optional[Path]:
|
|
47
|
+
"""Pull the latest vault from the backend.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
target_dir: Where to download the vault.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Path to the downloaded vault, or None if nothing available.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def available(self) -> bool:
|
|
58
|
+
"""Check if this backend is currently usable."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def name(self) -> str:
|
|
63
|
+
"""Human-readable backend name."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SyncthingBackend(SyncBackend):
|
|
67
|
+
"""Syncthing-based real-time P2P sync.
|
|
68
|
+
|
|
69
|
+
Pushes vaults to the Syncthing-watched outbox directory.
|
|
70
|
+
Syncthing handles the actual peer-to-peer transfer.
|
|
71
|
+
Pulls by checking the inbox for incoming vaults.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, config: SyncBackendConfig, agent_home: Path):
|
|
75
|
+
self.config = config
|
|
76
|
+
self.sync_dir = agent_home / "sync"
|
|
77
|
+
self.outbox = self.sync_dir / "outbox"
|
|
78
|
+
self.inbox = self.sync_dir / "inbox"
|
|
79
|
+
self.archive = self.sync_dir / "archive"
|
|
80
|
+
|
|
81
|
+
for d in (self.outbox, self.inbox, self.archive):
|
|
82
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def name(self) -> str:
|
|
86
|
+
return "syncthing"
|
|
87
|
+
|
|
88
|
+
def push(self, vault_path: Path, manifest_path: Path) -> bool:
|
|
89
|
+
"""Copy vault to Syncthing outbox for P2P distribution."""
|
|
90
|
+
try:
|
|
91
|
+
shutil.copy2(vault_path, self.outbox / vault_path.name)
|
|
92
|
+
shutil.copy2(
|
|
93
|
+
manifest_path, self.outbox / manifest_path.name
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
state_file = self.sync_dir / "sync-state.json"
|
|
97
|
+
state = {}
|
|
98
|
+
if state_file.exists():
|
|
99
|
+
try:
|
|
100
|
+
state = json.loads(state_file.read_text())
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
state["last_push"] = datetime.now(timezone.utc).isoformat()
|
|
105
|
+
state["seed_count"] = len(list(self.outbox.glob("*.tar.gz*")))
|
|
106
|
+
state_file.write_text(json.dumps(state, indent=2))
|
|
107
|
+
|
|
108
|
+
logger.info(
|
|
109
|
+
"Vault pushed to Syncthing outbox: %s", vault_path.name
|
|
110
|
+
)
|
|
111
|
+
return True
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
logger.error("Syncthing push failed: %s", exc)
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def pull(self, target_dir: Path) -> Optional[Path]:
|
|
117
|
+
"""Check Syncthing inbox for incoming vaults."""
|
|
118
|
+
vaults = sorted(
|
|
119
|
+
self.inbox.glob("vault-*.tar.gz*"),
|
|
120
|
+
key=lambda p: p.stat().st_mtime,
|
|
121
|
+
reverse=True,
|
|
122
|
+
)
|
|
123
|
+
if not vaults:
|
|
124
|
+
logger.info("No vaults in Syncthing inbox")
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
latest = vaults[0]
|
|
128
|
+
dest = target_dir / latest.name
|
|
129
|
+
shutil.copy2(latest, dest)
|
|
130
|
+
|
|
131
|
+
shutil.move(str(latest), str(self.archive / latest.name))
|
|
132
|
+
|
|
133
|
+
logger.info("Vault pulled from Syncthing inbox: %s", latest.name)
|
|
134
|
+
return dest
|
|
135
|
+
|
|
136
|
+
def available(self) -> bool:
|
|
137
|
+
return shutil.which("syncthing") is not None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class GitBackend(SyncBackend):
|
|
141
|
+
"""Git-based sync backend (GitHub, Forgejo, Gitea, etc).
|
|
142
|
+
|
|
143
|
+
Commits vault archives to a dedicated branch in a git repo.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, config: SyncBackendConfig, agent_home: Path):
|
|
147
|
+
self.config = config
|
|
148
|
+
self.agent_home = agent_home
|
|
149
|
+
self._repo_dir = agent_home / "sync" / "git-cache"
|
|
150
|
+
self._repo_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def name(self) -> str:
|
|
154
|
+
backend_label = (
|
|
155
|
+
"forgejo"
|
|
156
|
+
if self.config.backend_type == SyncBackendType.FORGEJO
|
|
157
|
+
else "github"
|
|
158
|
+
)
|
|
159
|
+
return backend_label
|
|
160
|
+
|
|
161
|
+
def _ensure_repo(self) -> bool:
|
|
162
|
+
"""Clone or verify the git repository."""
|
|
163
|
+
if not self.config.repo_url:
|
|
164
|
+
logger.error("No repo_url configured for git backend")
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
git_dir = self._repo_dir / ".git"
|
|
168
|
+
if git_dir.exists():
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
env = os.environ.copy()
|
|
172
|
+
if self.config.token_env_var:
|
|
173
|
+
token = os.environ.get(self.config.token_env_var, "")
|
|
174
|
+
if token:
|
|
175
|
+
env["GIT_ASKPASS"] = "echo"
|
|
176
|
+
env["GIT_TOKEN"] = token
|
|
177
|
+
|
|
178
|
+
result = subprocess.run(
|
|
179
|
+
["git", "clone", "--depth", "1",
|
|
180
|
+
"-b", self.config.branch,
|
|
181
|
+
self.config.repo_url, str(self._repo_dir)],
|
|
182
|
+
capture_output=True, text=True, check=False, env=env,
|
|
183
|
+
)
|
|
184
|
+
return result.returncode == 0
|
|
185
|
+
|
|
186
|
+
def push(self, vault_path: Path, manifest_path: Path) -> bool:
|
|
187
|
+
if not self._ensure_repo():
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
shutil.copy2(
|
|
192
|
+
vault_path, self._repo_dir / vault_path.name
|
|
193
|
+
)
|
|
194
|
+
shutil.copy2(
|
|
195
|
+
manifest_path, self._repo_dir / manifest_path.name
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
cmds = [
|
|
199
|
+
["git", "add", "-A"],
|
|
200
|
+
[
|
|
201
|
+
"git", "commit", "-m",
|
|
202
|
+
f"vault: {vault_path.name} "
|
|
203
|
+
f"[{datetime.now(timezone.utc).isoformat()}]",
|
|
204
|
+
],
|
|
205
|
+
["git", "push", "origin", self.config.branch],
|
|
206
|
+
]
|
|
207
|
+
for cmd in cmds:
|
|
208
|
+
result = subprocess.run(
|
|
209
|
+
cmd, capture_output=True, text=True,
|
|
210
|
+
check=False, cwd=str(self._repo_dir),
|
|
211
|
+
)
|
|
212
|
+
if result.returncode != 0:
|
|
213
|
+
logger.error(
|
|
214
|
+
"Git command failed: %s -> %s",
|
|
215
|
+
" ".join(cmd), result.stderr,
|
|
216
|
+
)
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
logger.info("Vault pushed to %s", self.name)
|
|
220
|
+
return True
|
|
221
|
+
except OSError as exc:
|
|
222
|
+
logger.error("Git push failed: %s", exc)
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
def pull(self, target_dir: Path) -> Optional[Path]:
|
|
226
|
+
if not self._ensure_repo():
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
result = subprocess.run(
|
|
230
|
+
["git", "pull", "origin", self.config.branch],
|
|
231
|
+
capture_output=True, text=True, check=False,
|
|
232
|
+
cwd=str(self._repo_dir),
|
|
233
|
+
)
|
|
234
|
+
if result.returncode != 0:
|
|
235
|
+
logger.error("Git pull failed: %s", result.stderr)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
vaults = sorted(
|
|
239
|
+
self._repo_dir.glob("vault-*.tar.gz*"),
|
|
240
|
+
key=lambda p: p.stat().st_mtime,
|
|
241
|
+
reverse=True,
|
|
242
|
+
)
|
|
243
|
+
if not vaults:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
latest = vaults[0]
|
|
247
|
+
dest = target_dir / latest.name
|
|
248
|
+
shutil.copy2(latest, dest)
|
|
249
|
+
logger.info("Vault pulled from %s: %s", self.name, latest.name)
|
|
250
|
+
return dest
|
|
251
|
+
|
|
252
|
+
def available(self) -> bool:
|
|
253
|
+
return (
|
|
254
|
+
shutil.which("git") is not None
|
|
255
|
+
and self.config.repo_url is not None
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class LocalBackend(SyncBackend):
|
|
260
|
+
"""Local filesystem backend for USB, NAS, or mounted drives."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, config: SyncBackendConfig, agent_home: Path):
|
|
263
|
+
self.config = config
|
|
264
|
+
self.target = (
|
|
265
|
+
config.local_path.expanduser()
|
|
266
|
+
if config.local_path
|
|
267
|
+
else agent_home / "sync" / "local-backup"
|
|
268
|
+
)
|
|
269
|
+
self.target.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def name(self) -> str:
|
|
273
|
+
return "local"
|
|
274
|
+
|
|
275
|
+
def push(self, vault_path: Path, manifest_path: Path) -> bool:
|
|
276
|
+
try:
|
|
277
|
+
shutil.copy2(vault_path, self.target / vault_path.name)
|
|
278
|
+
shutil.copy2(
|
|
279
|
+
manifest_path, self.target / manifest_path.name
|
|
280
|
+
)
|
|
281
|
+
logger.info("Vault pushed to local: %s", self.target)
|
|
282
|
+
return True
|
|
283
|
+
except OSError as exc:
|
|
284
|
+
logger.error("Local push failed: %s", exc)
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
def pull(self, target_dir: Path) -> Optional[Path]:
|
|
288
|
+
vaults = sorted(
|
|
289
|
+
self.target.glob("vault-*.tar.gz*"),
|
|
290
|
+
key=lambda p: p.stat().st_mtime,
|
|
291
|
+
reverse=True,
|
|
292
|
+
)
|
|
293
|
+
if not vaults:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
latest = vaults[0]
|
|
297
|
+
dest = target_dir / latest.name
|
|
298
|
+
shutil.copy2(latest, dest)
|
|
299
|
+
logger.info("Vault pulled from local: %s", latest.name)
|
|
300
|
+
return dest
|
|
301
|
+
|
|
302
|
+
def available(self) -> bool:
|
|
303
|
+
return self.target.exists()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def create_backend(
|
|
307
|
+
config: SyncBackendConfig, agent_home: Path
|
|
308
|
+
) -> SyncBackend:
|
|
309
|
+
"""Factory function to create the appropriate backend.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
config: Backend configuration.
|
|
313
|
+
agent_home: Agent home directory.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Instantiated SyncBackend.
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
ValueError: If backend type is not supported.
|
|
320
|
+
"""
|
|
321
|
+
factories = {
|
|
322
|
+
SyncBackendType.SYNCTHING: SyncthingBackend,
|
|
323
|
+
SyncBackendType.GITHUB: GitBackend,
|
|
324
|
+
SyncBackendType.FORGEJO: GitBackend,
|
|
325
|
+
SyncBackendType.LOCAL: LocalBackend,
|
|
326
|
+
}
|
|
327
|
+
factory = factories.get(config.backend_type)
|
|
328
|
+
if not factory:
|
|
329
|
+
raise ValueError(f"Unsupported backend: {config.backend_type}")
|
|
330
|
+
return factory(config, agent_home)
|