nia-sync 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: nia-sync
3
+ Version: 0.1.0
4
+ Summary: Keep your local files in sync with Nia
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typer>=0.9.0
7
+ Requires-Dist: rich>=13.0.0
8
+ Requires-Dist: httpx>=0.25.0
9
+ Requires-Dist: watchdog>=4.0.0
nia_sync-0.1.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]")
@@ -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