From ddfe17b77bfdc91ea3ec560503d98b8f1082d890 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:34:12 -0400 Subject: [PATCH] Add StateManager and relay CLI --- src/seedpass/cli.py | 33 ++++++++++++ src/seedpass/core/__init__.py | 4 +- src/seedpass/core/api.py | 18 +++++++ src/seedpass/core/manager.py | 21 +++++++- src/seedpass/core/state_manager.py | 85 ++++++++++++++++++++++++++++++ src/tests/test_cli_relays.py | 53 +++++++++++++++++++ src/tests/test_state_manager.py | 26 +++++++++ 7 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 src/seedpass/core/state_manager.py create mode 100644 src/tests/test_cli_relays.py create mode 100644 src/tests/test_state_manager.py diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index dd3f1af..caf86b9 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -492,6 +492,39 @@ def nostr_get_pubkey(ctx: typer.Context) -> None: typer.echo(npub) +@nostr_app.command("list-relays") +def nostr_list_relays(ctx: typer.Context) -> None: + """Display configured Nostr relays.""" + service = _get_nostr_service(ctx) + relays = service.list_relays() + for i, r in enumerate(relays, 1): + typer.echo(f"{i}: {r}") + + +@nostr_app.command("add-relay") +def nostr_add_relay(ctx: typer.Context, url: str) -> None: + """Add a relay URL.""" + service = _get_nostr_service(ctx) + try: + service.add_relay(url) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo("Added") + + +@nostr_app.command("remove-relay") +def nostr_remove_relay(ctx: typer.Context, idx: int) -> None: + """Remove a relay by index (1-based).""" + service = _get_nostr_service(ctx) + try: + service.remove_relay(idx) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo("Removed") + + @config_app.command("get") def config_get(ctx: typer.Context, key: str) -> None: """Get a configuration value.""" diff --git a/src/seedpass/core/__init__.py b/src/seedpass/core/__init__.py index 00d933c..4610c5e 100644 --- a/src/seedpass/core/__init__.py +++ b/src/seedpass/core/__init__.py @@ -4,7 +4,7 @@ from importlib import import_module -__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"] +__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType", "StateManager"] def __getattr__(name: str): @@ -16,4 +16,6 @@ def __getattr__(name: str): return import_module(".vault", __name__).Vault if name == "EntryType": return import_module(".entry_types", __name__).EntryType + if name == "StateManager": + return import_module(".state_manager", __name__).StateManager raise AttributeError(f"module '{__name__}' has no attribute '{name}'") diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index eeb76af..a214a2e 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -525,3 +525,21 @@ class NostrService: def get_pubkey(self) -> str: with self._lock: return self._manager.nostr_client.key_manager.get_npub() + + def list_relays(self) -> list[str]: + with self._lock: + return self._manager.state_manager.list_relays() + + def add_relay(self, url: str) -> None: + with self._lock: + self._manager.state_manager.add_relay(url) + self._manager.nostr_client.relays = ( + self._manager.state_manager.list_relays() + ) + + def remove_relay(self, idx: int) -> None: + with self._lock: + self._manager.state_manager.remove_relay(idx) + self._manager.nostr_client.relays = ( + self._manager.state_manager.list_relays() + ) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index ce30289..74a0da1 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -95,6 +95,7 @@ from utils.fingerprint_manager import FingerprintManager # Import NostrClient from nostr.client import NostrClient, DEFAULT_RELAYS from .config_manager import ConfigManager +from .state_manager import StateManager # Instantiate the logger logger = logging.getLogger(__name__) @@ -140,6 +141,7 @@ class PasswordManager: self.bip85: Optional[BIP85] = None self.nostr_client: Optional[NostrClient] = None self.config_manager: Optional[ConfigManager] = None + self.state_manager: Optional[StateManager] = None self.notifications: queue.Queue[Notification] = queue.Queue() self._current_notification: Optional[Notification] = None self._notification_expiry: float = 0.0 @@ -157,6 +159,8 @@ class PasswordManager: self.last_unlock_duration: float | None = None self.verbose_timing: bool = False self._suppress_entry_actions_menu: bool = False + self.last_bip85_idx: int = 0 + self.last_sync_ts: int = 0 # Initialize the fingerprint manager first self.initialize_fingerprint_manager() @@ -1073,6 +1077,7 @@ class PasswordManager: vault=self.vault, fingerprint_dir=self.fingerprint_dir, ) + self.state_manager = StateManager(self.fingerprint_dir) self.backup_manager = BackupManager( fingerprint_dir=self.fingerprint_dir, config_manager=self.config_manager, @@ -1091,7 +1096,15 @@ class PasswordManager: # Load relay configuration and initialize NostrClient config = self.config_manager.load_config() - relay_list = config.get("relays", list(DEFAULT_RELAYS)) + if getattr(self, "state_manager", None) is not None: + state = self.state_manager.state + relay_list = state.get("relays", list(DEFAULT_RELAYS)) + self.last_bip85_idx = state.get("last_bip85_idx", 0) + self.last_sync_ts = state.get("last_sync_ts", 0) + else: + relay_list = list(DEFAULT_RELAYS) + self.last_bip85_idx = 0 + self.last_sync_ts = 0 self.offline_mode = bool(config.get("offline_mode", False)) self.inactivity_timeout = config.get( "inactivity_timeout", INACTIVITY_TIMEOUT @@ -3966,7 +3979,11 @@ class PasswordManager: self.password_generator.encryption_manager = new_enc_mgr self.store_hashed_password(new_password) - relay_list = config_data.get("relays", list(DEFAULT_RELAYS)) + if getattr(self, "state_manager", None) is not None: + state = self.state_manager.state + relay_list = state.get("relays", list(DEFAULT_RELAYS)) + else: + relay_list = list(DEFAULT_RELAYS) self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, diff --git a/src/seedpass/core/state_manager.py b/src/seedpass/core/state_manager.py new file mode 100644 index 0000000..8d142f9 --- /dev/null +++ b/src/seedpass/core/state_manager.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import List + +from utils.file_lock import exclusive_lock, shared_lock +from nostr.client import DEFAULT_RELAYS + + +class StateManager: + """Persist simple state values per profile.""" + + STATE_FILENAME = "seedpass_state.json" + + def __init__(self, fingerprint_dir: Path) -> None: + self.fingerprint_dir = Path(fingerprint_dir) + self.state_path = self.fingerprint_dir / self.STATE_FILENAME + + def _load(self) -> dict: + if not self.state_path.exists(): + return { + "last_bip85_idx": 0, + "last_sync_ts": 0, + "relays": list(DEFAULT_RELAYS), + } + with shared_lock(self.state_path) as fh: + fh.seek(0) + data = fh.read() + if not data: + return { + "last_bip85_idx": 0, + "last_sync_ts": 0, + "relays": list(DEFAULT_RELAYS), + } + try: + obj = json.loads(data.decode()) + except Exception: + obj = {} + obj.setdefault("last_bip85_idx", 0) + obj.setdefault("last_sync_ts", 0) + obj.setdefault("relays", list(DEFAULT_RELAYS)) + return obj + + def _save(self, data: dict) -> None: + with exclusive_lock(self.state_path) as fh: + fh.seek(0) + fh.truncate() + fh.write(json.dumps(data, separators=(",", ":")).encode()) + fh.flush() + os.fsync(fh.fileno()) + + @property + def state(self) -> dict: + return self._load() + + def update_state(self, **kwargs) -> None: + data = self._load() + data.update(kwargs) + self._save(data) + + # Relay helpers + def list_relays(self) -> List[str]: + return self._load().get("relays", []) + + def add_relay(self, url: str) -> None: + data = self._load() + relays = data.get("relays", []) + if url in relays: + raise ValueError("Relay already present") + relays.append(url) + data["relays"] = relays + self._save(data) + + def remove_relay(self, idx: int) -> None: + data = self._load() + relays = data.get("relays", []) + if not 1 <= idx <= len(relays): + raise ValueError("Invalid index") + if len(relays) == 1: + raise ValueError("At least one relay required") + relays.pop(idx - 1) + data["relays"] = relays + self._save(data) diff --git a/src/tests/test_cli_relays.py b/src/tests/test_cli_relays.py new file mode 100644 index 0000000..fcfe5fc --- /dev/null +++ b/src/tests/test_cli_relays.py @@ -0,0 +1,53 @@ +from types import SimpleNamespace +from typer.testing import CliRunner + +from seedpass.cli import app +from seedpass import cli + + +class DummyService: + def __init__(self, relays): + self.relays = relays + + def get_pubkey(self): + return "npub" + + def list_relays(self): + return self.relays + + def add_relay(self, url): + if url in self.relays: + raise ValueError("exists") + self.relays.append(url) + + def remove_relay(self, idx): + if not 1 <= idx <= len(self.relays): + raise ValueError("bad") + if len(self.relays) == 1: + raise ValueError("min") + self.relays.pop(idx - 1) + + +runner = CliRunner() + + +def test_cli_relay_crud(monkeypatch): + relays = ["wss://a"] + + def pm_factory(*a, **k): + return SimpleNamespace() + + monkeypatch.setattr(cli, "PasswordManager", pm_factory) + monkeypatch.setattr(cli, "NostrService", lambda pm: DummyService(relays)) + + result = runner.invoke(app, ["nostr", "list-relays"]) + assert "1: wss://a" in result.stdout + + result = runner.invoke(app, ["nostr", "add-relay", "wss://b"]) + assert result.exit_code == 0 + assert "Added" in result.stdout + assert relays == ["wss://a", "wss://b"] + + result = runner.invoke(app, ["nostr", "remove-relay", "1"]) + assert result.exit_code == 0 + assert relays == ["wss://b"] diff --git a/src/tests/test_state_manager.py b/src/tests/test_state_manager.py new file mode 100644 index 0000000..0aef6d6 --- /dev/null +++ b/src/tests/test_state_manager.py @@ -0,0 +1,26 @@ +from tempfile import TemporaryDirectory +from pathlib import Path + +from seedpass.core.state_manager import StateManager +from nostr.client import DEFAULT_RELAYS + + +def test_state_manager_round_trip(): + with TemporaryDirectory() as tmpdir: + sm = StateManager(Path(tmpdir)) + state = sm.state + assert state["relays"] == list(DEFAULT_RELAYS) + assert state["last_bip85_idx"] == 0 + assert state["last_sync_ts"] == 0 + + sm.add_relay("wss://example.com") + sm.update_state(last_bip85_idx=5, last_sync_ts=123) + + sm2 = StateManager(Path(tmpdir)) + state2 = sm2.state + assert "wss://example.com" in state2["relays"] + assert state2["last_bip85_idx"] == 5 + assert state2["last_sync_ts"] == 123 + + sm2.remove_relay(1) # remove first default relay + assert len(sm2.list_relays()) == len(DEFAULT_RELAYS)