nia-sync 0.1.0__py3-none-any.whl
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.
- auth.py +168 -0
- config.py +276 -0
- extractor.py +947 -0
- main.py +632 -0
- nia_sync-0.1.0.dist-info/METADATA +9 -0
- nia_sync-0.1.0.dist-info/RECORD +11 -0
- nia_sync-0.1.0.dist-info/WHEEL +5 -0
- nia_sync-0.1.0.dist-info/entry_points.txt +2 -0
- nia_sync-0.1.0.dist-info/top_level.txt +6 -0
- sync.py +192 -0
- watcher.py +304 -0
auth.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication module using the existing MCP device flow.
|
|
3
|
+
|
|
4
|
+
Reuses the existing endpoints:
|
|
5
|
+
- POST /public/mcp-device/start -> get user_code + session_id
|
|
6
|
+
- POST /public/mcp-device/exchange -> exchange for API key
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
from config import (
|
|
16
|
+
NIA_SYNC_DIR,
|
|
17
|
+
CONFIG_FILE,
|
|
18
|
+
load_config,
|
|
19
|
+
save_config,
|
|
20
|
+
clear_config,
|
|
21
|
+
API_BASE_URL,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
# Polling configuration
|
|
27
|
+
POLL_INTERVAL_SECONDS = 2
|
|
28
|
+
MAX_POLL_ATTEMPTS = 150 # 5 minutes max
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_authenticated() -> bool:
|
|
32
|
+
"""Check if user is authenticated (has API key stored)."""
|
|
33
|
+
config = load_config()
|
|
34
|
+
return bool(config.get("api_key"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_api_key() -> str | None:
|
|
38
|
+
"""Get the stored API key."""
|
|
39
|
+
config = load_config()
|
|
40
|
+
return config.get("api_key")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def login() -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Authenticate using the MCP device flow.
|
|
46
|
+
|
|
47
|
+
1. Call /public/mcp-device/start to get user_code
|
|
48
|
+
2. Open browser for user to authenticate
|
|
49
|
+
3. Poll /public/mcp-device/exchange until ready
|
|
50
|
+
4. Store API key locally
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
# Step 1: Start device session
|
|
54
|
+
console.print("Starting authentication...")
|
|
55
|
+
|
|
56
|
+
with httpx.Client(timeout=30) as client:
|
|
57
|
+
response = client.post(f"{API_BASE_URL}/public/mcp-device/start")
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
|
|
60
|
+
data = response.json()
|
|
61
|
+
user_code = data["user_code"]
|
|
62
|
+
authorization_session_id = data["authorization_session_id"]
|
|
63
|
+
verification_url = data["verification_url"]
|
|
64
|
+
|
|
65
|
+
# Step 2: Show code and open browser
|
|
66
|
+
console.print()
|
|
67
|
+
console.print(Panel.fit(
|
|
68
|
+
f"[bold cyan]Your code: {user_code}[/bold cyan]\n\n"
|
|
69
|
+
"1. A browser window will open\n"
|
|
70
|
+
"2. Sign in to your Nia account\n"
|
|
71
|
+
"3. The code will be pre-filled\n"
|
|
72
|
+
"4. Complete the setup, then return here",
|
|
73
|
+
title="Authentication Code",
|
|
74
|
+
))
|
|
75
|
+
console.print()
|
|
76
|
+
|
|
77
|
+
# Open browser
|
|
78
|
+
webbrowser.open(verification_url)
|
|
79
|
+
console.print(f"[dim]Browser opened to: {verification_url}[/dim]")
|
|
80
|
+
console.print()
|
|
81
|
+
console.print("Waiting for authentication...")
|
|
82
|
+
|
|
83
|
+
# Step 3: Poll for completion
|
|
84
|
+
api_key = _poll_for_api_key(authorization_session_id, user_code)
|
|
85
|
+
|
|
86
|
+
if not api_key:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
# Step 4: Store credentials
|
|
90
|
+
save_config({
|
|
91
|
+
"api_key": api_key,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
except httpx.HTTPStatusError as e:
|
|
97
|
+
console.print(f"[red]HTTP error: {e.response.status_code}[/red]")
|
|
98
|
+
return False
|
|
99
|
+
except Exception as e:
|
|
100
|
+
console.print(f"[red]Error during login: {e}[/red]")
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _poll_for_api_key(session_id: str, user_code: str) -> str | None:
|
|
105
|
+
"""Poll the exchange endpoint until authentication completes."""
|
|
106
|
+
with httpx.Client(timeout=30) as client:
|
|
107
|
+
for attempt in range(MAX_POLL_ATTEMPTS):
|
|
108
|
+
try:
|
|
109
|
+
response = client.post(
|
|
110
|
+
f"{API_BASE_URL}/public/mcp-device/exchange",
|
|
111
|
+
json={
|
|
112
|
+
"authorization_session_id": session_id,
|
|
113
|
+
"user_code": user_code,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if response.status_code == 200:
|
|
118
|
+
data = response.json()
|
|
119
|
+
console.print("[green]Authentication successful![/green]")
|
|
120
|
+
return data.get("api_key")
|
|
121
|
+
|
|
122
|
+
elif response.status_code == 400:
|
|
123
|
+
# Not ready yet - still pending or authorized but not ready
|
|
124
|
+
detail = response.json().get("detail", "")
|
|
125
|
+
if "not yet authorized" in detail.lower() or "complete the setup" in detail.lower():
|
|
126
|
+
# Still waiting for user to complete in browser
|
|
127
|
+
_show_waiting_indicator(attempt)
|
|
128
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
129
|
+
continue
|
|
130
|
+
else:
|
|
131
|
+
console.print(f"[red]Error: {detail}[/red]")
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
elif response.status_code == 410:
|
|
135
|
+
console.print("[red]Session expired. Please try again.[/red]")
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
elif response.status_code == 409:
|
|
139
|
+
console.print("[red]Session already used. Please try again.[/red]")
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
elif response.status_code == 404:
|
|
143
|
+
console.print("[red]Invalid session. Please try again.[/red]")
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
else:
|
|
147
|
+
console.print(f"[red]Unexpected error: {response.status_code}[/red]")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
except httpx.RequestError as e:
|
|
151
|
+
console.print(f"[yellow]Network error, retrying... ({e})[/yellow]")
|
|
152
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
console.print("[red]Timeout waiting for authentication. Please try again.[/red]")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _show_waiting_indicator(attempt: int):
|
|
160
|
+
"""Show a waiting indicator."""
|
|
161
|
+
dots = "." * ((attempt % 3) + 1)
|
|
162
|
+
console.print(f"\r[dim]Waiting for browser authentication{dots} [/dim]", end="")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def logout():
|
|
166
|
+
"""Clear stored credentials."""
|
|
167
|
+
clear_config()
|
|
168
|
+
console.print("[green]Credentials cleared.[/green]")
|
config.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Nia Local Sync CLI.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Local config storage (~/.nia-sync/config.json)
|
|
6
|
+
- Fetching source configuration from cloud API
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
# Configuration paths
|
|
15
|
+
NIA_SYNC_DIR = Path.home() / ".nia-sync"
|
|
16
|
+
CONFIG_FILE = NIA_SYNC_DIR / "config.json"
|
|
17
|
+
|
|
18
|
+
# API configuration
|
|
19
|
+
API_BASE_URL = os.getenv("NIA_API_URL", "https://api.trynia.ai")
|
|
20
|
+
|
|
21
|
+
# Default directories to search for folders (no config needed)
|
|
22
|
+
DEFAULT_WATCH_DIRS = [
|
|
23
|
+
"~/Documents",
|
|
24
|
+
"~/Desktop",
|
|
25
|
+
"~/Projects",
|
|
26
|
+
"~/Developer",
|
|
27
|
+
"~/Code",
|
|
28
|
+
"~/dev",
|
|
29
|
+
"~/repos",
|
|
30
|
+
"~/Downloads",
|
|
31
|
+
"~/src",
|
|
32
|
+
"~/work",
|
|
33
|
+
"~/workspace",
|
|
34
|
+
"~/github",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_watch_dirs() -> list[str]:
|
|
39
|
+
"""Get directories to search for folders. Uses defaults + any custom ones."""
|
|
40
|
+
config = load_config()
|
|
41
|
+
custom = config.get("watch_dirs", [])
|
|
42
|
+
# Combine defaults + custom, dedupe
|
|
43
|
+
all_dirs = DEFAULT_WATCH_DIRS + custom
|
|
44
|
+
return list(dict.fromkeys(all_dirs))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_folder_path(folder_name: str, max_depth: int = 3) -> str | None:
|
|
48
|
+
"""
|
|
49
|
+
Search watch directories recursively for a folder with the given name.
|
|
50
|
+
Returns the full path if found, None otherwise.
|
|
51
|
+
|
|
52
|
+
Searches up to max_depth levels deep to avoid scanning entire filesystem.
|
|
53
|
+
"""
|
|
54
|
+
def search_dir(base: str, depth: int) -> str | None:
|
|
55
|
+
if depth > max_depth:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
for entry in os.scandir(base):
|
|
60
|
+
if not entry.is_dir():
|
|
61
|
+
continue
|
|
62
|
+
# Skip hidden directories and common large dirs
|
|
63
|
+
if entry.name.startswith('.') or entry.name in ('node_modules', 'venv', '__pycache__', 'build', 'dist'):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if entry.name == folder_name:
|
|
67
|
+
return entry.path
|
|
68
|
+
|
|
69
|
+
# Recurse into subdirectory
|
|
70
|
+
if depth < max_depth:
|
|
71
|
+
found = search_dir(entry.path, depth + 1)
|
|
72
|
+
if found:
|
|
73
|
+
return found
|
|
74
|
+
except PermissionError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
for watch_dir in get_watch_dirs():
|
|
80
|
+
expanded = os.path.expanduser(watch_dir)
|
|
81
|
+
if not os.path.isdir(expanded):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Check direct child first (fast path)
|
|
85
|
+
candidate = os.path.join(expanded, folder_name)
|
|
86
|
+
if os.path.exists(candidate):
|
|
87
|
+
return candidate
|
|
88
|
+
|
|
89
|
+
# Search recursively
|
|
90
|
+
found = search_dir(expanded, 1)
|
|
91
|
+
if found:
|
|
92
|
+
return found
|
|
93
|
+
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ensure_config_dir():
|
|
98
|
+
"""Ensure the config directory exists."""
|
|
99
|
+
NIA_SYNC_DIR.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_config() -> dict[str, Any]:
|
|
103
|
+
"""Load configuration from disk."""
|
|
104
|
+
if not CONFIG_FILE.exists():
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
with open(CONFIG_FILE, "r") as f:
|
|
109
|
+
return json.load(f)
|
|
110
|
+
except (json.JSONDecodeError, IOError):
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_config(config: dict[str, Any]):
|
|
115
|
+
"""Save configuration to disk."""
|
|
116
|
+
ensure_config_dir()
|
|
117
|
+
|
|
118
|
+
# Merge with existing config
|
|
119
|
+
existing = load_config()
|
|
120
|
+
existing.update(config)
|
|
121
|
+
|
|
122
|
+
with open(CONFIG_FILE, "w") as f:
|
|
123
|
+
json.dump(existing, f, indent=2)
|
|
124
|
+
|
|
125
|
+
# Secure the file (readable only by owner)
|
|
126
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def clear_config():
|
|
130
|
+
"""Clear all stored configuration."""
|
|
131
|
+
if CONFIG_FILE.exists():
|
|
132
|
+
CONFIG_FILE.unlink()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_api_key() -> str | None:
|
|
136
|
+
"""Get the stored API key."""
|
|
137
|
+
config = load_config()
|
|
138
|
+
return config.get("api_key")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_sources() -> list[dict[str, Any]]:
|
|
142
|
+
"""
|
|
143
|
+
Fetch configured sources from the cloud API.
|
|
144
|
+
|
|
145
|
+
Returns list of sources with:
|
|
146
|
+
- local_folder_id: UUID of the local folder
|
|
147
|
+
- path: Local path to sync (e.g., ~/Library/Messages/chat.db)
|
|
148
|
+
- detected_type: Type of source (imessage, safari_history, folder, etc.)
|
|
149
|
+
- cursor: Current sync cursor (for incremental sync)
|
|
150
|
+
- last_synced: ISO timestamp of last sync
|
|
151
|
+
"""
|
|
152
|
+
api_key = get_api_key()
|
|
153
|
+
if not api_key:
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
with httpx.Client(timeout=30) as client:
|
|
158
|
+
response = client.get(
|
|
159
|
+
f"{API_BASE_URL}/v2/daemon/sources",
|
|
160
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
161
|
+
)
|
|
162
|
+
response.raise_for_status()
|
|
163
|
+
return response.json()
|
|
164
|
+
|
|
165
|
+
except httpx.HTTPStatusError as e:
|
|
166
|
+
if e.response.status_code == 401:
|
|
167
|
+
# Invalid/expired API key
|
|
168
|
+
return []
|
|
169
|
+
raise
|
|
170
|
+
except httpx.RequestError:
|
|
171
|
+
# Network error - return empty for now
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def add_source(path: str, detected_type: str | None = None) -> dict[str, Any] | None:
|
|
176
|
+
"""
|
|
177
|
+
Add a new source for daemon sync.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
path: Local path to sync
|
|
181
|
+
detected_type: Optional detected type
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Created source info or None on failure
|
|
185
|
+
"""
|
|
186
|
+
api_key = get_api_key()
|
|
187
|
+
if not api_key:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
with httpx.Client(timeout=30) as client:
|
|
192
|
+
response = client.post(
|
|
193
|
+
f"{API_BASE_URL}/v2/daemon/sources",
|
|
194
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
195
|
+
json={
|
|
196
|
+
"path": path,
|
|
197
|
+
"detected_type": detected_type,
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
response.raise_for_status()
|
|
201
|
+
return response.json()
|
|
202
|
+
|
|
203
|
+
except httpx.HTTPStatusError:
|
|
204
|
+
return None
|
|
205
|
+
except httpx.RequestError:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def remove_source(local_folder_id: str) -> bool:
|
|
210
|
+
"""
|
|
211
|
+
Remove a source from daemon sync.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
local_folder_id: ID of the source to remove
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
True on success, False on failure
|
|
218
|
+
"""
|
|
219
|
+
api_key = get_api_key()
|
|
220
|
+
if not api_key:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
with httpx.Client(timeout=30) as client:
|
|
225
|
+
response = client.delete(
|
|
226
|
+
f"{API_BASE_URL}/v2/daemon/sources/{local_folder_id}",
|
|
227
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
228
|
+
)
|
|
229
|
+
return response.status_code == 200
|
|
230
|
+
|
|
231
|
+
except httpx.HTTPStatusError:
|
|
232
|
+
return False
|
|
233
|
+
except httpx.RequestError:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def update_source_cursor(local_folder_id: str, cursor: dict[str, Any]) -> bool:
|
|
238
|
+
"""
|
|
239
|
+
Update the sync cursor for a source after successful sync.
|
|
240
|
+
|
|
241
|
+
This is called by the sync engine after pushing data to the backend.
|
|
242
|
+
The backend updates the cursor in the database.
|
|
243
|
+
"""
|
|
244
|
+
# Note: Cursor is updated by the /daemon/sync endpoint, not a separate call
|
|
245
|
+
# This function is here for potential future use
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def enable_source_sync(local_folder_id: str, path: str) -> bool:
|
|
250
|
+
"""
|
|
251
|
+
Enable daemon sync for a source that exists locally.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
local_folder_id: ID of the source
|
|
255
|
+
path: Local path where the source exists
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
True on success, False on failure
|
|
259
|
+
"""
|
|
260
|
+
api_key = get_api_key()
|
|
261
|
+
if not api_key:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
with httpx.Client(timeout=30) as client:
|
|
266
|
+
response = client.post(
|
|
267
|
+
f"{API_BASE_URL}/v2/daemon/sources/{local_folder_id}/enable",
|
|
268
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
269
|
+
json={"path": path},
|
|
270
|
+
)
|
|
271
|
+
return response.status_code == 200
|
|
272
|
+
|
|
273
|
+
except httpx.HTTPStatusError:
|
|
274
|
+
return False
|
|
275
|
+
except httpx.RequestError:
|
|
276
|
+
return False
|