obsidian-sync

a short description
git clone https://git.skysyrup.dev/obsidian-sync.git
Log | Files | Refs | README

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()