cli.py (10338B)
1 #!/usr/bin/env python3 2 """ 3 obsidian-sync CLI 4 5 Commands: 6 login Sign in and save credentials 7 logout Sign out and remove credentials 8 vaults List vaults for the signed-in account 9 setup Interactively configure a vault for syncing 10 run Start the sync daemon for a configured vault 11 status Show current sync status / storage usage 12 info Show signed-in account info 13 """ 14 15 from __future__ import annotations 16 17 import argparse 18 import asyncio 19 import getpass 20 import json 21 import logging 22 import os 23 import sys 24 from pathlib import Path 25 26 # Config/credentials are stored in ~/.config/obsidian-sync/ 27 CONFIG_DIR = Path.home() / ".config" / "obsidian-sync" 28 CREDENTIALS_FILE = CONFIG_DIR / "credentials.json" 29 30 31 # --------------------------------------------------------------------------- 32 # Credential helpers 33 # --------------------------------------------------------------------------- 34 35 def _save_credentials(data: dict) -> None: 36 CONFIG_DIR.mkdir(parents=True, exist_ok=True) 37 CREDENTIALS_FILE.write_text(json.dumps(data, indent=2)) 38 CREDENTIALS_FILE.chmod(0o600) 39 40 41 def _load_credentials() -> dict: 42 if not CREDENTIALS_FILE.exists(): 43 return {} 44 try: 45 return json.loads(CREDENTIALS_FILE.read_text()) 46 except Exception: 47 return {} 48 49 50 def _require_token() -> str: 51 creds = _load_credentials() 52 token = creds.get("token", "") 53 if not token: 54 print("Not logged in. Run: obsidian-sync login", file=sys.stderr) 55 sys.exit(1) 56 return token 57 58 59 # --------------------------------------------------------------------------- 60 # Commands 61 # --------------------------------------------------------------------------- 62 63 async def cmd_login(args: argparse.Namespace) -> None: 64 from obsidian_sync.api import signin, ObsidianAPIError 65 66 email = args.email or input("Email: ").strip() 67 password = args.password or getpass.getpass("Password: ") 68 mfa = args.mfa or "" 69 70 try: 71 session = await signin(email, password, mfa) 72 except ObsidianAPIError as e: 73 if "mfa" in str(e).lower() or "two-factor" in str(e).lower(): 74 mfa = input("MFA code: ").strip() 75 session = await signin(email, password, mfa) 76 else: 77 print(f"Login failed: {e}", file=sys.stderr) 78 sys.exit(1) 79 80 _save_credentials({ 81 "token": session.token, 82 "email": session.email, 83 "name": session.name, 84 "license": session.license, 85 }) 86 print(f"Logged in as {session.email} ({session.name})") 87 if session.license: 88 print(f"License: {session.license}") 89 90 91 async def cmd_logout(args: argparse.Namespace) -> None: 92 from obsidian_sync.api import signout, ObsidianAPIError 93 94 creds = _load_credentials() 95 token = creds.get("token", "") 96 if token: 97 try: 98 await signout(token) 99 except ObsidianAPIError: 100 pass 101 if CREDENTIALS_FILE.exists(): 102 CREDENTIALS_FILE.unlink() 103 print("Logged out.") 104 105 106 async def cmd_info(args: argparse.Namespace) -> None: 107 from obsidian_sync.api import get_user_info, ObsidianAPIError 108 109 token = _require_token() 110 try: 111 info = await get_user_info(token) 112 except ObsidianAPIError as e: 113 print(f"Error: {e}", file=sys.stderr) 114 sys.exit(1) 115 116 print(f"Email: {info.get('email', '?')}") 117 print(f"Name: {info.get('name', '?')}") 118 print(f"License: {info.get('license', '?')}") 119 120 121 async def cmd_vaults(args: argparse.Namespace) -> None: 122 from obsidian_sync.api import list_vaults, ObsidianAPIError 123 124 token = _require_token() 125 try: 126 vaults = await list_vaults(token) 127 except ObsidianAPIError as e: 128 print(f"Error: {e}", file=sys.stderr) 129 sys.exit(1) 130 131 if not vaults: 132 print("No vaults found.") 133 return 134 135 print(f"{'#':<3} {'Name':<30} {'ID':<36} {'Host':<40} {'Enc'}") 136 print("-" * 120) 137 for i, v in enumerate(vaults): 138 print(f"{i:<3} {v.name:<30} {v.id:<36} {v.host:<40} v{v.encryption_version}") 139 140 141 async def cmd_setup(args: argparse.Namespace) -> None: 142 """Interactively create a vault config file.""" 143 from obsidian_sync.api import list_vaults, ObsidianAPIError 144 145 token = _require_token() 146 147 # List vaults 148 try: 149 vaults = await list_vaults(token) 150 except ObsidianAPIError as e: 151 print(f"Error fetching vaults: {e}", file=sys.stderr) 152 sys.exit(1) 153 154 if not vaults: 155 print("No vaults found on your account.", file=sys.stderr) 156 sys.exit(1) 157 158 print("\nAvailable vaults:") 159 for i, v in enumerate(vaults): 160 print(f" [{i}] {v.name} (enc v{v.encryption_version})") 161 162 idx_str = input("\nVault number: ").strip() 163 try: 164 idx = int(idx_str) 165 vault = vaults[idx] 166 except (ValueError, IndexError): 167 print("Invalid selection.", file=sys.stderr) 168 sys.exit(1) 169 170 local_path = input(f"Local vault path [~/vaults/{vault.name}]: ").strip() 171 if not local_path: 172 local_path = str(Path.home() / "vaults" / vault.name) 173 174 password = getpass.getpass(f"Vault encryption password: ") 175 176 device_name = input("Device name [obsidian-sync-daemon]: ").strip() or "obsidian-sync-daemon" 177 178 out_path = args.output or str(CONFIG_DIR / f"{vault.name.replace(' ', '_')}.json") 179 180 config_data = { 181 "token": token, 182 "vault_id": vault.id, 183 "vault_host": vault.host, 184 "vault_password": password, 185 "vault_salt": vault.salt, 186 "encryption_version": vault.encryption_version, 187 "vault_path": local_path, 188 "device_name": device_name, 189 "sync_images": False, 190 "sync_audio": False, 191 "sync_video": False, 192 "sync_pdf": False, 193 "sync_app_settings": True, 194 "ignore_folders": [], 195 "state_file": "", 196 } 197 198 out = Path(out_path) 199 out.parent.mkdir(parents=True, exist_ok=True) 200 out.write_text(json.dumps(config_data, indent=2)) 201 out.chmod(0o600) 202 print(f"\nConfig saved to: {out_path}") 203 print(f"Start syncing with: obsidian-sync run {out_path}") 204 205 206 async def cmd_run(args: argparse.Namespace) -> None: 207 """Start the sync daemon.""" 208 from obsidian_sync.daemon import SyncDaemon, SyncConfig 209 210 if not args.config: 211 print("Usage: obsidian-sync run <config.json>", file=sys.stderr) 212 sys.exit(1) 213 214 logging.basicConfig( 215 level=logging.DEBUG if args.verbose else logging.INFO, 216 format="%(asctime)s %(levelname)s %(name)s: %(message)s", 217 ) 218 219 try: 220 config = SyncConfig.from_file(args.config) 221 except Exception as e: 222 print(f"Failed to load config: {e}", file=sys.stderr) 223 sys.exit(1) 224 225 daemon = SyncDaemon(config) 226 227 import signal 228 229 loop = asyncio.get_event_loop() 230 231 def _stop(): 232 print("\nStopping...") 233 asyncio.create_task(daemon.stop()) 234 235 for sig in (signal.SIGINT, signal.SIGTERM): 236 loop.add_signal_handler(sig, _stop) 237 238 await daemon.run() 239 240 241 async def cmd_status(args: argparse.Namespace) -> None: 242 """Connect to vault and print storage usage.""" 243 from obsidian_sync.daemon import SyncConfig, SyncState 244 from obsidian_sync.crypto import create_encryption_provider 245 from obsidian_sync.websocket_client import ObsidianSyncClient 246 247 if not args.config: 248 print("Usage: obsidian-sync status <config.json>", file=sys.stderr) 249 sys.exit(1) 250 251 try: 252 config = SyncConfig.from_file(args.config) 253 except Exception as e: 254 print(f"Failed to load config: {e}", file=sys.stderr) 255 sys.exit(1) 256 257 enc = create_encryption_provider(config.vault_password, config.vault_salt, config.encryption_version) 258 client = ObsidianSyncClient( 259 host=config.vault_host, 260 token=config.token, 261 vault_id=config.vault_id, 262 encryption_provider=enc, 263 device_name=config.device_name, 264 last_version=0, 265 initial=False, 266 ) 267 268 await client.connect() 269 try: 270 size_info = await client.get_size() 271 used = size_info.get("vault_size", -1) 272 limit = size_info.get("limit", -1) 273 total = size_info.get("size", -1) 274 print(f"Vault storage: {_fmt_bytes(used)} / {_fmt_bytes(limit)}") 275 print(f"Account total: {_fmt_bytes(total)}") 276 finally: 277 await client.disconnect() 278 279 280 def _fmt_bytes(n: int) -> str: 281 if n < 0: 282 return "?" 283 for unit in ("B", "KB", "MB", "GB"): 284 if n < 1024: 285 return f"{n:.1f} {unit}" 286 n /= 1024 287 return f"{n:.1f} TB" 288 289 290 # --------------------------------------------------------------------------- 291 # Entry point 292 # --------------------------------------------------------------------------- 293 294 def main() -> None: 295 parser = argparse.ArgumentParser( 296 prog="obsidian-sync", 297 description="Lightweight Obsidian Sync client daemon", 298 ) 299 sub = parser.add_subparsers(dest="command", required=True) 300 301 # login 302 p_login = sub.add_parser("login", help="Sign in to Obsidian account") 303 p_login.add_argument("--email", default="") 304 p_login.add_argument("--password", default="") 305 p_login.add_argument("--mfa", default="", help="MFA/2FA code if required") 306 307 # logout 308 sub.add_parser("logout", help="Sign out and remove saved credentials") 309 310 # info 311 sub.add_parser("info", help="Show account info") 312 313 # vaults 314 sub.add_parser("vaults", help="List vaults") 315 316 # setup 317 p_setup = sub.add_parser("setup", help="Interactively configure a vault") 318 p_setup.add_argument("--output", default="", help="Output config file path") 319 320 # run 321 p_run = sub.add_parser("run", help="Start sync daemon") 322 p_run.add_argument("config", nargs="?", default="", help="Path to vault config JSON") 323 p_run.add_argument("-v", "--verbose", action="store_true") 324 325 # status 326 p_status = sub.add_parser("status", help="Show vault storage usage") 327 p_status.add_argument("config", nargs="?", default="", help="Path to vault config JSON") 328 329 args = parser.parse_args() 330 331 cmd_map = { 332 "login": cmd_login, 333 "logout": cmd_logout, 334 "info": cmd_info, 335 "vaults": cmd_vaults, 336 "setup": cmd_setup, 337 "run": cmd_run, 338 "status": cmd_status, 339 } 340 341 coro = cmd_map[args.command](args) 342 asyncio.run(coro) 343 344 345 if __name__ == "__main__": 346 main()