diff --git a/docs/assets/theme.js b/docs/assets/theme.js
index 518779eb..88670fbe 100644
--- a/docs/assets/theme.js
+++ b/docs/assets/theme.js
@@ -46,9 +46,26 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
+ function escapeHtml(text) {
+ if (!text) return text;
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
function highlight(text, q) {
+ if (!q) return escapeHtml(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + ')', 'gi');
- return text.replace(re, '$1');
+ return text.split(re).map((part, index) => {
+ if (index % 2 === 1) {
+ return '' + escapeHtml(part) + '';
+ } else {
+ return escapeHtml(part);
+ }
+ }).join('');
}
searchInput?.addEventListener('input', async e => {
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..76f66ca5
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "SeedPass",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/src/main.py b/src/main.py
index 2839db0e..eca4f940 100644
--- a/src/main.py
+++ b/src/main.py
@@ -11,6 +11,7 @@ import os
import logging
import signal
import time
+import queue
import argparse
import tomli
from tomli import TOMLDecodeError
@@ -45,7 +46,6 @@ from utils.logging_utils import (
)
from local_bip85.bip85 import Bip85Error
-
colorama_init()
OPTIONAL_DEPENDENCIES = {
diff --git a/src/nostr/event_handler.py b/src/nostr/event_handler.py
deleted file mode 100644
index 416de6a1..00000000
--- a/src/nostr/event_handler.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# nostr/event_handler.py
-
-import time
-import logging
-
-from nostr_sdk import Event
-
-
-# Instantiate the logger
-logger = logging.getLogger(__name__)
-
-
-class EventHandler:
- """
- Handles incoming Nostr events.
- """
-
- def __init__(self):
- pass # Initialize if needed
-
- def handle_new_event(self, evt: Event):
- """Process and log details from a Nostr event."""
-
- try:
- created_at = evt.created_at().as_secs()
- created_at_str = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(created_at))
- event_id = evt.id().to_hex()
-
- logger.info(
- f"[New Event] ID: {event_id} | Created At: {created_at_str} | Content: {evt.content()}"
- )
- except Exception as e:
- logger.error(f"Error handling new event: {e}", exc_info=True)
diff --git a/src/repo-context.txt b/src/repo-context.txt
index 0e3a669f..86ff7ca8 100644
--- a/src/repo-context.txt
+++ b/src/repo-context.txt
@@ -3081,52 +3081,6 @@ __all__ = ['NostrClient']
```
-## nostr/event_handler.py
-```python
-# nostr/event_handler.py
-
-import time
-import logging
-import traceback
-from monstr.event.event import Event
-
-# Instantiate the logger
-logger = logging.getLogger(__name__)
-
-class EventHandler:
- """
- Handles incoming Nostr events.
- """
-
- def __init__(self):
- pass # Initialize if needed
-
- def handle_new_event(self, evt: Event):
- """
- Processes incoming events by logging their details.
-
- :param evt: The received Event object.
- """
- try:
- # Assuming evt.created_at is always an integer Unix timestamp
- if isinstance(evt.created_at, int):
- created_at_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(evt.created_at))
- else:
- # Handle unexpected types gracefully
- created_at_str = str(evt.created_at)
-
- # Log the event details without extra newlines
- logger.info(
- f"[New Event] ID: {evt.id} | Created At: {created_at_str} | Content: {evt.content}"
- )
- except Exception as e:
- logger.error(f"Error handling new event: {e}")
- logger.error(traceback.format_exc())
- # Optionally, handle the exception without re-raising
- # For example, continue processing other events
-
-```
-
## nostr/encryption_manager.py
```python
# nostr/encryption_manager.py
@@ -3276,7 +3230,6 @@ import fcntl
from .key_manager import KeyManager
from .encryption_manager import EncryptionManager
-from .event_handler import EventHandler
from constants import APP_DIR
from utils.file_lock import lock_file
@@ -3326,7 +3279,6 @@ class NostrClient:
)
# Initialize event handler and client pool
- self.event_handler = EventHandler()
self.relays = relays if relays else DEFAULT_RELAYS
self.client_pool = ClientPool(self.relays)
self.subscriptions = {}
@@ -3582,7 +3534,6 @@ class NostrClient:
try:
await asyncio.gather(
self.do_post_async(text),
- self.subscribe_feed_async(self.event_handler.handle_new_event)
)
except Exception as e:
logger.error(f"An error occurred in publish_and_subscribe_async: {e}", exc_info=True)
diff --git a/src/seedpass/api.py b/src/seedpass/api.py
index 1aaef773..4c496885 100644
--- a/src/seedpass/api.py
+++ b/src/seedpass/api.py
@@ -28,7 +28,6 @@ from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import UtilityService
-
_RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100"))
_RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60"))
_RATE_LIMIT_STR = f"{_RATE_LIMIT}/{_RATE_WINDOW} seconds"
diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py
index 3b76f6ba..3babfcd0 100644
--- a/src/seedpass/cli/__init__.py
+++ b/src/seedpass/cli/__init__.py
@@ -28,14 +28,12 @@ no_clipboard_option = typer.Option(
False,
"--no-clipboard",
help="Disable clipboard support and print secrets instead",
- is_flag=True,
)
deterministic_totp_option = typer.Option(
False,
"--deterministic-totp",
help="Derive TOTP secrets deterministically",
- is_flag=True,
)
# Sub command groups
diff --git a/src/seedpass/cli/api.py b/src/seedpass/cli/api.py
index a4d33d08..618d8d9a 100644
--- a/src/seedpass/cli/api.py
+++ b/src/seedpass/cli/api.py
@@ -5,7 +5,6 @@ import uvicorn
from .. import api as api_module
-
app = typer.Typer(help="Run the API server")
diff --git a/src/seedpass/cli/config.py b/src/seedpass/cli/config.py
index 36e78c6d..4d58e5d3 100644
--- a/src/seedpass/cli/config.py
+++ b/src/seedpass/cli/config.py
@@ -4,7 +4,6 @@ import typer
from .common import _get_config_service
-
app = typer.Typer(help="Get or set configuration values")
diff --git a/src/seedpass/cli/entry.py b/src/seedpass/cli/entry.py
index a90201d9..1474cdfb 100644
--- a/src/seedpass/cli/entry.py
+++ b/src/seedpass/cli/entry.py
@@ -12,7 +12,6 @@ from .common import _get_entry_service, EntryType
from seedpass.core.entry_types import ALL_ENTRY_TYPES
from utils.clipboard import ClipboardUnavailableError
-
app = typer.Typer(help="Manage individual entries")
@@ -124,7 +123,7 @@ def entry_add(
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
no_special: bool = typer.Option(
- False, "--no-special", help="Exclude special characters", is_flag=True
+ False, "--no-special", help="Exclude special characters"
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
@@ -138,7 +137,6 @@ def entry_add(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
- is_flag=True,
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
diff --git a/src/seedpass/cli/fingerprint.py b/src/seedpass/cli/fingerprint.py
index 6d0653f5..630cead1 100644
--- a/src/seedpass/cli/fingerprint.py
+++ b/src/seedpass/cli/fingerprint.py
@@ -4,7 +4,6 @@ import typer
from .common import _get_services, ProfileRemoveRequest, ProfileSwitchRequest
-
app = typer.Typer(help="Manage seed profiles")
diff --git a/src/seedpass/cli/nostr.py b/src/seedpass/cli/nostr.py
index ba26e491..07a1b113 100644
--- a/src/seedpass/cli/nostr.py
+++ b/src/seedpass/cli/nostr.py
@@ -4,7 +4,6 @@ import typer
from .common import _get_services, _get_nostr_service
-
app = typer.Typer(
help="Interact with Nostr relays. See docs/nostr_setup.md for configuration and troubleshooting."
)
diff --git a/src/seedpass/cli/util.py b/src/seedpass/cli/util.py
index 83aa0b5e..77314944 100644
--- a/src/seedpass/cli/util.py
+++ b/src/seedpass/cli/util.py
@@ -6,7 +6,6 @@ import typer
from .common import _get_util_service
-
app = typer.Typer(help="Utility commands")
@@ -15,7 +14,7 @@ def generate_password(
ctx: typer.Context,
length: int = 24,
no_special: bool = typer.Option(
- False, "--no-special", help="Exclude special characters", is_flag=True
+ False, "--no-special", help="Exclude special characters"
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
@@ -29,7 +28,6 @@ def generate_password(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
- is_flag=True,
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
diff --git a/src/seedpass/cli/vault.py b/src/seedpass/cli/vault.py
index 89e69ce6..a5269340 100644
--- a/src/seedpass/cli/vault.py
+++ b/src/seedpass/cli/vault.py
@@ -13,7 +13,6 @@ from .common import (
BackupParentSeedRequest,
)
-
app = typer.Typer(help="Manage the entire vault")
diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py
index ed41776a..f02e6a77 100644
--- a/src/seedpass/core/config_manager.py
+++ b/src/seedpass/core/config_manager.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from pathlib import Path
-from typing import List, Optional
+from typing import List, Optional, TYPE_CHECKING
from utils.seed_prompt import masked_input
@@ -15,6 +15,9 @@ from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY
+if TYPE_CHECKING:
+ from .password_generation import PasswordPolicy
+
logger = logging.getLogger(__name__)
diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py
index b0b5c7c5..9e33d26a 100644
--- a/src/seedpass/core/encryption.py
+++ b/src/seedpass/core/encryption.py
@@ -6,7 +6,7 @@ import unicodedata
try:
import orjson as json_lib # type: ignore
- JSONDecodeError = orjson.JSONDecodeError
+ JSONDecodeError = json_lib.JSONDecodeError
USE_ORJSON = True
except Exception: # pragma: no cover - fallback for environments without orjson
import json as json_lib
diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py
index f953fbdb..ca4174bd 100644
--- a/src/seedpass/core/entry_management.py
+++ b/src/seedpass/core/entry_management.py
@@ -49,7 +49,6 @@ from .vault import Vault
from .backup import BackupManager
from .errors import SeedPassError
-
# Instantiate the logger
logger = logging.getLogger(__name__)
@@ -1337,7 +1336,7 @@ class EntryManager:
atomic_write(checksum_path, lambda f: f.write(checksum))
logger.debug(f"Checksum updated and written to '{checksum_path}'.")
- print(colored(f"[+] Checksum updated successfully.", "green"))
+ print(colored("[+] Checksum updated successfully.", "green"))
except Exception as e:
logger.error(f"Failed to update checksum: {e}", exc_info=True)
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index 9c25101a..d74540c2 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -1671,7 +1671,7 @@ class PasswordManager:
self.error_queue.put(exc)
try:
- loop = asyncio.get_running_loop()
+ asyncio.get_running_loop()
except RuntimeError:
thread = threading.Thread(
target=lambda: asyncio.run(_worker()), daemon=True
@@ -1734,7 +1734,7 @@ class PasswordManager:
self.error_queue.put(exc)
try:
- loop = asyncio.get_running_loop()
+ asyncio.get_running_loop()
except RuntimeError:
threading.Thread(target=_worker, daemon=True).start()
else:
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index ae376de3..f46b6f30 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -4,14 +4,30 @@ import sys
from pathlib import Path
import pytest
+from types import SimpleNamespace
+from httpx import ASGITransport, AsyncClient
+import bcrypt
sys.path.append(str(Path(__file__).resolve().parents[1]))
-from helpers import create_vault, TEST_PASSWORD, TEST_SEED
+from seedpass import api
+from seedpass.core.entry_types import EntryType
+from helpers import (
+ create_vault,
+ TEST_PASSWORD,
+ TEST_SEED,
+ DummyBuilder,
+ DummyFilter,
+ DummyTag,
+ DummyTimestamp,
+ DummyEventId,
+ DummyRelayClient,
+)
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.manager import EncryptionMode, PasswordManager
+from seedpass.core.encryption import EncryptionManager as EncMgr
@pytest.fixture(
@@ -94,3 +110,87 @@ def password_manager(vault, tmp_path):
pm.is_dirty = False
pm.secret_mode_enabled = False
return pm
+
+
+@pytest.fixture
+async def client(monkeypatch):
+ dummy = SimpleNamespace(
+ entry_manager=SimpleNamespace(
+ search_entries=lambda q: [
+ (1, "Site", "user", "url", False, EntryType.PASSWORD)
+ ],
+ retrieve_entry=lambda i: {"label": "Site"},
+ add_entry=lambda *a, **k: 1,
+ modify_entry=lambda *a, **k: None,
+ archive_entry=lambda i: None,
+ restore_entry=lambda i: None,
+ ),
+ config_manager=SimpleNamespace(
+ load_config=lambda require_pin=False: {"k": "v"},
+ set_pin=lambda v: None,
+ set_password_hash=lambda v: None,
+ set_relays=lambda v, require_pin=False: None,
+ set_inactivity_timeout=lambda v: None,
+ set_additional_backup_path=lambda v: None,
+ set_secret_mode_enabled=lambda v: None,
+ set_clipboard_clear_delay=lambda v: None,
+ set_quick_unlock=lambda v: None,
+ ),
+ fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
+ nostr_client=SimpleNamespace(
+ key_manager=SimpleNamespace(get_npub=lambda: "np")
+ ),
+ verify_password=lambda pw: True,
+ )
+ monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
+ monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")
+ token = api.start_server()
+ transport = ASGITransport(app=api.app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac, token
+
+
+@pytest.fixture
+def make_dummy_nostr_client(monkeypatch):
+ """Factory to return a NostrClient wired to a DummyRelayClient."""
+ from cryptography.fernet import Fernet
+ from nostr.client import NostrClient
+
+ def _make(base_path):
+ relay = DummyRelayClient()
+ monkeypatch.setattr("nostr.client.Client", lambda signer: relay)
+ monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder)
+ monkeypatch.setattr("nostr.client.Filter", DummyFilter)
+ monkeypatch.setattr("nostr.client.Tag", DummyTag)
+ monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp)
+ monkeypatch.setattr("nostr.client.EventId", DummyEventId)
+ from nostr.backup_models import KIND_DELTA as KD
+
+ monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False)
+ monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None)
+
+ enc_mgr = EncMgr(Fernet.generate_key(), base_path)
+
+ class DummyKeys:
+ def private_key_hex(self):
+ return "1" * 64
+
+ def public_key_hex(self):
+ return "2" * 64
+
+ class DummyKeyManager:
+ def __init__(self, *a, **k):
+ self.keys = DummyKeys()
+
+ with pytest.MonkeyPatch().context() as mp:
+ mp.setattr("nostr.client.KeyManager", DummyKeyManager)
+ mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED)
+ client = NostrClient(enc_mgr, "fp")
+ return client, relay
+
+ return _make
+
+
+@pytest.fixture
+def dummy_nostr_client(make_dummy_nostr_client, tmp_path):
+ return make_dummy_nostr_client(tmp_path)
diff --git a/src/tests/helpers.py b/src/tests/helpers.py
index 8373cd37..ad87a6f6 100644
--- a/src/tests/helpers.py
+++ b/src/tests/helpers.py
@@ -1,10 +1,7 @@
-import sys
-import time
import json
+import uuid
from pathlib import Path
-sys.path.append(str(Path(__file__).resolve().parents[1]))
-
from seedpass.core.vault import Vault
from seedpass.core.encryption import EncryptionManager
from utils.key_derivation import (
@@ -12,8 +9,16 @@ from utils.key_derivation import (
derive_key_from_password,
)
from utils.fingerprint import generate_fingerprint
+from nostr.backup_models import (
+ KIND_MANIFEST,
+ KIND_SNAPSHOT_CHUNK,
+ KIND_DELTA,
+)
-TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
+TEST_SEED = (
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon "
+ "abandon abandon about"
+)
TEST_PASSWORD = "pw"
@@ -34,17 +39,6 @@ def create_vault(
return vault, enc_mgr
-import uuid
-import asyncio
-import pytest
-
-from nostr.backup_models import (
- KIND_MANIFEST,
- KIND_SNAPSHOT_CHUNK,
- KIND_DELTA,
-)
-
-
class DummyEvent:
def __init__(self, kind: int, content: str, tags=None, event_id: str | None = None):
self.kind = kind
@@ -237,41 +231,3 @@ class DummyRelayClient:
return self._evs
return Result(events)
-
-
-@pytest.fixture
-def dummy_nostr_client(tmp_path, monkeypatch):
- """Return a NostrClient wired to a DummyRelayClient."""
- from cryptography.fernet import Fernet
- from nostr.client import NostrClient
-
- relay = DummyRelayClient()
- monkeypatch.setattr("nostr.client.Client", lambda signer: relay)
- monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder)
- monkeypatch.setattr("nostr.client.Filter", DummyFilter)
- monkeypatch.setattr("nostr.client.Tag", DummyTag)
- monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp)
- monkeypatch.setattr("nostr.client.EventId", DummyEventId)
- from nostr.backup_models import KIND_DELTA as KD
-
- monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False)
- monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None)
-
- enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path)
-
- class DummyKeys:
- def private_key_hex(self):
- return "1" * 64
-
- def public_key_hex(self):
- return "2" * 64
-
- class DummyKeyManager:
- def __init__(self, *a, **k):
- self.keys = DummyKeys()
-
- with pytest.MonkeyPatch().context() as mp:
- mp.setattr("nostr.client.KeyManager", DummyKeyManager)
- mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED)
- client = NostrClient(enc_mgr, "fp")
- return client, relay
diff --git a/src/tests/test_api.py b/src/tests/test_api.py
index ec0fabc5..64a85cbf 100644
--- a/src/tests/test_api.py
+++ b/src/tests/test_api.py
@@ -1,53 +1,12 @@
-from types import SimpleNamespace
from pathlib import Path
import sys
import pytest
-from httpx import ASGITransport, AsyncClient
import bcrypt
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
-from seedpass.core.entry_types import EntryType
-
-
-@pytest.fixture
-async def client(monkeypatch):
- dummy = SimpleNamespace(
- entry_manager=SimpleNamespace(
- search_entries=lambda q: [
- (1, "Site", "user", "url", False, EntryType.PASSWORD)
- ],
- retrieve_entry=lambda i: {"label": "Site"},
- add_entry=lambda *a, **k: 1,
- modify_entry=lambda *a, **k: None,
- archive_entry=lambda i: None,
- restore_entry=lambda i: None,
- ),
- config_manager=SimpleNamespace(
- load_config=lambda require_pin=False: {"k": "v"},
- set_pin=lambda v: None,
- set_password_hash=lambda v: None,
- set_relays=lambda v, require_pin=False: None,
- set_inactivity_timeout=lambda v: None,
- set_additional_backup_path=lambda v: None,
- set_secret_mode_enabled=lambda v: None,
- set_clipboard_clear_delay=lambda v: None,
- set_quick_unlock=lambda v: None,
- ),
- fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
- nostr_client=SimpleNamespace(
- key_manager=SimpleNamespace(get_npub=lambda: "np")
- ),
- verify_password=lambda pw: True,
- )
- monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
- monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")
- token = api.start_server()
- transport = ASGITransport(app=api.app)
- async with AsyncClient(transport=transport, base_url="http://test") as ac:
- yield ac, token
@pytest.mark.anyio
diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py
index c4503411..674737c0 100644
--- a/src/tests/test_api_new_endpoints.py
+++ b/src/tests/test_api_new_endpoints.py
@@ -5,8 +5,6 @@ import base64
import pytest
from seedpass import api
-from test_api import client
-from helpers import dummy_nostr_client
import string
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
from seedpass.core.encryption import EncryptionManager
diff --git a/src/tests/test_api_notifications.py b/src/tests/test_api_notifications.py
index 57a02a54..1d9069f9 100644
--- a/src/tests/test_api_notifications.py
+++ b/src/tests/test_api_notifications.py
@@ -1,4 +1,3 @@
-from test_api import client
from types import SimpleNamespace
import queue
import pytest
diff --git a/src/tests/test_api_profile_stats.py b/src/tests/test_api_profile_stats.py
index 2e172bd9..2b646c9d 100644
--- a/src/tests/test_api_profile_stats.py
+++ b/src/tests/test_api_profile_stats.py
@@ -1,4 +1,3 @@
-from test_api import client
import pytest
diff --git a/src/tests/test_cli_clipboard_flag.py b/src/tests/test_cli_clipboard_flag.py
index 29911ff0..a51a5498 100644
--- a/src/tests/test_cli_clipboard_flag.py
+++ b/src/tests/test_cli_clipboard_flag.py
@@ -4,7 +4,6 @@ from seedpass.cli import app, entry as cli_entry
from seedpass.core.entry_types import EntryType
from utils.clipboard import ClipboardUnavailableError
-
runner = CliRunner()
diff --git a/src/tests/test_core_api_services.py b/src/tests/test_core_api_services.py
new file mode 100644
index 00000000..cfeb1b30
--- /dev/null
+++ b/src/tests/test_core_api_services.py
@@ -0,0 +1,529 @@
+import pytest
+from unittest.mock import MagicMock, patch, call
+from pathlib import Path
+from threading import Lock
+import json
+from dataclasses import dataclass
+
+from seedpass.core.api import (
+ VaultService,
+ ProfileService,
+ SyncService,
+ EntryService,
+ ConfigService,
+ UtilityService,
+ NostrService,
+ VaultExportRequest,
+ VaultExportResponse,
+ VaultImportRequest,
+ ChangePasswordRequest,
+ UnlockRequest,
+ UnlockResponse,
+ BackupParentSeedRequest,
+ ProfileSwitchRequest,
+ ProfileRemoveRequest,
+ PasswordPolicyOptions,
+ GeneratePasswordRequest,
+ GeneratePasswordResponse,
+ AddPasswordEntryRequest,
+)
+from seedpass.core.manager import PasswordManager
+from seedpass.core.entry_types import EntryType
+
+
+@dataclass
+class MockPolicy:
+ include_special_chars: bool = False
+ min_uppercase: int = 0
+
+
+@pytest.fixture
+def mock_manager():
+ """Create a mock PasswordManager with all sub-components mocked."""
+ manager = MagicMock(spec=PasswordManager)
+
+ # Mock sub-managers
+ manager.entry_manager = MagicMock()
+ manager.config_manager = MagicMock()
+ manager.fingerprint_manager = MagicMock()
+ manager.encryption_manager = MagicMock()
+ manager.vault = MagicMock()
+ manager.password_generator = MagicMock()
+ manager.nostr_client = MagicMock()
+ manager.state_manager = MagicMock()
+
+ # Mock specific attributes
+ manager.KEY_TOTP_DET = b"mock_totp_key"
+ manager.parent_seed = "mock_parent_seed"
+ manager.current_fingerprint = "fp1"
+
+ return manager
+
+
+@pytest.fixture
+def mock_bus():
+ """Mock the pubsub bus."""
+ with patch("seedpass.core.api.bus") as mock:
+ yield mock
+
+
+class TestVaultService:
+ @pytest.fixture
+ def service(self, mock_manager):
+ return VaultService(mock_manager)
+
+ def test_export_vault(self, service, mock_manager):
+ req = VaultExportRequest(path=Path("/tmp/export.json"))
+ resp = service.export_vault(req)
+
+ mock_manager.handle_export_database.assert_called_once_with(req.path)
+ assert resp.path == req.path
+
+ def test_import_vault(self, service, mock_manager):
+ req = VaultImportRequest(path=Path("/tmp/import.json"))
+ service.import_vault(req)
+
+ mock_manager.handle_import_database.assert_called_once_with(req.path)
+ mock_manager.sync_vault.assert_called_once()
+
+ def test_export_profile(self, service, mock_manager):
+ mock_index = {"entries": {}}
+ mock_manager.vault.load_index.return_value = mock_index
+ mock_manager.vault.encryption_manager.encrypt_data.return_value = b"encrypted"
+
+ result = service.export_profile()
+
+ mock_manager.vault.load_index.assert_called_once()
+ expected_payload = json.dumps(
+ mock_index, sort_keys=True, separators=(",", ":")
+ ).encode("utf-8")
+ mock_manager.vault.encryption_manager.encrypt_data.assert_called_once_with(
+ expected_payload
+ )
+ assert result == b"encrypted"
+
+ def test_import_profile(self, service, mock_manager):
+ data = b"encrypted_data"
+ decrypted_json = json.dumps({"entries": {}})
+ mock_manager.vault.encryption_manager.decrypt_data.return_value = (
+ decrypted_json.encode("utf-8")
+ )
+
+ service.import_profile(data)
+
+ mock_manager.vault.encryption_manager.decrypt_data.assert_called_once_with(
+ data, context="profile"
+ )
+ mock_manager.vault.save_index.assert_called_once_with(
+ json.loads(decrypted_json)
+ )
+ mock_manager.sync_vault.assert_called_once()
+
+ def test_change_password(self, service, mock_manager):
+ req = ChangePasswordRequest(old_password="old", new_password="new")
+ service.change_password(req)
+
+ mock_manager.change_password.assert_called_once_with("old", "new")
+
+ def test_unlock(self, service, mock_manager):
+ mock_manager.unlock_vault.return_value = 0.5
+ req = UnlockRequest(password="secret")
+ resp = service.unlock(req)
+
+ mock_manager.unlock_vault.assert_called_once_with("secret")
+ assert resp.duration == 0.5
+
+ def test_lock(self, service, mock_manager):
+ service.lock()
+ mock_manager.lock_vault.assert_called_once()
+
+ def test_backup_parent_seed(self, service, mock_manager):
+ req = BackupParentSeedRequest(path=Path("/tmp/seed.enc"), password="pw")
+ service.backup_parent_seed(req)
+
+ mock_manager.handle_backup_reveal_parent_seed.assert_called_once_with(
+ req.path, password=req.password
+ )
+
+ def test_stats(self, service, mock_manager):
+ expected_stats = {"entries": 10}
+ mock_manager.get_profile_stats.return_value = expected_stats
+
+ stats = service.stats()
+
+ mock_manager.get_profile_stats.assert_called_once()
+ assert stats == expected_stats
+
+
+class TestProfileService:
+ @pytest.fixture
+ def service(self, mock_manager):
+ return ProfileService(mock_manager)
+
+ def test_list_profiles(self, service, mock_manager):
+ mock_manager.fingerprint_manager.list_fingerprints.return_value = ["fp1", "fp2"]
+ profiles = service.list_profiles()
+
+ mock_manager.fingerprint_manager.list_fingerprints.assert_called_once()
+ assert profiles == ["fp1", "fp2"]
+
+ def test_add_profile(self, service, mock_manager):
+ mock_manager.fingerprint_manager.current_fingerprint = "new_fp"
+ result = service.add_profile()
+
+ mock_manager.add_new_fingerprint.assert_called_once()
+ assert result == "new_fp"
+
+ def test_remove_profile(self, service, mock_manager):
+ req = ProfileRemoveRequest(fingerprint="fp1")
+ service.remove_profile(req)
+
+ mock_manager.fingerprint_manager.remove_fingerprint.assert_called_once_with(
+ "fp1"
+ )
+
+ def test_switch_profile(self, service, mock_manager):
+ req = ProfileSwitchRequest(fingerprint="fp2", password="pw")
+ service.switch_profile(req)
+
+ mock_manager.select_fingerprint.assert_called_once_with("fp2", password="pw")
+
+
+class TestSyncService:
+ @pytest.fixture
+ def service(self, mock_manager):
+ return SyncService(mock_manager)
+
+ def test_sync(self, service, mock_manager, mock_bus):
+ mock_result = {"manifest_id": "m1"}
+ mock_manager.sync_vault.return_value = mock_result
+
+ result = service.sync()
+
+ mock_bus.publish.assert_has_calls(
+ [call("sync_started"), call("sync_finished", mock_result)]
+ )
+ mock_manager.sync_vault.assert_called_once()
+ assert result.manifest_id == "m1"
+
+ def test_sync_none(self, service, mock_manager, mock_bus):
+ mock_manager.sync_vault.return_value = None
+
+ result = service.sync()
+
+ mock_bus.publish.assert_has_calls(
+ [call("sync_started"), call("sync_finished", None)]
+ )
+ assert result is None
+
+ def test_start_background_sync(self, service, mock_manager):
+ service.start_background_sync()
+ mock_manager.start_background_sync.assert_called_once()
+
+ def test_start_background_vault_sync(self, service, mock_manager):
+ service.start_background_vault_sync("summary")
+ mock_manager.start_background_vault_sync.assert_called_once_with("summary")
+
+
+class TestEntryService:
+ @pytest.fixture
+ def service(self, mock_manager):
+ return EntryService(mock_manager)
+
+ def test_list_entries(self, service, mock_manager):
+ mock_manager.entry_manager.list_entries.return_value = []
+ entries = service.list_entries()
+
+ mock_manager.entry_manager.list_entries.assert_called_once_with(
+ sort_by="index", filter_kinds=None, include_archived=False
+ )
+ assert entries == []
+
+ def test_search_entries(self, service, mock_manager):
+ mock_manager.entry_manager.search_entries.return_value = []
+ results = service.search_entries("query")
+
+ mock_manager.entry_manager.search_entries.assert_called_once_with(
+ "query", kinds=None
+ )
+ assert results == []
+
+ def test_retrieve_entry(self, service, mock_manager):
+ mock_entry = {"id": 1, "label": "test"}
+ mock_manager.entry_manager.retrieve_entry.return_value = mock_entry
+ entry = service.retrieve_entry(1)
+
+ mock_manager.entry_manager.retrieve_entry.assert_called_once_with(1)
+ assert entry == mock_entry
+
+ def test_generate_password_default(self, service, mock_manager):
+ # Case where _generate_password_for_entry is not available
+ del mock_manager._generate_password_for_entry
+ mock_manager.password_generator.generate_password.return_value = "pass123"
+ mock_manager.entry_manager.retrieve_entry.return_value = {}
+
+ password = service.generate_password(10, 1)
+
+ mock_manager.password_generator.generate_password.assert_called_once_with(10, 1)
+ assert password == "pass123"
+
+ def test_generate_password_custom(self, service, mock_manager):
+ # Case where _generate_password_for_entry is available
+ mock_manager._generate_password_for_entry = MagicMock(return_value="custom123")
+ mock_manager.entry_manager.retrieve_entry.return_value = {"policy": {}}
+
+ password = service.generate_password(10, 1)
+
+ mock_manager._generate_password_for_entry.assert_called_once()
+ assert password == "custom123"
+
+ def test_get_totp_code(self, service, mock_manager):
+ mock_manager.KEY_TOTP_DET = b"key"
+ mock_manager.entry_manager.get_totp_code.return_value = "123456"
+
+ code = service.get_totp_code(1)
+
+ mock_manager.entry_manager.get_totp_code.assert_called_once_with(1, b"key")
+ assert code == "123456"
+
+ def test_add_entry(self, service, mock_manager):
+ mock_manager.entry_manager.add_entry.return_value = 1
+
+ idx = service.add_entry("label", 12, "user", "url")
+
+ mock_manager.entry_manager.add_entry.assert_called_once()
+ mock_manager.start_background_vault_sync.assert_called_once()
+ assert idx == 1
+
+ def test_add_totp(self, service, mock_manager):
+ mock_manager.entry_manager.add_totp.return_value = "otpauth://..."
+
+ uri = service.add_totp("label")
+
+ mock_manager.entry_manager.add_totp.assert_called_once()
+ mock_manager.start_background_vault_sync.assert_called_once()
+ assert uri == "otpauth://..."
+
+ def test_add_ssh_key(self, service, mock_manager):
+ mock_manager.entry_manager.add_ssh_key.return_value = 2
+
+ idx = service.add_ssh_key("label")
+
+ mock_manager.entry_manager.add_ssh_key.assert_called_once()
+ mock_manager.start_background_vault_sync.assert_called_once()
+ assert idx == 2
+
+ def test_add_pgp_key(self, service, mock_manager):
+ mock_manager.entry_manager.add_pgp_key.return_value = 3
+
+ idx = service.add_pgp_key("label")
+
+ mock_manager.entry_manager.add_pgp_key.assert_called_once()
+ mock_manager.start_background_vault_sync.assert_called_once()
+ assert idx == 3
+
+ def test_add_nostr_key(self, service, mock_manager):
+ mock_manager.entry_manager.add_nostr_key.return_value = 4
+
+ idx = service.add_nostr_key("label")
+
+ mock_manager.entry_manager.add_nostr_key.assert_called_once()
+ mock_manager.start_background_vault_sync.assert_called_once()
+ assert idx == 4
+
+ def test_add_seed(self, service, mock_manager):
+ mock_manager.entry_manager.add_seed.return_value = 5
+
+ idx = service.add_seed("label")
+
+ mock_manager.entry_manager.add_seed.assert_called_once()
+ mock_manager.start_background_vault_sync.assert_called_once()
+ assert idx == 5
+
+ def test_add_key_value(self, service, mock_manager):
+ mock_manager.entry_manager.add_key_value.return_value = 6
+
+ idx = service.add_key_value("label", "k", "v")
+
+ mock_manager.entry_manager.add_key_value.assert_called_once()
+ mock_manager.start_background_vault_sync.assert_called_once()
+ assert idx == 6
+
+ def test_add_managed_account(self, service, mock_manager):
+ mock_manager.entry_manager.add_managed_account.return_value = 7
+
+ idx = service.add_managed_account("label")
+
+ mock_manager.entry_manager.add_managed_account.assert_called_once()
+ mock_manager.start_background_vault_sync.assert_called_once()
+ assert idx == 7
+
+ def test_modify_entry(self, service, mock_manager):
+ service.modify_entry(1, label="new_label")
+
+ mock_manager.entry_manager.modify_entry.assert_called_once_with(
+ 1,
+ username=None,
+ url=None,
+ notes=None,
+ label="new_label",
+ period=None,
+ digits=None,
+ key=None,
+ value=None,
+ )
+ mock_manager.start_background_vault_sync.assert_called_once()
+
+ def test_archive_entry(self, service, mock_manager):
+ service.archive_entry(1)
+
+ mock_manager.entry_manager.archive_entry.assert_called_once_with(1)
+ mock_manager.start_background_vault_sync.assert_called_once()
+
+ def test_restore_entry(self, service, mock_manager):
+ service.restore_entry(1)
+
+ mock_manager.entry_manager.restore_entry.assert_called_once_with(1)
+ mock_manager.start_background_vault_sync.assert_called_once()
+
+ def test_export_totp_entries(self, service, mock_manager):
+ mock_manager.entry_manager.export_totp_entries.return_value = {}
+
+ result = service.export_totp_entries()
+
+ mock_manager.entry_manager.export_totp_entries.assert_called_once()
+ assert result == {}
+
+ def test_display_totp_codes(self, service, mock_manager):
+ service.display_totp_codes()
+ mock_manager.handle_display_totp_codes.assert_called_once()
+
+
+class TestConfigService:
+ @pytest.fixture
+ def service(self, mock_manager):
+ return ConfigService(mock_manager)
+
+ def test_get(self, service, mock_manager):
+ mock_manager.config_manager.load_config.return_value = {"key": "value"}
+ val = service.get("key")
+
+ mock_manager.config_manager.load_config.assert_called_once_with(
+ require_pin=False
+ )
+ assert val == "value"
+
+ def test_set_simple(self, service, mock_manager):
+ service.set("inactivity_timeout", "10")
+ mock_manager.config_manager.set_inactivity_timeout.assert_called_once_with(10.0)
+
+ def test_set_bool(self, service, mock_manager):
+ service.set("secret_mode_enabled", "true")
+ mock_manager.config_manager.set_secret_mode_enabled.assert_called_once_with(
+ True
+ )
+
+ def test_set_relays(self, service, mock_manager):
+ # relays setter logic: ("set_relays", lambda v: (v, {"require_pin": False})),
+ # but the lambda returns a tuple (arg, kwargs)
+ # and then getattr(cfg, method_name)(arg, **kwargs) is called.
+ # Wait, the lambda returns (v, {"require_pin": False})
+ # So arg is v, kwargs is {"require_pin": False}
+
+ service.set("relays", ["r1"])
+ mock_manager.config_manager.set_relays.assert_called_once_with(
+ ["r1"], require_pin=False
+ )
+
+ def test_set_invalid_key(self, service, mock_manager):
+ with pytest.raises(KeyError):
+ service.set("invalid_key", "val")
+
+ def test_get_secret_mode_enabled(self, service, mock_manager):
+ mock_manager.config_manager.get_secret_mode_enabled.return_value = True
+ assert service.get_secret_mode_enabled() is True
+
+ def test_get_clipboard_clear_delay(self, service, mock_manager):
+ mock_manager.config_manager.get_clipboard_clear_delay.return_value = 45
+ assert service.get_clipboard_clear_delay() == 45
+
+ def test_set_secret_mode(self, service, mock_manager):
+ service.set_secret_mode(True, 30)
+
+ mock_manager.config_manager.set_secret_mode_enabled.assert_called_once_with(
+ True
+ )
+ mock_manager.config_manager.set_clipboard_clear_delay.assert_called_once_with(
+ 30
+ )
+ assert mock_manager.secret_mode_enabled is True
+ assert mock_manager.clipboard_clear_delay == 30
+
+ def test_get_offline_mode(self, service, mock_manager):
+ mock_manager.config_manager.get_offline_mode.return_value = False
+ assert service.get_offline_mode() is False
+
+ def test_set_offline_mode(self, service, mock_manager):
+ service.set_offline_mode(True)
+
+ mock_manager.config_manager.set_offline_mode.assert_called_once_with(True)
+ assert mock_manager.offline_mode is True
+
+
+class TestUtilityService:
+ @pytest.fixture
+ def service(self, mock_manager):
+ return UtilityService(mock_manager)
+
+ def test_generate_password(self, service, mock_manager):
+ mock_manager.password_generator.generate_password.return_value = "pw"
+
+ # Mock policy with a real dataclass
+ base_policy = MockPolicy(include_special_chars=False, min_uppercase=0)
+ mock_manager.password_generator.policy = base_policy
+
+ result = service.generate_password(
+ 10, include_special_chars=True, min_uppercase=5
+ )
+
+ mock_manager.password_generator.generate_password.assert_called_once_with(10)
+ assert result == "pw"
+
+ # Verify policy was restored
+ assert mock_manager.password_generator.policy == base_policy
+
+ def test_verify_checksum(self, service, mock_manager):
+ service.verify_checksum()
+ mock_manager.handle_verify_checksum.assert_called_once()
+
+ def test_update_checksum(self, service, mock_manager):
+ service.update_checksum()
+ mock_manager.handle_update_script_checksum.assert_called_once()
+
+
+class TestNostrService:
+ @pytest.fixture
+ def service(self, mock_manager):
+ return NostrService(mock_manager)
+
+ def test_get_pubkey(self, service, mock_manager):
+ mock_manager.nostr_client.key_manager.get_npub.return_value = "npub1..."
+ assert service.get_pubkey() == "npub1..."
+
+ def test_list_relays(self, service, mock_manager):
+ mock_manager.state_manager.list_relays.return_value = ["r1"]
+ assert service.list_relays() == ["r1"]
+
+ def test_add_relay(self, service, mock_manager):
+ mock_manager.state_manager.list_relays.return_value = ["r1", "r2"]
+ service.add_relay("r2")
+
+ mock_manager.state_manager.add_relay.assert_called_once_with("r2")
+ assert mock_manager.nostr_client.relays == ["r1", "r2"]
+
+ def test_remove_relay(self, service, mock_manager):
+ mock_manager.state_manager.list_relays.return_value = []
+ service.remove_relay(0)
+
+ mock_manager.state_manager.remove_relay.assert_called_once_with(0)
+ assert mock_manager.nostr_client.relays == []
diff --git a/src/tests/test_event_handler.py b/src/tests/test_event_handler.py
deleted file mode 100644
index 85ba7868..00000000
--- a/src/tests/test_event_handler.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import logging
-import time
-
-import sys
-from pathlib import Path
-
-sys.path.append(str(Path(__file__).resolve().parents[1]))
-
-from nostr import event_handler
-from nostr_sdk import EventBuilder, Keys
-
-
-def test_handle_new_event_logs(caplog):
- handler = event_handler.EventHandler()
- keys = Keys.generate()
- evt = EventBuilder.text_note("hello").sign_with_keys(keys)
-
- caplog.set_level(logging.INFO, logger=event_handler.logger.name)
- handler.handle_new_event(evt)
-
- event_id = evt.id().to_hex()
- created_at = evt.created_at().as_secs()
- created_at_str = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(created_at))
-
- assert (
- f"[New Event] ID: {event_id} | Created At: {created_at_str} | Content: hello"
- in caplog.text
- )
-
-
-def test_handle_new_event_error(monkeypatch, caplog):
- handler = event_handler.EventHandler()
- keys = Keys.generate()
- evt = EventBuilder.text_note("boom").sign_with_keys(keys)
-
- def raise_info(*args, **kwargs):
- raise RuntimeError("fail")
-
- monkeypatch.setattr(event_handler.logger, "info", raise_info)
- caplog.set_level(logging.ERROR, logger=event_handler.logger.name)
-
- handler.handle_new_event(evt)
-
- assert "Error handling new event: fail" in caplog.text
diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py
index 69a24d3d..c9709f7a 100644
--- a/src/tests/test_full_sync_roundtrip.py
+++ b/src/tests/test_full_sync_roundtrip.py
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
-from helpers import create_vault, dummy_nostr_client
+from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py
index 69a24d3d..c9709f7a 100644
--- a/src/tests/test_full_sync_roundtrip_new.py
+++ b/src/tests/test_full_sync_roundtrip_new.py
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
-from helpers import create_vault, dummy_nostr_client
+from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
diff --git a/src/tests/test_fuzz_key_derivation.py b/src/tests/test_fuzz_key_derivation.py
index ac85e417..0849408d 100644
--- a/src/tests/test_fuzz_key_derivation.py
+++ b/src/tests/test_fuzz_key_derivation.py
@@ -16,7 +16,6 @@ from utils.key_derivation import (
from utils.fingerprint import generate_fingerprint
from seedpass.core.encryption import EncryptionManager
-
cfg_values = st.one_of(
st.integers(min_value=0, max_value=100),
st.text(min_size=0, max_size=20),
diff --git a/src/tests/test_generate_test_profile_sync.py b/src/tests/test_generate_test_profile_sync.py
index 216bb3d6..b8c5435e 100644
--- a/src/tests/test_generate_test_profile_sync.py
+++ b/src/tests/test_generate_test_profile_sync.py
@@ -5,8 +5,6 @@ from tempfile import TemporaryDirectory
import asyncio
import gzip
-from helpers import dummy_nostr_client
-
def load_script():
script_path = (
diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py
index 92bc12c6..422827b9 100644
--- a/src/tests/test_manager_add_password.py
+++ b/src/tests/test_manager_add_password.py
@@ -5,7 +5,7 @@ from types import SimpleNamespace
import pytest
-from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client
+from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
diff --git a/src/tests/test_manifest_id_privacy.py b/src/tests/test_manifest_id_privacy.py
index aa06c1c1..35a0ba56 100644
--- a/src/tests/test_manifest_id_privacy.py
+++ b/src/tests/test_manifest_id_privacy.py
@@ -1,7 +1,5 @@
import asyncio
-from helpers import dummy_nostr_client
-
def test_published_events_no_fingerprint(dummy_nostr_client):
client, relay = dummy_nostr_client
diff --git a/src/tests/test_manifest_state_restore.py b/src/tests/test_manifest_state_restore.py
index c28fad78..5abb75d2 100644
--- a/src/tests/test_manifest_state_restore.py
+++ b/src/tests/test_manifest_state_restore.py
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
-from helpers import create_vault, dummy_nostr_client, TEST_SEED
+from helpers import create_vault, TEST_SEED
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
@@ -34,8 +34,8 @@ def _init_pm(dir_path: Path, client) -> PasswordManager:
return pm
-def test_manifest_state_restored(monkeypatch, tmp_path):
- client, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch)
+def test_manifest_state_restored(monkeypatch, tmp_path, make_dummy_nostr_client):
+ client, relay = make_dummy_nostr_client(tmp_path / "c1")
with TemporaryDirectory() as tmpdir:
fp_dir = Path(tmpdir)
pm1 = _init_pm(fp_dir, client)
@@ -48,7 +48,7 @@ def test_manifest_state_restored(monkeypatch, tmp_path):
assert delta_ts > 0
assert result["manifest_id"] == manifest_id
- client2, _ = dummy_nostr_client.__wrapped__(tmp_path / "c2", monkeypatch)
+ client2, _ = make_dummy_nostr_client(tmp_path / "c2")
monkeypatch.setattr(
"seedpass.core.manager.NostrClient", lambda *a, **k: client2
)
diff --git a/src/tests/test_multiple_deltas_sync.py b/src/tests/test_multiple_deltas_sync.py
index 3b2b2f71..296f6421 100644
--- a/src/tests/test_multiple_deltas_sync.py
+++ b/src/tests/test_multiple_deltas_sync.py
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
-from helpers import create_vault, dummy_nostr_client
+from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py
index 763e5b0d..5087794f 100644
--- a/src/tests/test_nostr_dummy_client.py
+++ b/src/tests/test_nostr_dummy_client.py
@@ -3,7 +3,7 @@ import gzip
import math
import pytest
-from helpers import create_vault, dummy_nostr_client, TEST_SEED
+from helpers import create_vault, TEST_SEED
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
diff --git a/src/tests/test_nostr_legacy_key_fallback.py b/src/tests/test_nostr_legacy_key_fallback.py
index a551b120..07f42558 100644
--- a/src/tests/test_nostr_legacy_key_fallback.py
+++ b/src/tests/test_nostr_legacy_key_fallback.py
@@ -3,7 +3,7 @@ import base64
import hashlib
import json
-from helpers import DummyEvent, DummyFilter, dummy_nostr_client
+from helpers import DummyEvent, DummyFilter
from nostr.backup_models import KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
from nostr_sdk import Keys
diff --git a/src/tests/test_nostr_restore_flow.py b/src/tests/test_nostr_restore_flow.py
index 759f6f86..1c5c1f86 100644
--- a/src/tests/test_nostr_restore_flow.py
+++ b/src/tests/test_nostr_restore_flow.py
@@ -1,7 +1,7 @@
from pathlib import Path
import main
-from helpers import create_vault, dummy_nostr_client, TEST_SEED, TEST_PASSWORD
+from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
@@ -28,8 +28,8 @@ def _init_pm(dir_path: Path, client) -> PasswordManager:
return pm
-def test_restore_flow_from_snapshot(monkeypatch, tmp_path):
- client, relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
+def test_restore_flow_from_snapshot(monkeypatch, tmp_path, make_dummy_nostr_client):
+ client, relay = make_dummy_nostr_client(tmp_path / "srv")
dir_a = tmp_path / "A"
dir_b = tmp_path / "B"
diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py
index e76f7ef6..699adb57 100644
--- a/src/tests/test_portable_backup.py
+++ b/src/tests/test_portable_backup.py
@@ -19,7 +19,6 @@ from seedpass.core.portable_backup import PortableMode
from utils.key_derivation import derive_index_key, derive_key_from_password
from utils.fingerprint import generate_fingerprint
-
SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
PASSWORD = "passw0rd"
diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py
index 02db4b3d..a7cfb4f2 100644
--- a/src/tests/test_profiles.py
+++ b/src/tests/test_profiles.py
@@ -6,11 +6,10 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.fingerprint_manager import FingerprintManager
from seedpass.core.manager import PasswordManager, EncryptionMode
-from helpers import create_vault, dummy_nostr_client
+from helpers import create_vault
import gzip
from nostr.backup_models import Manifest, ChunkMeta
-
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
diff --git a/src/tests/test_restore_from_nostr_setup.py b/src/tests/test_restore_from_nostr_setup.py
index 9f304779..8df98c91 100644
--- a/src/tests/test_restore_from_nostr_setup.py
+++ b/src/tests/test_restore_from_nostr_setup.py
@@ -1,6 +1,6 @@
from pathlib import Path
-from helpers import create_vault, dummy_nostr_client, TEST_SEED, TEST_PASSWORD
+from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
@@ -28,8 +28,10 @@ def _init_pm(dir_path: Path, client) -> PasswordManager:
return pm
-def test_handle_new_seed_setup_restore_from_nostr(monkeypatch, tmp_path, capsys):
- client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
+def test_handle_new_seed_setup_restore_from_nostr(
+ monkeypatch, tmp_path, capsys, make_dummy_nostr_client
+):
+ client, _relay = make_dummy_nostr_client(tmp_path / "srv")
dir_a = tmp_path / "A"
dir_b = tmp_path / "B"
@@ -133,8 +135,10 @@ async def _no_snapshot():
return None
-def test_restore_from_nostr_warns(monkeypatch, tmp_path, capsys):
- client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
+def test_restore_from_nostr_warns(
+ monkeypatch, tmp_path, capsys, make_dummy_nostr_client
+):
+ client, _relay = make_dummy_nostr_client(tmp_path / "srv")
monkeypatch.setattr(client, "fetch_latest_snapshot", _no_snapshot)
pm = PasswordManager.__new__(PasswordManager)
@@ -150,8 +154,10 @@ def test_restore_from_nostr_warns(monkeypatch, tmp_path, capsys):
assert "No Nostr backup" in out
-def test_restore_from_nostr_abort(monkeypatch, tmp_path, capsys):
- client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch)
+def test_restore_from_nostr_abort(
+ monkeypatch, tmp_path, capsys, make_dummy_nostr_client
+):
+ client, _relay = make_dummy_nostr_client(tmp_path / "srv")
monkeypatch.setattr(client, "fetch_latest_snapshot", _no_snapshot)
pm = PasswordManager.__new__(PasswordManager)
diff --git a/src/tests/test_service_classes.py b/src/tests/test_service_classes.py
index 530e60ff..e29ade7d 100644
--- a/src/tests/test_service_classes.py
+++ b/src/tests/test_service_classes.py
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
-from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client
+from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
diff --git a/src/tests/test_sync_race_conditions.py b/src/tests/test_sync_race_conditions.py
index 07866594..b7dbc45a 100644
--- a/src/tests/test_sync_race_conditions.py
+++ b/src/tests/test_sync_race_conditions.py
@@ -3,7 +3,7 @@ import threading
from pathlib import Path
-from helpers import create_vault, dummy_nostr_client
+from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
@@ -31,8 +31,8 @@ def _init_pm(dir_path: Path, client) -> PasswordManager:
return pm
-def test_sync_race_conditions(monkeypatch, tmp_path):
- client_a, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch)
+def test_sync_race_conditions(monkeypatch, tmp_path, make_dummy_nostr_client):
+ client_a, relay = make_dummy_nostr_client(tmp_path / "c1")
from cryptography.fernet import Fernet
from nostr.client import NostrClient
diff --git a/src/tests/test_totp_uri.py b/src/tests/test_totp_uri.py
index 1970794b..fb8c2b27 100644
--- a/src/tests/test_totp_uri.py
+++ b/src/tests/test_totp_uri.py
@@ -7,7 +7,6 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.totp import TotpManager
-
# Test parsing a normal otpauth URI with custom period and digits
diff --git a/src/tests/test_verbose_timing.py b/src/tests/test_verbose_timing.py
index a623b256..ff2517ec 100644
--- a/src/tests/test_verbose_timing.py
+++ b/src/tests/test_verbose_timing.py
@@ -2,7 +2,6 @@ import asyncio
import logging
from seedpass.core.manager import PasswordManager
-from helpers import dummy_nostr_client
def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path):
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
index 6b91dace..da73c00e 100644
--- a/src/utils/__init__.py
+++ b/src/utils/__init__.py
@@ -40,14 +40,15 @@ from .atomic_write import atomic_write
try: # pragma: no cover - exercised when dependency missing
from .clipboard import ClipboardUnavailableError, copy_to_clipboard
except Exception as exc: # pragma: no cover - executed only if pyperclip missing
+ _error_msg = str(exc)
class ClipboardUnavailableError(RuntimeError):
"""Stub exception when clipboard support is unavailable."""
def copy_to_clipboard(*_args, **_kwargs):
"""Stub when clipboard support is unavailable."""
- logger.warning("Clipboard support unavailable: %s", exc)
- raise ClipboardUnavailableError(str(exc))
+ logger.warning("Clipboard support unavailable: %s", _error_msg)
+ raise ClipboardUnavailableError(_error_msg)
__all__ = [
diff --git a/src/utils/color_scheme.py b/src/utils/color_scheme.py
index d0c13762..64d1faa9 100644
--- a/src/utils/color_scheme.py
+++ b/src/utils/color_scheme.py
@@ -2,7 +2,6 @@
from termcolor import colored
-
# ANSI escape for 256-color orange (color code 208)
_ORANGE = "\033[38;5;208m"
_RESET = "\033[0m"
diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py
index 712a8e95..b473889a 100644
--- a/src/utils/key_derivation.py
+++ b/src/utils/key_derivation.py
@@ -21,7 +21,7 @@ import hmac
import time
from dataclasses import dataclass, field
from enum import Enum
-from typing import Optional, Union, Dict, Any
+from typing import Optional, Union, Dict, Any, TYPE_CHECKING
from bip_utils import Bip39SeedGenerator
from local_bip85 import BIP85
@@ -46,6 +46,9 @@ DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY
# Purpose constant for TOTP secret derivation using BIP85
TOTP_PURPOSE = 39
+if TYPE_CHECKING:
+ from seedpass.core.config_manager import ConfigManager
+
@dataclass
class KdfConfig:
diff --git a/src/utils/seed_prompt.py b/src/utils/seed_prompt.py
index 45759e17..889ca8ea 100644
--- a/src/utils/seed_prompt.py
+++ b/src/utils/seed_prompt.py
@@ -17,7 +17,6 @@ except ImportError: # pragma: no cover - POSIX only
from utils.terminal_utils import clear_screen
from utils.logging_utils import pause_console_logging, resume_console_logging
-
DEFAULT_MAX_ATTEMPTS = 5
diff --git a/torch/eslint.config.mjs b/torch/eslint.config.mjs
new file mode 100644
index 00000000..e65162b3
--- /dev/null
+++ b/torch/eslint.config.mjs
@@ -0,0 +1,21 @@
+import js from '@eslint/js';
+import globals from 'globals';
+
+export default [
+ js.configs.recommended,
+ {
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ globals: {
+ ...globals.node,
+ ...globals.es2021,
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
+ 'no-undef': 'error',
+ },
+ ignores: ['node_modules/', 'task-logs/', 'package.json', 'package-lock.json', 'torch-config.json'],
+ },
+];
diff --git a/torch/src/cli-parser.mjs b/torch/src/cli-parser.mjs
new file mode 100644
index 00000000..21e20b53
--- /dev/null
+++ b/torch/src/cli-parser.mjs
@@ -0,0 +1,137 @@
+import { DEFAULT_DASHBOARD_PORT } from './constants.mjs';
+
+/**
+ * Parses command-line arguments into a structured object.
+ *
+ * @param {string[]} argv - Arguments from process.argv.slice(2)
+ * @returns {Object} - Parsed arguments (command, flags, values)
+ */
+export function parseArgs(argv) {
+ const args = {
+ command: null,
+ agent: null,
+ cadence: null,
+ json: false,
+ jsonFile: null,
+ quiet: false,
+ dryRun: false,
+ force: false,
+ port: DEFAULT_DASHBOARD_PORT,
+ host: null,
+ logDir: 'task-logs',
+ ignoreLogs: false,
+ id: null,
+ type: null,
+ tags: [],
+ pinned: null,
+ limit: null,
+ offset: null,
+ retentionMs: null,
+ windowMs: null,
+ timeoutMs: null,
+ allRelaysDownMinutes: null,
+ minSuccessRate: null,
+ windowMinutes: null,
+ platform: null,
+ model: null,
+ };
+ let i = 0;
+
+ if (argv.length > 0 && !argv[0].startsWith('-')) {
+ args.command = argv[0];
+ i = 1;
+ }
+
+ for (; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '--agent' || arg === '-a') {
+ args.agent = argv[++i];
+ } else if (arg === '--cadence' || arg === '-c') {
+ args.cadence = argv[++i];
+ } else if (arg === '--json') {
+ args.json = true;
+ } else if (arg === '--json-file') {
+ args.jsonFile = argv[++i];
+ } else if (arg.startsWith('--json-file=')) {
+ args.jsonFile = arg.split('=')[1];
+ } else if (arg === '--quiet') {
+ args.quiet = true;
+ } else if (arg.startsWith('--agent=')) {
+ args.agent = arg.split('=')[1];
+ } else if (arg.startsWith('--cadence=')) {
+ args.cadence = arg.split('=')[1];
+ } else if (arg === '--dry-run') {
+ args.dryRun = true;
+ } else if (arg === '--force') {
+ args.force = true;
+ } else if (arg === '--port') {
+ args.port = parseInt(argv[++i], 10) || DEFAULT_DASHBOARD_PORT;
+ } else if (arg === '--host') {
+ args.host = argv[++i];
+ } else if (arg === '--log-dir') {
+ args.logDir = argv[++i];
+ } else if (arg === '--ignore-logs') {
+ args.ignoreLogs = true;
+ } else if (arg === '--id') {
+ args.id = argv[++i];
+ } else if (arg.startsWith('--id=')) {
+ args.id = arg.split('=')[1];
+ } else if (arg === '--type') {
+ args.type = argv[++i];
+ } else if (arg.startsWith('--type=')) {
+ args.type = arg.split('=')[1];
+ } else if (arg === '--tags') {
+ args.tags = String(argv[++i]).split(',').map((tag) => tag.trim()).filter(Boolean);
+ } else if (arg.startsWith('--tags=')) {
+ args.tags = String(arg.split('=')[1]).split(',').map((tag) => tag.trim()).filter(Boolean);
+ } else if (arg === '--pinned') {
+ const value = String(argv[++i]).toLowerCase();
+ args.pinned = value === 'true' ? true : value === 'false' ? false : null;
+ } else if (arg.startsWith('--pinned=')) {
+ const value = String(arg.split('=')[1]).toLowerCase();
+ args.pinned = value === 'true' ? true : value === 'false' ? false : null;
+ } else if (arg === '--limit') {
+ args.limit = parseInt(argv[++i], 10);
+ } else if (arg.startsWith('--limit=')) {
+ args.limit = parseInt(arg.split('=')[1], 10);
+ } else if (arg === '--offset') {
+ args.offset = parseInt(argv[++i], 10);
+ } else if (arg.startsWith('--offset=')) {
+ args.offset = parseInt(arg.split('=')[1], 10);
+ } else if (arg === '--retention-ms') {
+ args.retentionMs = parseInt(argv[++i], 10);
+ } else if (arg.startsWith('--retention-ms=')) {
+ args.retentionMs = parseInt(arg.split('=')[1], 10);
+ } else if (arg === '--window-ms') {
+ args.windowMs = parseInt(argv[++i], 10);
+ } else if (arg.startsWith('--window-ms=')) {
+ args.windowMs = parseInt(arg.split('=')[1], 10);
+ } else if (arg === '--timeout-ms') {
+ args.timeoutMs = parseInt(argv[++i], 10);
+ } else if (arg.startsWith('--timeout-ms=')) {
+ args.timeoutMs = parseInt(arg.split('=')[1], 10);
+ } else if (arg === '--all-relays-down-minutes') {
+ args.allRelaysDownMinutes = parseInt(argv[++i], 10);
+ } else if (arg.startsWith('--all-relays-down-minutes=')) {
+ args.allRelaysDownMinutes = parseInt(arg.split('=')[1], 10);
+ } else if (arg === '--min-success-rate') {
+ args.minSuccessRate = parseFloat(argv[++i]);
+ } else if (arg.startsWith('--min-success-rate=')) {
+ args.minSuccessRate = parseFloat(arg.split('=')[1]);
+ } else if (arg === '--window-minutes') {
+ args.windowMinutes = parseInt(argv[++i], 10);
+ } else if (arg.startsWith('--window-minutes=')) {
+ args.windowMinutes = parseInt(arg.split('=')[1], 10);
+ } else if (arg === '--platform') {
+ args.platform = argv[++i];
+ } else if (arg.startsWith('--platform=')) {
+ args.platform = arg.split('=')[1];
+ } else if (arg === '--model') {
+ args.model = argv[++i];
+ } else if (arg.startsWith('--model=')) {
+ args.model = arg.split('=')[1];
+ }
+ }
+
+ return args;
+}
diff --git a/torch/src/cmd-check.mjs b/torch/src/cmd-check.mjs
new file mode 100644
index 00000000..a0d16f4f
--- /dev/null
+++ b/torch/src/cmd-check.mjs
@@ -0,0 +1,61 @@
+import {
+ loadTorchConfig,
+ getRelays,
+ getNamespace,
+} from './torch-config.mjs';
+import { getRoster } from './roster.mjs';
+import { queryLocks } from './lock-ops.mjs';
+import { todayDateStr } from './utils.mjs';
+
+export async function cmdCheck(cadence, deps = {}) {
+ const {
+ loadTorchConfigFn = loadTorchConfig,
+ getRelaysFn = getRelays,
+ getNamespaceFn = getNamespace,
+ getRosterFn = getRoster,
+ queryLocksFn = queryLocks,
+ todayDateStrFn = todayDateStr,
+ } = deps;
+
+ const relays = await getRelaysFn();
+ const namespace = await getNamespaceFn();
+ const dateStr = todayDateStrFn();
+ const config = await loadTorchConfigFn();
+ const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || [];
+
+ console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`);
+ console.error(`Relays: ${relays.join(', ')}`);
+ if (pausedAgents.length > 0) {
+ console.error(`Paused agents: ${pausedAgents.join(', ')}`);
+ }
+
+ const locks = await queryLocksFn(relays, cadence, dateStr, namespace);
+ const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))];
+ const roster = await getRosterFn(cadence);
+
+ const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])];
+ const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent));
+ const available = roster.filter((a) => !excludedAgents.includes(a));
+
+ const result = {
+ namespace,
+ cadence,
+ date: dateStr,
+ locked: lockedAgents.sort(),
+ paused: pausedAgents.sort(),
+ excluded: excludedAgents.sort(),
+ available: available.sort(),
+ lockCount: locks.length,
+ unknownLockedAgents: unknownLockedAgents.sort(),
+ locks: locks.map((l) => ({
+ agent: l.agent,
+ eventId: l.eventId,
+ createdAt: l.createdAtIso,
+ expiresAt: l.expiresAtIso,
+ platform: l.platform,
+ })),
+ };
+
+ console.log(JSON.stringify(result, null, 2));
+ return result;
+}
diff --git a/torch/src/cmd-list.mjs b/torch/src/cmd-list.mjs
new file mode 100644
index 00000000..65a9166e
--- /dev/null
+++ b/torch/src/cmd-list.mjs
@@ -0,0 +1,62 @@
+import {
+ getRelays,
+ getNamespace,
+} from './torch-config.mjs';
+import { getRoster } from './roster.mjs';
+import { queryLocks } from './lock-ops.mjs';
+import { todayDateStr, nowUnix } from './utils.mjs';
+
+export async function cmdList(cadence) {
+ const relays = await getRelays();
+ const namespace = await getNamespace();
+ const dateStr = todayDateStr();
+ const cadences = cadence ? [cadence] : ['daily', 'weekly'];
+
+ console.error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`);
+
+ const results = await Promise.all(
+ cadences.map(async (c) => {
+ const locks = await queryLocks(relays, c, dateStr, namespace);
+ return { c, locks };
+ }),
+ );
+
+ for (const { c, locks } of results) {
+ console.log(`\n${'='.repeat(72)}`);
+ console.log(`Active ${namespace} ${c} locks (${dateStr})`);
+ console.log('='.repeat(72));
+
+ if (locks.length === 0) {
+ console.log(' (no active locks)');
+ continue;
+ }
+
+ const sorted = locks.sort((a, b) => a.createdAt - b.createdAt);
+ for (const lock of sorted) {
+ const age = nowUnix() - lock.createdAt;
+ const ageMin = Math.round(age / 60);
+ const remaining = lock.expiresAt ? lock.expiresAt - nowUnix() : null;
+ const remainMin = remaining ? Math.round(remaining / 60) : '?';
+
+ console.log(
+ ` ${(lock.agent ?? 'unknown').padEnd(30)} ` +
+ `age: ${String(ageMin).padStart(4)}m ` +
+ `ttl: ${String(remainMin).padStart(4)}m ` +
+ `platform: ${lock.platform ?? '?'} ` +
+ `event: ${lock.eventId?.slice(0, 12)}...`,
+ );
+ }
+
+ const roster = await getRoster(c);
+ const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean));
+ const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent));
+ const available = roster.filter((a) => !lockedAgents.has(a));
+
+ if (unknownLockedAgents.length > 0) {
+ console.log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`);
+ }
+
+ console.log(`\n Locked: ${lockedAgents.size}/${roster.length}`);
+ console.log(` Available: ${available.join(', ') || '(none)'}`);
+ }
+}
diff --git a/torch/src/cmd-lock.mjs b/torch/src/cmd-lock.mjs
new file mode 100644
index 00000000..195baa56
--- /dev/null
+++ b/torch/src/cmd-lock.mjs
@@ -0,0 +1,122 @@
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import {
+ getRelays,
+ getNamespace,
+ getTtl,
+} from './torch-config.mjs';
+import {
+ RACE_CHECK_DELAY_MS,
+ KIND_APP_DATA,
+} from './constants.mjs';
+import { getRoster } from './roster.mjs';
+import { queryLocks, publishLock } from './lock-ops.mjs';
+import { todayDateStr, nowUnix } from './utils.mjs';
+import { ExitError } from './errors.mjs';
+
+export async function cmdLock(agent, cadence, dryRun = false) {
+ const relays = await getRelays();
+ const namespace = await getNamespace();
+ const dateStr = todayDateStr();
+ const ttl = await getTtl();
+ const now = nowUnix();
+ const expiresAt = now + ttl;
+
+ console.error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
+ console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`);
+ console.error(`Relays: ${relays.join(', ')}`);
+
+ const roster = await getRoster(cadence);
+ if (!roster.includes(agent)) {
+ console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
+ console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
+ throw new ExitError(1, 'Agent not in roster');
+ }
+
+ console.error('Step 1: Checking for existing locks...');
+ const existingLocks = await queryLocks(relays, cadence, dateStr, namespace);
+ const conflicting = existingLocks.filter((l) => l.agent === agent);
+
+ if (conflicting.length > 0) {
+ const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0];
+ console.error(
+ `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` +
+ `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`,
+ );
+ console.log('LOCK_STATUS=denied');
+ console.log('LOCK_REASON=already_locked');
+ console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`);
+ throw new ExitError(3, 'Lock denied');
+ }
+
+ console.error('Step 2: Generating ephemeral keypair...');
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`);
+
+ console.error('Step 3: Building lock event...');
+ const event = finalizeEvent(
+ {
+ kind: KIND_APP_DATA,
+ created_at: now,
+ tags: [
+ ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`],
+ ['t', `${namespace}-agent-lock`],
+ ['t', `${namespace}-lock-${cadence}`],
+ ['t', `${namespace}-lock-${cadence}-${dateStr}`],
+ ['expiration', String(expiresAt)],
+ ],
+ content: JSON.stringify({
+ agent,
+ cadence,
+ status: 'started',
+ namespace,
+ date: dateStr,
+ platform: process.env.AGENT_PLATFORM || 'unknown',
+ lockedAt: new Date(now * 1000).toISOString(),
+ expiresAt: new Date(expiresAt * 1000).toISOString(),
+ }),
+ },
+ sk,
+ );
+
+ console.error(` Event ID: ${event.id}`);
+
+ if (dryRun) {
+ console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent');
+ console.error('RACE CHECK: won (dry run — no real contention possible)');
+ } else {
+ console.error('Step 4: Publishing to relays...');
+ await publishLock(relays, event);
+
+ console.error('Step 5: Race check...');
+ await new Promise((resolve) => setTimeout(resolve, RACE_CHECK_DELAY_MS));
+
+ const postLocks = await queryLocks(relays, cadence, dateStr, namespace);
+ const racingLocks = postLocks
+ .filter((l) => l.agent === agent)
+ .sort((a, b) => a.createdAt - b.createdAt);
+
+ if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) {
+ const winner = racingLocks[0];
+ console.error(
+ `RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`,
+ );
+ console.log('LOCK_STATUS=race_lost');
+ console.log('LOCK_REASON=earlier_claim_exists');
+ console.log(`LOCK_WINNER_EVENT=${winner.eventId}`);
+ throw new ExitError(3, 'Race check lost');
+ }
+
+ console.error('RACE CHECK: won');
+ }
+
+ console.log('LOCK_STATUS=ok');
+ console.log(`LOCK_EVENT_ID=${event.id}`);
+ console.log(`LOCK_PUBKEY=${pk}`);
+ console.log(`LOCK_AGENT=${agent}`);
+ console.log(`LOCK_CADENCE=${cadence}`);
+ console.log(`LOCK_DATE=${dateStr}`);
+ console.log(`LOCK_EXPIRES=${expiresAt}`);
+ console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`);
+ return { status: 'ok', eventId: event.id };
+}
diff --git a/torch/src/constants.mjs b/torch/src/constants.mjs
new file mode 100644
index 00000000..8e5a6534
--- /dev/null
+++ b/torch/src/constants.mjs
@@ -0,0 +1,67 @@
+export const DEFAULT_DASHBOARD_PORT = 4173;
+export const RACE_CHECK_DELAY_MS = 1500;
+export const DEFAULT_RELAYS = [
+ 'wss://relay.damus.io',
+ 'wss://nos.lol',
+ 'wss://relay.primal.net',
+];
+export const DEFAULT_TTL = 7200;
+export const DEFAULT_NAMESPACE = 'torch';
+export const DEFAULT_QUERY_TIMEOUT_MS = 30_000;
+export const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000;
+export const DEFAULT_MIN_SUCCESSFUL_PUBLISHES = 1;
+export const DEFAULT_MIN_ACTIVE_RELAY_POOL = 1;
+export const DEFAULT_RETRY_ATTEMPTS = 4;
+export const DEFAULT_RETRY_BASE_DELAY_MS = 500;
+export const DEFAULT_RETRY_CAP_DELAY_MS = 8000;
+export const DEFAULT_ROLLING_WINDOW_SIZE = 25;
+export const DEFAULT_FAILURE_THRESHOLD = 3;
+export const DEFAULT_QUARANTINE_COOLDOWN_MS = 30_000;
+export const DEFAULT_MAX_QUARANTINE_COOLDOWN_MS = 300_000;
+export const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
+export const VALID_CADENCES = new Set(['daily', 'weekly']);
+export const KIND_APP_DATA = 30078;
+
+export const USAGE_TEXT = `Usage: torch-lock [options]
+
+Commands:
+ check --cadence Check locked agents (JSON)
+ lock --agent --cadence Claim a lock
+ complete --agent --cadence Mark task as completed (permanent)
+ list [--cadence ] Print active lock table
+ health --cadence Probe relay websocket + publish/read health
+ dashboard [--port ] [--host ] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT})
+ init [--force] Initialize torch/ directory in current project
+ update [--force] Update torch/ configuration (backups, merges)
+
+ list-memories [--agent ] [--type ] [--tags ] [--pinned ] [--full]
+ inspect-memory --id
+ pin-memory --id
+ unpin-memory --id
+ trigger-prune-dry-run [--retention-ms ]
+ memory-stats [--window-ms ]
+
+Options:
+ --dry-run Build and sign the event but do not publish
+ --force Overwrite existing files (for init) or all files (for update)
+ --log-dir Path to task logs directory (default: task-logs)
+ --ignore-logs Skip checking local logs for completed tasks
+ --json Emit compact single-line JSON
+ --json-file Write JSON output to a file path
+ --quiet Suppress stderr progress logs (pairs well with --json)
+
+Environment:
+ NOSTR_LOCK_NAMESPACE Namespace prefix for lock tags (default: torch)
+ NOSTR_LOCK_RELAYS Comma-separated relay WSS URLs
+ NOSTR_LOCK_TTL Lock TTL in seconds (default: 7200)
+ NOSTR_LOCK_QUERY_TIMEOUT_MS Relay query timeout in milliseconds (default: 15000)
+ NOSTR_LOCK_DAILY_ROSTER Comma-separated daily roster (optional)
+ NOSTR_LOCK_WEEKLY_ROSTER Comma-separated weekly roster (optional)
+ TORCH_CONFIG_PATH Optional path to torch-config.json (default: ./torch-config.json)
+ AGENT_PLATFORM Platform identifier (e.g., codex)
+
+Exit codes:
+ 0 Success
+ 1 Usage error
+ 2 Relay/network error
+ 3 Lock denied (already locked or race lost)`;
diff --git a/torch/src/dashboard.mjs b/torch/src/dashboard.mjs
new file mode 100644
index 00000000..740357ac
--- /dev/null
+++ b/torch/src/dashboard.mjs
@@ -0,0 +1,210 @@
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import http from 'node:http';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { DEFAULT_DASHBOARD_PORT } from './constants.mjs';
+import { getDashboardAuth, parseTorchConfig } from './torch-config.mjs';
+
+function timingSafeCompare(a, b) {
+ const bufA = Buffer.from(a);
+ const bufB = Buffer.from(b);
+ if (bufA.length !== bufB.length) {
+ // Still do a comparison to avoid some timing leaks, though length leak is hard to avoid entirely
+ crypto.timingSafeEqual(bufA, bufA);
+ return false;
+ }
+ return crypto.timingSafeEqual(bufA, bufB);
+}
+
+export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.0.1') {
+ // Resolve package root relative to this file (src/dashboard.mjs)
+ // this file is in /src/dashboard.mjs, so '..' goes to
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+ const auth = await getDashboardAuth();
+
+ const server = http.createServer(async (req, res) => {
+ try {
+ // Basic Auth check
+ if (auth) {
+ const authHeader = req.headers.authorization;
+ if (!authHeader) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' });
+ res.end('Authentication required');
+ return;
+ }
+ const parts = authHeader.split(' ');
+ const type = parts[0];
+ const credentials = parts[1];
+ let isValid = false;
+ if (type === 'Basic' && credentials) {
+ try {
+ const decoded = Buffer.from(credentials, 'base64').toString();
+ isValid = timingSafeCompare(decoded, auth);
+ } catch {
+ isValid = false;
+ }
+ }
+
+ if (!isValid) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' });
+ res.end('Invalid credentials');
+ return;
+ }
+ }
+
+ // URL parsing
+ const url = new URL(req.url, `http://${req.headers.host}`);
+ let pathname = url.pathname;
+
+ // Redirect / to /dashboard/
+ if (pathname === '/' || pathname === '/dashboard') {
+ res.writeHead(302, { 'Location': '/dashboard/' });
+ res.end();
+ return;
+ }
+
+ async function statSafe(p) {
+ try {
+ return await fsp.stat(p);
+ } catch {
+ return null;
+ }
+ }
+
+ // Special case: /torch-config.json
+ // Priority: Env Var > User's CWD > Package default
+ if (pathname === '/torch-config.json') {
+ let configContent = null;
+
+ if (process.env.TORCH_CONFIG_PATH) {
+ const envPath = path.resolve(process.cwd(), process.env.TORCH_CONFIG_PATH);
+ try {
+ configContent = await fsp.readFile(envPath, 'utf8');
+ } catch {
+ // Explicitly provided config path not found, do not fall back
+ }
+ } else {
+ const userConfigPath = path.resolve(process.cwd(), 'torch-config.json');
+ try {
+ configContent = await fsp.readFile(userConfigPath, 'utf8');
+ } catch {
+ // Try package default
+ const packageConfigPath = path.join(packageRoot, 'torch-config.json');
+ try {
+ configContent = await fsp.readFile(packageConfigPath, 'utf8');
+ } catch {
+ // Not found in either location
+ }
+ }
+ }
+
+ if (configContent) {
+ try {
+ const rawConfig = JSON.parse(configContent);
+ const safeConfig = parseTorchConfig(rawConfig);
+
+ // Security: Sanitize sensitive fields
+ if (safeConfig.dashboard) {
+ delete safeConfig.dashboard.auth;
+ }
+ delete safeConfig.configPath;
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(safeConfig, null, 2));
+ return;
+ } catch (err) {
+ console.error('Error parsing torch-config.json:', err);
+ res.writeHead(500);
+ res.end('Internal Server Error');
+ return;
+ }
+ } else {
+ res.writeHead(404);
+ res.end('Not Found');
+ return;
+ }
+ }
+
+ // Security check: prevent directory traversal
+ const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), '');
+ let filePath = path.join(packageRoot, safePath);
+
+ // Security check: restrict access to allowed paths
+ const allowedPaths = [
+ path.join(packageRoot, 'dashboard'),
+ path.join(packageRoot, 'assets'),
+ path.join(packageRoot, 'src', 'docs'),
+ path.join(packageRoot, 'src', 'constants.mjs'),
+ path.join(packageRoot, 'torch-config.json')
+ ];
+
+ const isAllowed = allowedPaths.some(allowedPath => {
+ const rel = path.relative(allowedPath, filePath);
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
+ });
+
+ if (!isAllowed) {
+ res.writeHead(403);
+ res.end('Forbidden');
+ return;
+ }
+
+ // If directory, try index.html
+ let fileStat = await statSafe(filePath);
+ if (fileStat && fileStat.isDirectory()) {
+ filePath = path.join(filePath, 'index.html');
+ fileStat = await statSafe(filePath);
+ }
+
+ // Check if file exists and is a file
+ if (!fileStat || !fileStat.isFile()) {
+ res.writeHead(404);
+ res.end('Not Found');
+ return;
+ }
+
+ // MIME types
+ const extname = path.extname(filePath);
+ let contentType = 'text/plain';
+ switch (extname) {
+ case '.html': contentType = 'text/html'; break;
+ case '.js': contentType = 'text/javascript'; break;
+ case '.mjs': contentType = 'text/javascript'; break;
+ case '.css': contentType = 'text/css'; break;
+ case '.json': contentType = 'application/json'; break;
+ case '.png': contentType = 'image/png'; break;
+ case '.jpg': contentType = 'image/jpeg'; break;
+ case '.svg': contentType = 'image/svg+xml'; break;
+ case '.ico': contentType = 'image/x-icon'; break;
+ case '.md': contentType = 'text/markdown'; break;
+ }
+
+ res.writeHead(200, { 'Content-Type': contentType });
+ fs.createReadStream(filePath).pipe(res);
+ } catch (err) {
+ console.error('Dashboard Server Error:', err);
+ if (!res.headersSent) {
+ res.writeHead(500);
+ res.end('Internal Server Error');
+ }
+ }
+ });
+
+ return new Promise((resolve, reject) => {
+ server.listen(port, host, () => {
+ const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`;
+ console.log(`Dashboard running at ${listenUrl}`);
+ if (auth) {
+ console.log('Authentication: enabled (Basic Auth)');
+ } else {
+ console.warn('Authentication: DISABLED (Dashboard is public)');
+ }
+ console.log(`Serving files from ${packageRoot}`);
+ console.log(`Using configuration from ${process.cwd()}`);
+ resolve(server);
+ });
+ server.on('error', reject);
+ });
+}
diff --git a/torch/src/errors.mjs b/torch/src/errors.mjs
new file mode 100644
index 00000000..c997a9f8
--- /dev/null
+++ b/torch/src/errors.mjs
@@ -0,0 +1,7 @@
+// Custom Error class for controlled exits
+export class ExitError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.code = code;
+ }
+}
diff --git a/torch/src/lib.mjs b/torch/src/lib.mjs
new file mode 100644
index 00000000..ec97d947
--- /dev/null
+++ b/torch/src/lib.mjs
@@ -0,0 +1,684 @@
+#!/usr/bin/env node
+
+// TORCH — Task Orchestration via Relay-Coordinated Handoff
+// Generic Nostr-based task locking for multi-agent development.
+
+import { generateSecretKey as _generateSecretKey, getPublicKey as _getPublicKey, finalizeEvent as _finalizeEvent } from 'nostr-tools/pure';
+import { useWebSocketImplementation } from 'nostr-tools/relay';
+import WebSocket from 'ws';
+import {
+ loadTorchConfig as _loadTorchConfig,
+ getRelays as _getRelays,
+ getNamespace as _getNamespace,
+ getTtl as _getTtl,
+ getHashtag as _getHashtag,
+} from './torch-config.mjs';
+import {
+ VALID_CADENCES,
+ KIND_APP_DATA,
+ RACE_CHECK_DELAY_MS,
+ USAGE_TEXT,
+} from './constants.mjs';
+import { cmdInit, cmdUpdate } from './ops.mjs';
+import { parseArgs } from './cli-parser.mjs';
+import { getRoster as _getRoster } from './roster.mjs';
+import { queryLocks as _queryLocks, publishLock as _publishLock, parseLockEvent } from './lock-ops.mjs';
+import { cmdDashboard } from './dashboard.mjs';
+import {
+ inspectMemory,
+ listMemories,
+ memoryStats,
+ pinMemory,
+ triggerPruneDryRun,
+ unpinMemory,
+} from './services/memory/index.js';
+import { ExitError } from './errors.mjs';
+import { todayDateStr, nowUnix, detectPlatform } from './utils.mjs';
+import { runRelayHealthCheck } from './relay-health.mjs';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { getCompletedAgents } from './lock-utils.mjs';
+
+useWebSocketImplementation(WebSocket);
+
+// Re-export for backward compatibility/library usage
+export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock };
+
+/**
+ * Checks the status of the repository locks for a given cadence.
+ *
+ * It aggregates data from three sources:
+ * 1. Configuration (paused agents)
+ * 2. Local logs (completed agents)
+ * 3. Nostr relays (current active locks)
+ *
+ * It outputs a JSON object describing the state (locked, available, excluded agents).
+ *
+ * @param {string} cadence - 'daily' or 'weekly'
+ * @param {Object} [deps] - Dependency injection
+ * @returns {Promise