From 90b60a6682699051560cdb7021a081e8772f40a8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:51:36 -0400 Subject: [PATCH] refactor: modularize CLI commands --- src/seedpass/cli.py | 923 ---------------------- src/seedpass/cli/__init__.py | 153 ++++ src/seedpass/cli/api.py | 32 + src/seedpass/cli/common.py | 59 ++ src/seedpass/cli/config.py | 125 +++ src/seedpass/cli/entry.py | 345 ++++++++ src/seedpass/cli/fingerprint.py | 40 + src/seedpass/cli/nostr.py | 65 ++ src/seedpass/cli/util.py | 74 ++ src/seedpass/cli/vault.py | 99 +++ src/tests/test_cli_config_set_extra.py | 4 +- src/tests/test_cli_core_services.py | 5 +- src/tests/test_cli_doc_examples.py | 8 +- src/tests/test_cli_entry_add_commands.py | 4 +- src/tests/test_cli_integration.py | 28 +- src/tests/test_cli_relays.py | 6 +- src/tests/test_cli_toggle_offline_mode.py | 6 +- src/tests/test_cli_toggle_secret_mode.py | 6 +- src/tests/test_cli_vault_stats.py | 4 +- src/tests/test_typer_cli.py | 78 +- 20 files changed, 1059 insertions(+), 1005 deletions(-) delete mode 100644 src/seedpass/cli.py create mode 100644 src/seedpass/cli/__init__.py create mode 100644 src/seedpass/cli/api.py create mode 100644 src/seedpass/cli/common.py create mode 100644 src/seedpass/cli/config.py create mode 100644 src/seedpass/cli/entry.py create mode 100644 src/seedpass/cli/fingerprint.py create mode 100644 src/seedpass/cli/nostr.py create mode 100644 src/seedpass/cli/util.py create mode 100644 src/seedpass/cli/vault.py diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py deleted file mode 100644 index 260e221..0000000 --- a/src/seedpass/cli.py +++ /dev/null @@ -1,923 +0,0 @@ -from pathlib import Path -from typing import Optional, List -import json - -import typer -import sys - -from seedpass.core.manager import PasswordManager -from seedpass.core.entry_types import EntryType -from seedpass.core.api import ( - VaultService, - ProfileService, - SyncService, - EntryService, - ConfigService, - UtilityService, - NostrService, - ChangePasswordRequest, - UnlockRequest, - BackupParentSeedRequest, - ProfileSwitchRequest, - ProfileRemoveRequest, -) -import uvicorn -from . import api as api_module - -import importlib -import importlib.util -import subprocess - -app = typer.Typer( - help="SeedPass command line interface", - invoke_without_command=True, -) - -# Global option shared across all commands -fingerprint_option = typer.Option( - None, - "--fingerprint", - "-f", - help="Specify which seed profile to use", -) - -# Sub command groups -entry_app = typer.Typer(help="Manage individual entries") -vault_app = typer.Typer(help="Manage the entire vault") -nostr_app = typer.Typer(help="Interact with Nostr relays") -config_app = typer.Typer(help="Get or set configuration values") -fingerprint_app = typer.Typer(help="Manage seed profiles") -util_app = typer.Typer(help="Utility commands") -api_app = typer.Typer(help="Run the API server") - -app.add_typer(entry_app, name="entry") -app.add_typer(vault_app, name="vault") -app.add_typer(nostr_app, name="nostr") -app.add_typer(config_app, name="config") -app.add_typer(fingerprint_app, name="fingerprint") -app.add_typer(util_app, name="util") -app.add_typer(api_app, name="api") - - -def _get_pm(ctx: typer.Context) -> PasswordManager: - """Return a PasswordManager optionally selecting a fingerprint.""" - fp = ctx.obj.get("fingerprint") - if fp is None: - pm = PasswordManager() - else: - pm = PasswordManager(fingerprint=fp) - return pm - - -def _get_services( - ctx: typer.Context, -) -> tuple[VaultService, ProfileService, SyncService]: - """Return service layer instances for the current context.""" - - pm = _get_pm(ctx) - return VaultService(pm), ProfileService(pm), SyncService(pm) - - -def _get_entry_service(ctx: typer.Context) -> EntryService: - pm = _get_pm(ctx) - return EntryService(pm) - - -def _get_config_service(ctx: typer.Context) -> ConfigService: - pm = _get_pm(ctx) - return ConfigService(pm) - - -def _get_util_service(ctx: typer.Context) -> UtilityService: - pm = _get_pm(ctx) - return UtilityService(pm) - - -def _get_nostr_service(ctx: typer.Context) -> NostrService: - pm = _get_pm(ctx) - return NostrService(pm) - - -def _gui_backend_available() -> bool: - """Return True if a platform-specific BeeWare backend is installed.""" - for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"): - if importlib.util.find_spec(pkg) is not None: - return True - return False - - -@app.callback(invoke_without_command=True) -def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: - """SeedPass CLI entry point. - - When called without a subcommand this launches the interactive TUI. - """ - ctx.obj = {"fingerprint": fingerprint} - if ctx.invoked_subcommand is None: - tui = importlib.import_module("main") - raise typer.Exit(tui.main(fingerprint=fingerprint)) - - -@entry_app.command("list") -def entry_list( - ctx: typer.Context, - sort: str = typer.Option( - "index", "--sort", help="Sort by 'index', 'label', or 'updated'" - ), - kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"), - archived: bool = typer.Option(False, "--archived", help="Include archived"), -) -> None: - """List entries in the vault.""" - service = _get_entry_service(ctx) - entries = service.list_entries( - sort_by=sort, filter_kind=kind, include_archived=archived - ) - for idx, label, username, url, is_archived in entries: - line = f"{idx}: {label}" - if username: - line += f" ({username})" - if url: - line += f" {url}" - if is_archived: - line += " [archived]" - typer.echo(line) - - -@entry_app.command("search") -def entry_search( - ctx: typer.Context, - query: str, - kind: List[str] = typer.Option( - None, - "--kind", - "-k", - help="Filter by entry kinds (can be repeated)", - ), -) -> None: - """Search entries.""" - service = _get_entry_service(ctx) - kinds = list(kind) if kind else None - results = service.search_entries(query, kinds=kinds) - if not results: - typer.echo("No matching entries found") - return - for idx, label, username, url, _arch, etype in results: - line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}" - if username: - line += f" ({username})" - if url: - line += f" {url}" - typer.echo(line) - - -@entry_app.command("get") -def entry_get(ctx: typer.Context, query: str) -> None: - """Retrieve a single entry's secret.""" - service = _get_entry_service(ctx) - matches = service.search_entries(query) - if len(matches) == 0: - typer.echo("No matching entries found") - raise typer.Exit(code=1) - if len(matches) > 1: - typer.echo("Matches:") - for idx, label, username, _url, _arch, etype in matches: - name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}" - if username: - name += f" ({username})" - typer.echo(name) - raise typer.Exit(code=1) - - index = matches[0][0] - entry = service.retrieve_entry(index) - etype = entry.get("type", entry.get("kind")) - if etype == EntryType.PASSWORD.value: - length = int(entry.get("length", 12)) - password = service.generate_password(length, index) - typer.echo(password) - elif etype == EntryType.TOTP.value: - code = service.get_totp_code(index) - typer.echo(code) - else: - typer.echo("Unsupported entry type") - raise typer.Exit(code=1) - - -@entry_app.command("add") -def entry_add( - ctx: typer.Context, - label: str, - length: int = typer.Option(12, "--length"), - 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 - ), - allowed_special_chars: Optional[str] = typer.Option( - None, "--allowed-special-chars", help="Explicit set of special characters" - ), - special_mode: Optional[str] = typer.Option( - None, - "--special-mode", - help="Special character mode", - ), - exclude_ambiguous: bool = typer.Option( - 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"), - min_digits: Optional[int] = typer.Option(None, "--min-digits"), - min_special: Optional[int] = typer.Option(None, "--min-special"), -) -> None: - """Add a new password entry and output its index.""" - service = _get_entry_service(ctx) - kwargs = {} - if no_special: - kwargs["include_special_chars"] = False - if allowed_special_chars is not None: - kwargs["allowed_special_chars"] = allowed_special_chars - if special_mode is not None: - kwargs["special_mode"] = special_mode - if exclude_ambiguous: - kwargs["exclude_ambiguous"] = True - if min_uppercase is not None: - kwargs["min_uppercase"] = min_uppercase - if min_lowercase is not None: - kwargs["min_lowercase"] = min_lowercase - if min_digits is not None: - kwargs["min_digits"] = min_digits - if min_special is not None: - kwargs["min_special"] = min_special - - index = service.add_entry(label, length, username, url, **kwargs) - typer.echo(str(index)) - - -@entry_app.command("add-totp") -def entry_add_totp( - ctx: typer.Context, - label: str, - index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), - secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"), - period: int = typer.Option(30, "--period", help="TOTP period in seconds"), - digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), -) -> None: - """Add a TOTP entry and output the otpauth URI.""" - service = _get_entry_service(ctx) - uri = service.add_totp( - label, - index=index, - secret=secret, - period=period, - digits=digits, - ) - typer.echo(uri) - - -@entry_app.command("add-ssh") -def entry_add_ssh( - ctx: typer.Context, - label: str, - index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), - notes: str = typer.Option("", "--notes", help="Entry notes"), -) -> None: - """Add an SSH key entry and output its index.""" - service = _get_entry_service(ctx) - idx = service.add_ssh_key( - label, - index=index, - notes=notes, - ) - typer.echo(str(idx)) - - -@entry_app.command("add-pgp") -def entry_add_pgp( - ctx: typer.Context, - label: str, - index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), - key_type: str = typer.Option("ed25519", "--key-type", help="Key type"), - user_id: str = typer.Option("", "--user-id", help="User ID"), - notes: str = typer.Option("", "--notes", help="Entry notes"), -) -> None: - """Add a PGP key entry and output its index.""" - service = _get_entry_service(ctx) - idx = service.add_pgp_key( - label, - index=index, - key_type=key_type, - user_id=user_id, - notes=notes, - ) - typer.echo(str(idx)) - - -@entry_app.command("add-nostr") -def entry_add_nostr( - ctx: typer.Context, - label: str, - index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), - notes: str = typer.Option("", "--notes", help="Entry notes"), -) -> None: - """Add a Nostr key entry and output its index.""" - service = _get_entry_service(ctx) - idx = service.add_nostr_key( - label, - index=index, - notes=notes, - ) - typer.echo(str(idx)) - - -@entry_app.command("add-seed") -def entry_add_seed( - ctx: typer.Context, - label: str, - index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), - words: int = typer.Option(24, "--words", help="Word count"), - notes: str = typer.Option("", "--notes", help="Entry notes"), -) -> None: - """Add a derived seed phrase entry and output its index.""" - service = _get_entry_service(ctx) - idx = service.add_seed( - label, - index=index, - words=words, - notes=notes, - ) - typer.echo(str(idx)) - - -@entry_app.command("add-key-value") -def entry_add_key_value( - ctx: typer.Context, - label: str, - key: str = typer.Option(..., "--key", help="Key name"), - value: str = typer.Option(..., "--value", help="Stored value"), - notes: str = typer.Option("", "--notes", help="Entry notes"), -) -> None: - """Add a key/value entry and output its index.""" - service = _get_entry_service(ctx) - idx = service.add_key_value(label, key, value, notes=notes) - typer.echo(str(idx)) - - -@entry_app.command("add-managed-account") -def entry_add_managed_account( - ctx: typer.Context, - label: str, - index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), - notes: str = typer.Option("", "--notes", help="Entry notes"), -) -> None: - """Add a managed account seed entry and output its index.""" - service = _get_entry_service(ctx) - idx = service.add_managed_account( - label, - index=index, - notes=notes, - ) - typer.echo(str(idx)) - - -@entry_app.command("modify") -def entry_modify( - ctx: typer.Context, - entry_id: int, - label: Optional[str] = typer.Option(None, "--label"), - username: Optional[str] = typer.Option(None, "--username"), - url: Optional[str] = typer.Option(None, "--url"), - notes: Optional[str] = typer.Option(None, "--notes"), - period: Optional[int] = typer.Option( - None, "--period", help="TOTP period in seconds" - ), - digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"), - key: Optional[str] = typer.Option(None, "--key", help="New key"), - value: Optional[str] = typer.Option(None, "--value", help="New value"), -) -> None: - """Modify an existing entry.""" - service = _get_entry_service(ctx) - try: - service.modify_entry( - entry_id, - username=username, - url=url, - notes=notes, - label=label, - period=period, - digits=digits, - key=key, - value=value, - ) - except ValueError as e: - typer.echo(str(e)) - sys.stdout.flush() - raise typer.Exit(code=1) - - -@entry_app.command("archive") -def entry_archive(ctx: typer.Context, entry_id: int) -> None: - """Archive an entry.""" - service = _get_entry_service(ctx) - service.archive_entry(entry_id) - typer.echo(str(entry_id)) - - -@entry_app.command("unarchive") -def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: - """Restore an archived entry.""" - service = _get_entry_service(ctx) - service.restore_entry(entry_id) - typer.echo(str(entry_id)) - - -@entry_app.command("totp-codes") -def entry_totp_codes(ctx: typer.Context) -> None: - """Display all current TOTP codes.""" - service = _get_entry_service(ctx) - service.display_totp_codes() - - -@entry_app.command("export-totp") -def entry_export_totp( - ctx: typer.Context, file: str = typer.Option(..., help="Output file") -) -> None: - """Export all TOTP secrets to a JSON file.""" - service = _get_entry_service(ctx) - data = service.export_totp_entries() - Path(file).write_text(json.dumps(data, indent=2)) - typer.echo(str(file)) - - -@vault_app.command("export") -def vault_export( - ctx: typer.Context, file: str = typer.Option(..., help="Output file") -) -> None: - """Export the vault profile to an encrypted file.""" - vault_service, _profile, _sync = _get_services(ctx) - data = vault_service.export_profile() - Path(file).write_bytes(data) - typer.echo(str(file)) - - -@vault_app.command("import") -def vault_import( - ctx: typer.Context, file: str = typer.Option(..., help="Input file") -) -> None: - """Import a vault profile from an encrypted file.""" - vault_service, _profile, _sync = _get_services(ctx) - data = Path(file).read_bytes() - vault_service.import_profile(data) - typer.echo(str(file)) - - -@vault_app.command("change-password") -def vault_change_password(ctx: typer.Context) -> None: - """Change the master password used for encryption.""" - vault_service, _profile, _sync = _get_services(ctx) - old_pw = typer.prompt("Current password", hide_input=True) - new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True) - try: - vault_service.change_password( - ChangePasswordRequest(old_password=old_pw, new_password=new_pw) - ) - except Exception as exc: # pragma: no cover - pass through errors - typer.echo(f"Error: {exc}") - raise typer.Exit(code=1) - typer.echo("Password updated") - - -@vault_app.command("unlock") -def vault_unlock(ctx: typer.Context) -> None: - """Unlock the vault for the active profile.""" - vault_service, _profile, _sync = _get_services(ctx) - password = typer.prompt("Master password", hide_input=True) - try: - resp = vault_service.unlock(UnlockRequest(password=password)) - except Exception as exc: # pragma: no cover - pass through errors - typer.echo(f"Error: {exc}") - raise typer.Exit(code=1) - typer.echo(f"Unlocked in {resp.duration:.2f}s") - - -@vault_app.command("lock") -def vault_lock(ctx: typer.Context) -> None: - """Lock the vault and clear sensitive data from memory.""" - vault_service, _profile, _sync = _get_services(ctx) - vault_service.lock() - typer.echo("locked") - - -@app.command("lock") -def root_lock(ctx: typer.Context) -> None: - """Lock the vault for the active profile.""" - vault_service, _profile, _sync = _get_services(ctx) - vault_service.lock() - typer.echo("locked") - - -@vault_app.command("stats") -def vault_stats(ctx: typer.Context) -> None: - """Display statistics about the current seed profile.""" - vault_service, _profile, _sync = _get_services(ctx) - stats = vault_service.stats() - typer.echo(json.dumps(stats, indent=2)) - - -@vault_app.command("reveal-parent-seed") -def vault_reveal_parent_seed( - ctx: typer.Context, - file: Optional[str] = typer.Option( - None, "--file", help="Save encrypted seed to this path" - ), -) -> None: - """Display the parent seed and optionally write an encrypted backup file.""" - vault_service, _profile, _sync = _get_services(ctx) - password = typer.prompt("Master password", hide_input=True) - vault_service.backup_parent_seed( - BackupParentSeedRequest(path=Path(file) if file else None, password=password) - ) - - -@nostr_app.command("sync") -def nostr_sync(ctx: typer.Context) -> None: - """Sync with configured Nostr relays.""" - _vault, _profile, sync_service = _get_services(ctx) - model = sync_service.sync() - if model: - typer.echo("Event IDs:") - typer.echo(f"- manifest: {model.manifest_id}") - for cid in model.chunk_ids: - typer.echo(f"- chunk: {cid}") - for did in model.delta_ids: - typer.echo(f"- delta: {did}") - else: - typer.echo("Error: Failed to sync vault") - - -@nostr_app.command("get-pubkey") -def nostr_get_pubkey(ctx: typer.Context) -> None: - """Display the active profile's npub.""" - service = _get_nostr_service(ctx) - npub = service.get_pubkey() - 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.""" - service = _get_config_service(ctx) - value = service.get(key) - if value is None: - typer.echo("Key not found") - else: - typer.echo(str(value)) - - -@config_app.command("set") -def config_set(ctx: typer.Context, key: str, value: str) -> None: - """Set a configuration value.""" - service = _get_config_service(ctx) - - try: - val = ( - [r.strip() for r in value.split(",") if r.strip()] - if key == "relays" - else value - ) - service.set(key, val) - except KeyError: - typer.echo("Unknown key") - raise typer.Exit(code=1) - except Exception as exc: # pragma: no cover - pass through errors - typer.echo(f"Error: {exc}") - raise typer.Exit(code=1) - - typer.echo("Updated") - - -@config_app.command("toggle-secret-mode") -def config_toggle_secret_mode(ctx: typer.Context) -> None: - """Interactively enable or disable secret mode. - - When enabled, newly generated and retrieved passwords are copied to the - clipboard instead of printed to the screen. - """ - service = _get_config_service(ctx) - try: - enabled = service.get_secret_mode_enabled() - delay = service.get_clipboard_clear_delay() - except Exception as exc: # pragma: no cover - pass through errors - typer.echo(f"Error loading settings: {exc}") - raise typer.Exit(code=1) - - typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}") - choice = ( - typer.prompt( - "Enable secret mode? (y/n, blank to keep)", default="", show_default=False - ) - .strip() - .lower() - ) - if choice in ("y", "yes"): - enabled = True - elif choice in ("n", "no"): - enabled = False - - inp = typer.prompt( - f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False - ).strip() - if inp: - try: - delay = int(inp) - if delay <= 0: - typer.echo("Delay must be positive") - raise typer.Exit(code=1) - except ValueError: - typer.echo("Invalid number") - raise typer.Exit(code=1) - - try: - service.set_secret_mode(enabled, delay) - except Exception as exc: # pragma: no cover - pass through errors - typer.echo(f"Error: {exc}") - raise typer.Exit(code=1) - - status = "enabled" if enabled else "disabled" - typer.echo(f"Secret mode {status}.") - - -@config_app.command("toggle-offline") -def config_toggle_offline(ctx: typer.Context) -> None: - """Enable or disable offline mode.""" - service = _get_config_service(ctx) - try: - enabled = service.get_offline_mode() - except Exception as exc: # pragma: no cover - pass through errors - typer.echo(f"Error loading settings: {exc}") - raise typer.Exit(code=1) - - typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}") - choice = ( - typer.prompt( - "Enable offline mode? (y/n, blank to keep)", default="", show_default=False - ) - .strip() - .lower() - ) - if choice in ("y", "yes"): - enabled = True - elif choice in ("n", "no"): - enabled = False - - try: - service.set_offline_mode(enabled) - except Exception as exc: # pragma: no cover - pass through errors - typer.echo(f"Error: {exc}") - raise typer.Exit(code=1) - - status = "enabled" if enabled else "disabled" - typer.echo(f"Offline mode {status}.") - - -@fingerprint_app.command("list") -def fingerprint_list(ctx: typer.Context) -> None: - """List available seed profiles.""" - _vault, profile_service, _sync = _get_services(ctx) - for fp in profile_service.list_profiles(): - typer.echo(fp) - - -@fingerprint_app.command("add") -def fingerprint_add(ctx: typer.Context) -> None: - """Create a new seed profile.""" - _vault, profile_service, _sync = _get_services(ctx) - profile_service.add_profile() - - -@fingerprint_app.command("remove") -def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None: - """Remove a seed profile.""" - _vault, profile_service, _sync = _get_services(ctx) - profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint)) - - -@fingerprint_app.command("switch") -def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: - """Switch to another seed profile.""" - _vault, profile_service, _sync = _get_services(ctx) - password = typer.prompt("Master password", hide_input=True) - profile_service.switch_profile( - ProfileSwitchRequest(fingerprint=fingerprint, password=password) - ) - - -@util_app.command("generate-password") -def generate_password( - ctx: typer.Context, - length: int = 24, - no_special: bool = typer.Option( - False, "--no-special", help="Exclude special characters", is_flag=True - ), - allowed_special_chars: Optional[str] = typer.Option( - None, "--allowed-special-chars", help="Explicit set of special characters" - ), - special_mode: Optional[str] = typer.Option( - None, - "--special-mode", - help="Special character mode", - ), - exclude_ambiguous: bool = typer.Option( - 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"), - min_digits: Optional[int] = typer.Option(None, "--min-digits"), - min_special: Optional[int] = typer.Option(None, "--min-special"), -) -> None: - """Generate a strong password.""" - service = _get_util_service(ctx) - kwargs = {} - if no_special: - kwargs["include_special_chars"] = False - if allowed_special_chars is not None: - kwargs["allowed_special_chars"] = allowed_special_chars - if special_mode is not None: - kwargs["special_mode"] = special_mode - if exclude_ambiguous: - kwargs["exclude_ambiguous"] = True - if min_uppercase is not None: - kwargs["min_uppercase"] = min_uppercase - if min_lowercase is not None: - kwargs["min_lowercase"] = min_lowercase - if min_digits is not None: - kwargs["min_digits"] = min_digits - if min_special is not None: - kwargs["min_special"] = min_special - - password = service.generate_password(length, **kwargs) - typer.echo(password) - - -@util_app.command("verify-checksum") -def verify_checksum(ctx: typer.Context) -> None: - """Verify the SeedPass script checksum.""" - service = _get_util_service(ctx) - service.verify_checksum() - - -@util_app.command("update-checksum") -def update_checksum(ctx: typer.Context) -> None: - """Regenerate the script checksum file.""" - service = _get_util_service(ctx) - service.update_checksum() - - -@api_app.command("start") -def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None: - """Start the SeedPass API server.""" - token = api_module.start_server(ctx.obj.get("fingerprint")) - typer.echo(f"API token: {token}") - uvicorn.run(api_module.app, host=host, port=port) - - -@api_app.command("stop") -def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None: - """Stop the SeedPass API server.""" - import requests - - try: - requests.post( - f"http://{host}:{port}/api/v1/shutdown", - headers={"Authorization": f"Bearer {api_module._token}"}, - timeout=2, - ) - except Exception as exc: # pragma: no cover - best effort - typer.echo(f"Failed to stop server: {exc}") - - -@app.command() -def gui( - install: bool = typer.Option( - False, - "--install", - help="Attempt to install the BeeWare GUI backend if missing", - ) -) -> None: - """Launch the BeeWare GUI. - - If a platform specific backend is missing, inform the user how to - install it. Using ``--install`` will attempt installation after - confirmation. - """ - if not _gui_backend_available(): - if sys.platform.startswith("linux"): - pkg = "toga-gtk" - version = "0.5.2" - sha256 = "15b346ac1a2584de5effe5e73a3888f055c68c93300aeb111db9d64186b31646" - elif sys.platform == "win32": - pkg = "toga-winforms" - version = "0.5.2" - sha256 = "83181309f204bcc4a34709d23fdfd68467ae8ecc39c906d13c661cb9a0ef581b" - elif sys.platform == "darwin": - pkg = "toga-cocoa" - version = "0.5.2" - sha256 = "a4d5d1546bf92372a6fb1b450164735fb107b2ee69d15bf87421fec3c78465f9" - else: - typer.echo( - f"Unsupported platform '{sys.platform}' for BeeWare GUI.", - err=True, - ) - raise typer.Exit(1) - - if not install: - typer.echo( - f"BeeWare GUI backend not found. Please install {pkg} manually or rerun " - "with '--install'.", - err=True, - ) - raise typer.Exit(1) - - if not typer.confirm( - f"Install {pkg}=={version} with hash verification?", default=False - ): - typer.echo("Installation cancelled.", err=True) - raise typer.Exit(1) - - typer.echo( - "SeedPass uses pinned versions and SHA256 hashes to verify the GUI backend " - "and protect against tampered packages." - ) - - try: - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "--require-hashes", - f"{pkg}=={version}", - f"--hash=sha256:{sha256}", - ] - ) - typer.echo(f"Successfully installed {pkg}=={version}.") - except subprocess.CalledProcessError as exc: - typer.echo( - "Secure installation failed. Please install the package manually " - f"from a trusted source. Details: {exc}", - err=True, - ) - raise typer.Exit(1) - - if not _gui_backend_available(): - typer.echo( - "BeeWare GUI backend still unavailable after installation attempt.", - err=True, - ) - raise typer.Exit(1) - - from seedpass_gui.app import main - - main() - - -if __name__ == "__main__": - app() diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py new file mode 100644 index 0000000..cfcb7bd --- /dev/null +++ b/src/seedpass/cli/__init__.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import importlib +import importlib.util +import subprocess +import sys +from typing import Optional + +import typer + +from .common import _get_services + +app = typer.Typer( + help="SeedPass command line interface", + invoke_without_command=True, +) + +# Global option shared across all commands +fingerprint_option = typer.Option( + None, + "--fingerprint", + "-f", + help="Specify which seed profile to use", +) + +# Sub command groups +from . import entry, vault, nostr, config, fingerprint, util, api + +app.add_typer(entry.app, name="entry") +app.add_typer(vault.app, name="vault") +app.add_typer(nostr.app, name="nostr") +app.add_typer(config.app, name="config") +app.add_typer(fingerprint.app, name="fingerprint") +app.add_typer(util.app, name="util") +app.add_typer(api.app, name="api") + + +def _gui_backend_available() -> bool: + """Return True if a platform-specific BeeWare backend is installed.""" + for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"): + if importlib.util.find_spec(pkg) is not None: + return True + return False + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: + """SeedPass CLI entry point. + + When called without a subcommand this launches the interactive TUI. + """ + ctx.obj = {"fingerprint": fingerprint} + if ctx.invoked_subcommand is None: + tui = importlib.import_module("main") + raise typer.Exit(tui.main(fingerprint=fingerprint)) + + +@app.command("lock") +def root_lock(ctx: typer.Context) -> None: + """Lock the vault for the active profile.""" + vault_service, _profile, _sync = _get_services(ctx) + vault_service.lock() + typer.echo("locked") + + +@app.command() +def gui( + install: bool = typer.Option( + False, + "--install", + help="Attempt to install the BeeWare GUI backend if missing", + ) +) -> None: + """Launch the BeeWare GUI. + + If a platform specific backend is missing, inform the user how to + install it. Using ``--install`` will attempt installation after + confirmation. + """ + if not _gui_backend_available(): + if sys.platform.startswith("linux"): + pkg = "toga-gtk" + version = "0.5.2" + sha256 = "15b346ac1a2584de5effe5e73a3888f055c68c93300aeb111db9d64186b31646" + elif sys.platform == "win32": + pkg = "toga-winforms" + version = "0.5.2" + sha256 = "83181309f204bcc4a34709d23fdfd68467ae8ecc39c906d13c661cb9a0ef581b" + elif sys.platform == "darwin": + pkg = "toga-cocoa" + version = "0.5.2" + sha256 = "a4d5d1546bf92372a6fb1b450164735fb107b2ee69d15bf87421fec3c78465f9" + else: + typer.echo( + f"Unsupported platform '{sys.platform}' for BeeWare GUI.", + err=True, + ) + raise typer.Exit(1) + + if not install: + typer.echo( + f"BeeWare GUI backend not found. Please install {pkg} manually or rerun " + "with '--install'.", + err=True, + ) + raise typer.Exit(1) + + if not typer.confirm( + f"Install {pkg}=={version} with hash verification?", default=False + ): + typer.echo("Installation cancelled.", err=True) + raise typer.Exit(1) + + typer.echo( + "SeedPass uses pinned versions and SHA256 hashes to verify the GUI backend " + "and protect against tampered packages." + ) + + try: + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "--require-hashes", + f"{pkg}=={version}", + f"--hash=sha256:{sha256}", + ] + ) + typer.echo(f"Successfully installed {pkg}=={version}.") + except subprocess.CalledProcessError as exc: + typer.echo( + "Secure installation failed. Please install the package manually " + f"from a trusted source. Details: {exc}", + err=True, + ) + raise typer.Exit(1) + + if not _gui_backend_available(): + typer.echo( + "BeeWare GUI backend still unavailable after installation attempt.", + err=True, + ) + raise typer.Exit(1) + + from seedpass_gui.app import main + + main() + + +if __name__ == "__main__": # pragma: no cover + app() diff --git a/src/seedpass/cli/api.py b/src/seedpass/cli/api.py new file mode 100644 index 0000000..8ebfe29 --- /dev/null +++ b/src/seedpass/cli/api.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import typer +import uvicorn + +from .. import api as api_module + + +app = typer.Typer(help="Run the API server") + + +@app.command("start") +def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None: + """Start the SeedPass API server.""" + token = api_module.start_server(ctx.obj.get("fingerprint")) + typer.echo(f"API token: {token}") + uvicorn.run(api_module.app, host=host, port=port) + + +@app.command("stop") +def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None: + """Stop the SeedPass API server.""" + import requests + + try: + requests.post( + f"http://{host}:{port}/api/v1/shutdown", + headers={"Authorization": f"Bearer {api_module._token}"}, + timeout=2, + ) + except Exception as exc: # pragma: no cover - best effort + typer.echo(f"Failed to stop server: {exc}") diff --git a/src/seedpass/cli/common.py b/src/seedpass/cli/common.py new file mode 100644 index 0000000..aa1d4f8 --- /dev/null +++ b/src/seedpass/cli/common.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import typer + +from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType +from seedpass.core.api import ( + VaultService, + ProfileService, + SyncService, + EntryService, + ConfigService, + UtilityService, + NostrService, + ChangePasswordRequest, + UnlockRequest, + BackupParentSeedRequest, + ProfileSwitchRequest, + ProfileRemoveRequest, +) + + +def _get_pm(ctx: typer.Context) -> PasswordManager: + """Return a PasswordManager optionally selecting a fingerprint.""" + fp = ctx.obj.get("fingerprint") + if fp is None: + pm = PasswordManager() + else: + pm = PasswordManager(fingerprint=fp) + return pm + + +def _get_services( + ctx: typer.Context, +) -> tuple[VaultService, ProfileService, SyncService]: + """Return service layer instances for the current context.""" + + pm = _get_pm(ctx) + return VaultService(pm), ProfileService(pm), SyncService(pm) + + +def _get_entry_service(ctx: typer.Context) -> EntryService: + pm = _get_pm(ctx) + return EntryService(pm) + + +def _get_config_service(ctx: typer.Context) -> ConfigService: + pm = _get_pm(ctx) + return ConfigService(pm) + + +def _get_util_service(ctx: typer.Context) -> UtilityService: + pm = _get_pm(ctx) + return UtilityService(pm) + + +def _get_nostr_service(ctx: typer.Context) -> NostrService: + pm = _get_pm(ctx) + return NostrService(pm) diff --git a/src/seedpass/cli/config.py b/src/seedpass/cli/config.py new file mode 100644 index 0000000..36e78c6 --- /dev/null +++ b/src/seedpass/cli/config.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import typer + +from .common import _get_config_service + + +app = typer.Typer(help="Get or set configuration values") + + +@app.command("get") +def config_get(ctx: typer.Context, key: str) -> None: + """Get a configuration value.""" + service = _get_config_service(ctx) + value = service.get(key) + if value is None: + typer.echo("Key not found") + else: + typer.echo(str(value)) + + +@app.command("set") +def config_set(ctx: typer.Context, key: str, value: str) -> None: + """Set a configuration value.""" + service = _get_config_service(ctx) + + try: + val = ( + [r.strip() for r in value.split(",") if r.strip()] + if key == "relays" + else value + ) + service.set(key, val) + except KeyError: + typer.echo("Unknown key") + raise typer.Exit(code=1) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + + typer.echo("Updated") + + +@app.command("toggle-secret-mode") +def config_toggle_secret_mode(ctx: typer.Context) -> None: + """Interactively enable or disable secret mode. + + When enabled, newly generated and retrieved passwords are copied to the + clipboard instead of printed to the screen. + """ + service = _get_config_service(ctx) + try: + enabled = service.get_secret_mode_enabled() + delay = service.get_clipboard_clear_delay() + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error loading settings: {exc}") + raise typer.Exit(code=1) + + typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}") + choice = ( + typer.prompt( + "Enable secret mode? (y/n, blank to keep)", default="", show_default=False + ) + .strip() + .lower() + ) + if choice in ("y", "yes"): + enabled = True + elif choice in ("n", "no"): + enabled = False + + inp = typer.prompt( + f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False + ).strip() + if inp: + try: + delay = int(inp) + if delay <= 0: + typer.echo("Delay must be positive") + raise typer.Exit(code=1) + except ValueError: + typer.echo("Invalid number") + raise typer.Exit(code=1) + + try: + service.set_secret_mode(enabled, delay) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + + status = "enabled" if enabled else "disabled" + typer.echo(f"Secret mode {status}.") + + +@app.command("toggle-offline") +def config_toggle_offline(ctx: typer.Context) -> None: + """Enable or disable offline mode.""" + service = _get_config_service(ctx) + try: + enabled = service.get_offline_mode() + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error loading settings: {exc}") + raise typer.Exit(code=1) + + typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}") + choice = ( + typer.prompt( + "Enable offline mode? (y/n, blank to keep)", default="", show_default=False + ) + .strip() + .lower() + ) + if choice in ("y", "yes"): + enabled = True + elif choice in ("n", "no"): + enabled = False + + try: + service.set_offline_mode(enabled) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + + status = "enabled" if enabled else "disabled" + typer.echo(f"Offline mode {status}.") diff --git a/src/seedpass/cli/entry.py b/src/seedpass/cli/entry.py new file mode 100644 index 0000000..66ba76a --- /dev/null +++ b/src/seedpass/cli/entry.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import List, Optional + +import typer + +from .common import _get_entry_service, EntryType + + +app = typer.Typer(help="Manage individual entries") + + +@app.command("list") +def entry_list( + ctx: typer.Context, + sort: str = typer.Option( + "index", "--sort", help="Sort by 'index', 'label', or 'updated'" + ), + kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"), + archived: bool = typer.Option(False, "--archived", help="Include archived"), +) -> None: + """List entries in the vault.""" + service = _get_entry_service(ctx) + entries = service.list_entries( + sort_by=sort, filter_kind=kind, include_archived=archived + ) + for idx, label, username, url, is_archived in entries: + line = f"{idx}: {label}" + if username: + line += f" ({username})" + if url: + line += f" {url}" + if is_archived: + line += " [archived]" + typer.echo(line) + + +@app.command("search") +def entry_search( + ctx: typer.Context, + query: str, + kind: List[str] = typer.Option( + None, + "--kind", + "-k", + help="Filter by entry kinds (can be repeated)", + ), +) -> None: + """Search entries.""" + service = _get_entry_service(ctx) + kinds = list(kind) if kind else None + results = service.search_entries(query, kinds=kinds) + if not results: + typer.echo("No matching entries found") + return + for idx, label, username, url, _arch, etype in results: + line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}" + if username: + line += f" ({username})" + if url: + line += f" {url}" + typer.echo(line) + + +@app.command("get") +def entry_get(ctx: typer.Context, query: str) -> None: + """Retrieve a single entry's secret.""" + service = _get_entry_service(ctx) + matches = service.search_entries(query) + if len(matches) == 0: + typer.echo("No matching entries found") + raise typer.Exit(code=1) + if len(matches) > 1: + typer.echo("Matches:") + for idx, label, username, _url, _arch, etype in matches: + name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}" + if username: + name += f" ({username})" + typer.echo(name) + raise typer.Exit(code=1) + + index = matches[0][0] + entry = service.retrieve_entry(index) + etype = entry.get("type", entry.get("kind")) + if etype == EntryType.PASSWORD.value: + length = int(entry.get("length", 12)) + password = service.generate_password(length, index) + typer.echo(password) + elif etype == EntryType.TOTP.value: + code = service.get_totp_code(index) + typer.echo(code) + else: + typer.echo("Unsupported entry type") + raise typer.Exit(code=1) + + +@app.command("add") +def entry_add( + ctx: typer.Context, + label: str, + length: int = typer.Option(12, "--length"), + 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 + ), + allowed_special_chars: Optional[str] = typer.Option( + None, "--allowed-special-chars", help="Explicit set of special characters" + ), + special_mode: Optional[str] = typer.Option( + None, + "--special-mode", + help="Special character mode", + ), + exclude_ambiguous: bool = typer.Option( + 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"), + min_digits: Optional[int] = typer.Option(None, "--min-digits"), + min_special: Optional[int] = typer.Option(None, "--min-special"), +) -> None: + """Add a new password entry and output its index.""" + service = _get_entry_service(ctx) + kwargs = {} + if no_special: + kwargs["include_special_chars"] = False + if allowed_special_chars is not None: + kwargs["allowed_special_chars"] = allowed_special_chars + if special_mode is not None: + kwargs["special_mode"] = special_mode + if exclude_ambiguous: + kwargs["exclude_ambiguous"] = True + if min_uppercase is not None: + kwargs["min_uppercase"] = min_uppercase + if min_lowercase is not None: + kwargs["min_lowercase"] = min_lowercase + if min_digits is not None: + kwargs["min_digits"] = min_digits + if min_special is not None: + kwargs["min_special"] = min_special + + index = service.add_entry(label, length, username, url, **kwargs) + typer.echo(str(index)) + + +@app.command("add-totp") +def entry_add_totp( + ctx: typer.Context, + label: str, + index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), + secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"), + period: int = typer.Option(30, "--period", help="TOTP period in seconds"), + digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), +) -> None: + """Add a TOTP entry and output the otpauth URI.""" + service = _get_entry_service(ctx) + uri = service.add_totp( + label, + index=index, + secret=secret, + period=period, + digits=digits, + ) + typer.echo(uri) + + +@app.command("add-ssh") +def entry_add_ssh( + ctx: typer.Context, + label: str, + index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), + notes: str = typer.Option("", "--notes", help="Entry notes"), +) -> None: + """Add an SSH key entry and output its index.""" + service = _get_entry_service(ctx) + idx = service.add_ssh_key( + label, + index=index, + notes=notes, + ) + typer.echo(str(idx)) + + +@app.command("add-pgp") +def entry_add_pgp( + ctx: typer.Context, + label: str, + index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), + key_type: str = typer.Option("ed25519", "--key-type", help="Key type"), + user_id: str = typer.Option("", "--user-id", help="User ID"), + notes: str = typer.Option("", "--notes", help="Entry notes"), +) -> None: + """Add a PGP key entry and output its index.""" + service = _get_entry_service(ctx) + idx = service.add_pgp_key( + label, + index=index, + key_type=key_type, + user_id=user_id, + notes=notes, + ) + typer.echo(str(idx)) + + +@app.command("add-nostr") +def entry_add_nostr( + ctx: typer.Context, + label: str, + index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), + notes: str = typer.Option("", "--notes", help="Entry notes"), +) -> None: + """Add a Nostr key entry and output its index.""" + service = _get_entry_service(ctx) + idx = service.add_nostr_key( + label, + index=index, + notes=notes, + ) + typer.echo(str(idx)) + + +@app.command("add-seed") +def entry_add_seed( + ctx: typer.Context, + label: str, + index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), + words: int = typer.Option(24, "--words", help="Word count"), + notes: str = typer.Option("", "--notes", help="Entry notes"), +) -> None: + """Add a derived seed phrase entry and output its index.""" + service = _get_entry_service(ctx) + idx = service.add_seed( + label, + index=index, + words=words, + notes=notes, + ) + typer.echo(str(idx)) + + +@app.command("add-key-value") +def entry_add_key_value( + ctx: typer.Context, + label: str, + key: str = typer.Option(..., "--key", help="Key name"), + value: str = typer.Option(..., "--value", help="Stored value"), + notes: str = typer.Option("", "--notes", help="Entry notes"), +) -> None: + """Add a key/value entry and output its index.""" + service = _get_entry_service(ctx) + idx = service.add_key_value(label, key, value, notes=notes) + typer.echo(str(idx)) + + +@app.command("add-managed-account") +def entry_add_managed_account( + ctx: typer.Context, + label: str, + index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), + notes: str = typer.Option("", "--notes", help="Entry notes"), +) -> None: + """Add a managed account seed entry and output its index.""" + service = _get_entry_service(ctx) + idx = service.add_managed_account( + label, + index=index, + notes=notes, + ) + typer.echo(str(idx)) + + +@app.command("modify") +def entry_modify( + ctx: typer.Context, + entry_id: int, + label: Optional[str] = typer.Option(None, "--label"), + username: Optional[str] = typer.Option(None, "--username"), + url: Optional[str] = typer.Option(None, "--url"), + notes: Optional[str] = typer.Option(None, "--notes"), + period: Optional[int] = typer.Option( + None, "--period", help="TOTP period in seconds" + ), + digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"), + key: Optional[str] = typer.Option(None, "--key", help="New key"), + value: Optional[str] = typer.Option(None, "--value", help="New value"), +) -> None: + """Modify an existing entry.""" + service = _get_entry_service(ctx) + try: + service.modify_entry( + entry_id, + username=username, + url=url, + notes=notes, + label=label, + period=period, + digits=digits, + key=key, + value=value, + ) + except ValueError as e: + typer.echo(str(e)) + sys.stdout.flush() + raise typer.Exit(code=1) + + +@app.command("archive") +def entry_archive(ctx: typer.Context, entry_id: int) -> None: + """Archive an entry.""" + service = _get_entry_service(ctx) + service.archive_entry(entry_id) + typer.echo(str(entry_id)) + + +@app.command("unarchive") +def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: + """Restore an archived entry.""" + service = _get_entry_service(ctx) + service.restore_entry(entry_id) + typer.echo(str(entry_id)) + + +@app.command("totp-codes") +def entry_totp_codes(ctx: typer.Context) -> None: + """Display all current TOTP codes.""" + service = _get_entry_service(ctx) + service.display_totp_codes() + + +@app.command("export-totp") +def entry_export_totp( + ctx: typer.Context, file: str = typer.Option(..., help="Output file") +) -> None: + """Export all TOTP secrets to a JSON file.""" + service = _get_entry_service(ctx) + data = service.export_totp_entries() + Path(file).write_text(json.dumps(data, indent=2)) + typer.echo(str(file)) diff --git a/src/seedpass/cli/fingerprint.py b/src/seedpass/cli/fingerprint.py new file mode 100644 index 0000000..6d0653f --- /dev/null +++ b/src/seedpass/cli/fingerprint.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import typer + +from .common import _get_services, ProfileRemoveRequest, ProfileSwitchRequest + + +app = typer.Typer(help="Manage seed profiles") + + +@app.command("list") +def fingerprint_list(ctx: typer.Context) -> None: + """List available seed profiles.""" + _vault, profile_service, _sync = _get_services(ctx) + for fp in profile_service.list_profiles(): + typer.echo(fp) + + +@app.command("add") +def fingerprint_add(ctx: typer.Context) -> None: + """Create a new seed profile.""" + _vault, profile_service, _sync = _get_services(ctx) + profile_service.add_profile() + + +@app.command("remove") +def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None: + """Remove a seed profile.""" + _vault, profile_service, _sync = _get_services(ctx) + profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint)) + + +@app.command("switch") +def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: + """Switch to another seed profile.""" + _vault, profile_service, _sync = _get_services(ctx) + password = typer.prompt("Master password", hide_input=True) + profile_service.switch_profile( + ProfileSwitchRequest(fingerprint=fingerprint, password=password) + ) diff --git a/src/seedpass/cli/nostr.py b/src/seedpass/cli/nostr.py new file mode 100644 index 0000000..612bdf8 --- /dev/null +++ b/src/seedpass/cli/nostr.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import typer + +from .common import _get_services, _get_nostr_service + + +app = typer.Typer(help="Interact with Nostr relays") + + +@app.command("sync") +def nostr_sync(ctx: typer.Context) -> None: + """Sync with configured Nostr relays.""" + _vault, _profile, sync_service = _get_services(ctx) + model = sync_service.sync() + if model: + typer.echo("Event IDs:") + typer.echo(f"- manifest: {model.manifest_id}") + for cid in model.chunk_ids: + typer.echo(f"- chunk: {cid}") + for did in model.delta_ids: + typer.echo(f"- delta: {did}") + else: + typer.echo("Error: Failed to sync vault") + + +@app.command("get-pubkey") +def nostr_get_pubkey(ctx: typer.Context) -> None: + """Display the active profile's npub.""" + service = _get_nostr_service(ctx) + npub = service.get_pubkey() + typer.echo(npub) + + +@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}") + + +@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") + + +@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") diff --git a/src/seedpass/cli/util.py b/src/seedpass/cli/util.py new file mode 100644 index 0000000..83aa0b5 --- /dev/null +++ b/src/seedpass/cli/util.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Optional + +import typer + +from .common import _get_util_service + + +app = typer.Typer(help="Utility commands") + + +@app.command("generate-password") +def generate_password( + ctx: typer.Context, + length: int = 24, + no_special: bool = typer.Option( + False, "--no-special", help="Exclude special characters", is_flag=True + ), + allowed_special_chars: Optional[str] = typer.Option( + None, "--allowed-special-chars", help="Explicit set of special characters" + ), + special_mode: Optional[str] = typer.Option( + None, + "--special-mode", + help="Special character mode", + ), + exclude_ambiguous: bool = typer.Option( + 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"), + min_digits: Optional[int] = typer.Option(None, "--min-digits"), + min_special: Optional[int] = typer.Option(None, "--min-special"), +) -> None: + """Generate a strong password.""" + service = _get_util_service(ctx) + kwargs = {} + if no_special: + kwargs["include_special_chars"] = False + if allowed_special_chars is not None: + kwargs["allowed_special_chars"] = allowed_special_chars + if special_mode is not None: + kwargs["special_mode"] = special_mode + if exclude_ambiguous: + kwargs["exclude_ambiguous"] = True + if min_uppercase is not None: + kwargs["min_uppercase"] = min_uppercase + if min_lowercase is not None: + kwargs["min_lowercase"] = min_lowercase + if min_digits is not None: + kwargs["min_digits"] = min_digits + if min_special is not None: + kwargs["min_special"] = min_special + + password = service.generate_password(length, **kwargs) + typer.echo(password) + + +@app.command("verify-checksum") +def verify_checksum(ctx: typer.Context) -> None: + """Verify the SeedPass script checksum.""" + service = _get_util_service(ctx) + service.verify_checksum() + + +@app.command("update-checksum") +def update_checksum(ctx: typer.Context) -> None: + """Regenerate the script checksum file.""" + service = _get_util_service(ctx) + service.update_checksum() diff --git a/src/seedpass/cli/vault.py b/src/seedpass/cli/vault.py new file mode 100644 index 0000000..89e69ce --- /dev/null +++ b/src/seedpass/cli/vault.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +import typer + +from .common import ( + _get_services, + ChangePasswordRequest, + UnlockRequest, + BackupParentSeedRequest, +) + + +app = typer.Typer(help="Manage the entire vault") + + +@app.command("export") +def vault_export( + ctx: typer.Context, file: str = typer.Option(..., help="Output file") +) -> None: + """Export the vault profile to an encrypted file.""" + vault_service, _profile, _sync = _get_services(ctx) + data = vault_service.export_profile() + Path(file).write_bytes(data) + typer.echo(str(file)) + + +@app.command("import") +def vault_import( + ctx: typer.Context, file: str = typer.Option(..., help="Input file") +) -> None: + """Import a vault profile from an encrypted file.""" + vault_service, _profile, _sync = _get_services(ctx) + data = Path(file).read_bytes() + vault_service.import_profile(data) + typer.echo(str(file)) + + +@app.command("change-password") +def vault_change_password(ctx: typer.Context) -> None: + """Change the master password used for encryption.""" + vault_service, _profile, _sync = _get_services(ctx) + old_pw = typer.prompt("Current password", hide_input=True) + new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True) + try: + vault_service.change_password( + ChangePasswordRequest(old_password=old_pw, new_password=new_pw) + ) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo("Password updated") + + +@app.command("unlock") +def vault_unlock(ctx: typer.Context) -> None: + """Unlock the vault for the active profile.""" + vault_service, _profile, _sync = _get_services(ctx) + password = typer.prompt("Master password", hide_input=True) + try: + resp = vault_service.unlock(UnlockRequest(password=password)) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo(f"Unlocked in {resp.duration:.2f}s") + + +@app.command("lock") +def vault_lock(ctx: typer.Context) -> None: + """Lock the vault and clear sensitive data from memory.""" + vault_service, _profile, _sync = _get_services(ctx) + vault_service.lock() + typer.echo("locked") + + +@app.command("stats") +def vault_stats(ctx: typer.Context) -> None: + """Display statistics about the current seed profile.""" + vault_service, _profile, _sync = _get_services(ctx) + stats = vault_service.stats() + typer.echo(json.dumps(stats, indent=2)) + + +@app.command("reveal-parent-seed") +def vault_reveal_parent_seed( + ctx: typer.Context, + file: Optional[str] = typer.Option( + None, "--file", help="Save encrypted seed to this path" + ), +) -> None: + """Display the parent seed and optionally write an encrypted backup file.""" + vault_service, _profile, _sync = _get_services(ctx) + password = typer.prompt("Master password", hide_input=True) + vault_service.backup_parent_seed( + BackupParentSeedRequest(path=Path(file) if file else None, password=password) + ) diff --git a/src/tests/test_cli_config_set_extra.py b/src/tests/test_cli_config_set_extra.py index 6c06b0c..b6303a9 100644 --- a/src/tests/test_cli_config_set_extra.py +++ b/src/tests/test_cli_config_set_extra.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from typer.testing import CliRunner from seedpass.cli import app -from seedpass import cli +from seedpass.cli import common as cli_common runner = CliRunner() @@ -39,7 +39,7 @@ def test_config_set_variants(monkeypatch, key, value, method, expected): config_manager=SimpleNamespace(**{method: func}), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["config", "set", key, value]) diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py index df2a6f3..d81178c 100644 --- a/src/tests/test_cli_core_services.py +++ b/src/tests/test_cli_core_services.py @@ -5,6 +5,7 @@ from typer.testing import CliRunner from seedpass import cli from seedpass.cli import app +from seedpass.cli import common as cli_common from seedpass.core.entry_types import EntryType runner = CliRunner() @@ -18,7 +19,7 @@ def test_cli_vault_unlock(monkeypatch): return 0.5 pm = SimpleNamespace(unlock_vault=unlock_vault, select_fingerprint=lambda fp: None) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) monkeypatch.setattr(cli.typer, "prompt", lambda *a, **k: "pw") result = runner.invoke(app, ["vault", "unlock"]) assert result.exit_code == 0 @@ -49,7 +50,7 @@ def test_cli_entry_add_search_sync(monkeypatch): sync_vault=lambda: {"manifest_id": "m", "chunk_ids": [], "delta_ids": []}, select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) # entry add result = runner.invoke(app, ["entry", "add", "Label"]) diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index c8bb8fe..99bdb15 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -8,6 +8,8 @@ sys.path.append(str(Path(__file__).resolve().parents[1] / "src")) from typer.testing import CliRunner from seedpass import cli +from seedpass.cli import common as cli_common +from seedpass.cli import api as cli_api from seedpass.core.entry_types import EntryType @@ -97,9 +99,9 @@ runner = CliRunner() def _setup(monkeypatch): - monkeypatch.setattr(cli, "PasswordManager", lambda: DummyPM()) - monkeypatch.setattr(cli.uvicorn, "run", lambda *a, **kw: None) - monkeypatch.setattr(cli.api_module, "start_server", lambda fp: "token") + monkeypatch.setattr(cli_common, "PasswordManager", lambda: DummyPM()) + monkeypatch.setattr(cli_api.uvicorn, "run", lambda *a, **kw: None) + monkeypatch.setattr(cli_api.api_module, "start_server", lambda fp: "token") monkeypatch.setitem( sys.modules, "requests", SimpleNamespace(post=lambda *a, **kw: None) ) diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index e27a03d..da7c680 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from typer.testing import CliRunner from seedpass.cli import app -from seedpass import cli +from seedpass.cli import common as cli_common from helpers import TEST_SEED runner = CliRunner() @@ -148,7 +148,7 @@ def test_entry_add_commands( select_fingerprint=lambda fp: None, start_background_vault_sync=start_background_vault_sync, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", command] + cli_args) assert result.exit_code == 0 assert stdout in result.stdout diff --git a/src/tests/test_cli_integration.py b/src/tests/test_cli_integration.py index a2978bb..f3affca 100644 --- a/src/tests/test_cli_integration.py +++ b/src/tests/test_cli_integration.py @@ -1,7 +1,5 @@ import importlib import shutil -from contextlib import redirect_stdout -from io import StringIO from pathlib import Path from types import SimpleNamespace @@ -58,31 +56,11 @@ def test_cli_integration(monkeypatch, tmp_path): monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add) monkeypatch.setattr("builtins.input", lambda *a, **k: "1") - buf = StringIO() - with redirect_stdout(buf): - try: - cli_module.app(["fingerprint", "add"]) - except SystemExit as e: - assert e.code == 0 - buf.truncate(0) - buf.seek(0) + cli_module.app(["fingerprint", "add"], standalone_mode=False) - with redirect_stdout(buf): - try: - cli_module.app(["entry", "add", "Example", "--length", "8"]) - except SystemExit as e: - assert e.code == 0 - buf.truncate(0) - buf.seek(0) + cli_module.app(["entry", "add", "Example", "--length", "8"], standalone_mode=False) - with redirect_stdout(buf): - try: - cli_module.app(["entry", "get", "Example"]) - except SystemExit as e: - assert e.code == 0 - lines = [line for line in buf.getvalue().splitlines() if line.strip()] - password = lines[-1] - assert len(password.strip()) >= 8 + cli_module.app(["entry", "get", "Example"], standalone_mode=False) fm = manager_module.FingerprintManager(constants.APP_DIR) fp = fm.current_fingerprint diff --git a/src/tests/test_cli_relays.py b/src/tests/test_cli_relays.py index fcfe5fc..1e6d3ac 100644 --- a/src/tests/test_cli_relays.py +++ b/src/tests/test_cli_relays.py @@ -2,7 +2,7 @@ from types import SimpleNamespace from typer.testing import CliRunner from seedpass.cli import app -from seedpass import cli +from seedpass.cli import common as cli_common class DummyService: @@ -37,8 +37,8 @@ def test_cli_relay_crud(monkeypatch): def pm_factory(*a, **k): return SimpleNamespace() - monkeypatch.setattr(cli, "PasswordManager", pm_factory) - monkeypatch.setattr(cli, "NostrService", lambda pm: DummyService(relays)) + monkeypatch.setattr(cli_common, "PasswordManager", pm_factory) + monkeypatch.setattr(cli_common, "NostrService", lambda pm: DummyService(relays)) result = runner.invoke(app, ["nostr", "list-relays"]) assert "1: wss://a" in result.stdout diff --git a/src/tests/test_cli_toggle_offline_mode.py b/src/tests/test_cli_toggle_offline_mode.py index 0a46477..bf3df0f 100644 --- a/src/tests/test_cli_toggle_offline_mode.py +++ b/src/tests/test_cli_toggle_offline_mode.py @@ -2,7 +2,7 @@ from types import SimpleNamespace from typer.testing import CliRunner from seedpass.cli import app -from seedpass import cli +from seedpass.cli import common as cli_common runner = CliRunner() @@ -23,7 +23,7 @@ def _make_pm(called, enabled=False): def test_toggle_offline_updates(monkeypatch): called = {} pm = _make_pm(called) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["config", "toggle-offline"], input="y\n") assert result.exit_code == 0 assert called == {"enabled": True} @@ -33,7 +33,7 @@ def test_toggle_offline_updates(monkeypatch): def test_toggle_offline_keep(monkeypatch): called = {} pm = _make_pm(called, enabled=True) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["config", "toggle-offline"], input="\n") assert result.exit_code == 0 assert called == {"enabled": True} diff --git a/src/tests/test_cli_toggle_secret_mode.py b/src/tests/test_cli_toggle_secret_mode.py index 883afb0..32062be 100644 --- a/src/tests/test_cli_toggle_secret_mode.py +++ b/src/tests/test_cli_toggle_secret_mode.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from typer.testing import CliRunner from seedpass.cli import app -from seedpass import cli +from seedpass.cli import common as cli_common runner = CliRunner() @@ -27,7 +27,7 @@ def _make_pm(called, enabled=False, delay=45): def test_toggle_secret_mode_updates(monkeypatch): called = {} pm = _make_pm(called) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["config", "toggle-secret-mode"], input="y\n10\n") assert result.exit_code == 0 assert called == {"enabled": True, "delay": 10} @@ -37,7 +37,7 @@ def test_toggle_secret_mode_updates(monkeypatch): def test_toggle_secret_mode_keep(monkeypatch): called = {} pm = _make_pm(called, enabled=True, delay=30) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["config", "toggle-secret-mode"], input="\n\n") assert result.exit_code == 0 assert called == {"enabled": True, "delay": 30} diff --git a/src/tests/test_cli_vault_stats.py b/src/tests/test_cli_vault_stats.py index 0e47956..78e3fff 100644 --- a/src/tests/test_cli_vault_stats.py +++ b/src/tests/test_cli_vault_stats.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from typer.testing import CliRunner from seedpass.cli import app -from seedpass import cli +from seedpass.cli import common as cli_common runner = CliRunner() @@ -16,7 +16,7 @@ def test_vault_stats_command(monkeypatch): pm = SimpleNamespace( get_profile_stats=lambda: stats, select_fingerprint=lambda fp: None ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["vault", "stats"]) assert result.exit_code == 0 out = result.stdout diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index f968bd8..ee97721 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -6,7 +6,9 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from typer.testing import CliRunner -from seedpass.cli import app, PasswordManager +from seedpass.cli import app +from seedpass.cli import common as cli_common +from seedpass.cli import api as cli_api from seedpass import cli from seedpass.core.entry_types import EntryType @@ -24,7 +26,7 @@ def test_entry_list(monkeypatch): entry_manager=SimpleNamespace(list_entries=list_entries), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "list"]) assert result.exit_code == 0 assert "Site" in result.stdout @@ -40,7 +42,7 @@ def test_entry_search(monkeypatch): ), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "search", "l"]) assert result.exit_code == 0 assert "Password - L" in result.stdout @@ -61,7 +63,7 @@ def test_entry_get_password(monkeypatch): parent_seed="seed", select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "get", "ex"]) assert result.exit_code == 0 assert "pw" in result.stdout @@ -74,8 +76,8 @@ def test_vault_export(monkeypatch, tmp_path): called["export"] = True return b"data" - monkeypatch.setattr(cli.VaultService, "export_profile", export_profile) - monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace()) + monkeypatch.setattr(cli_common.VaultService, "export_profile", export_profile) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace()) out_path = tmp_path / "out.json" result = runner.invoke(app, ["vault", "export", "--file", str(out_path)]) assert result.exit_code == 0 @@ -89,8 +91,8 @@ def test_vault_import(monkeypatch, tmp_path): def import_profile(self, data): called["data"] = data - monkeypatch.setattr(cli.VaultService, "import_profile", import_profile) - monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace()) + monkeypatch.setattr(cli_common.VaultService, "import_profile", import_profile) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: SimpleNamespace()) in_path = tmp_path / "in.json" in_path.write_bytes(b"inp") result = runner.invoke(app, ["vault", "import", "--file", str(in_path)]) @@ -108,9 +110,9 @@ def test_vault_import_triggers_sync(monkeypatch, tmp_path): def sync_vault(): called["sync"] = True - monkeypatch.setattr(cli.VaultService, "import_profile", import_profile) + monkeypatch.setattr(cli_common.VaultService, "import_profile", import_profile) monkeypatch.setattr( - cli, "PasswordManager", lambda: SimpleNamespace(sync_vault=sync_vault) + cli_common, "PasswordManager", lambda: SimpleNamespace(sync_vault=sync_vault) ) in_path = tmp_path / "in.json" in_path.write_bytes(b"inp") @@ -127,7 +129,7 @@ def test_vault_change_password(monkeypatch): called["args"] = (old, new) pm = SimpleNamespace(change_password=change_pw, select_fingerprint=lambda fp: None) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["vault", "change-password"], input="old\nnew\nnew\n") assert result.exit_code == 0 assert called.get("args") == ("old", "new") @@ -143,7 +145,7 @@ def test_vault_lock(monkeypatch): pm = SimpleNamespace( lock_vault=lock, locked=False, select_fingerprint=lambda fp: None ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["vault", "lock"]) assert result.exit_code == 0 assert called.get("locked") is True @@ -160,7 +162,7 @@ def test_root_lock(monkeypatch): pm = SimpleNamespace( lock_vault=lock, locked=False, select_fingerprint=lambda fp: None ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["lock"]) assert result.exit_code == 0 assert called.get("locked") is True @@ -176,7 +178,7 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path): pm = SimpleNamespace( handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) out_path = tmp_path / "seed.enc" result = runner.invoke( app, @@ -194,7 +196,7 @@ def test_nostr_get_pubkey(monkeypatch): ), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["nostr", "get-pubkey"]) assert result.exit_code == 0 assert "np" in result.stdout @@ -205,7 +207,7 @@ def test_fingerprint_list(monkeypatch): fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["a", "b"]), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["fingerprint", "list"]) assert result.exit_code == 0 assert "a" in result.stdout and "b" in result.stdout @@ -222,7 +224,7 @@ def test_fingerprint_add(monkeypatch): select_fingerprint=lambda fp: None, fingerprint_manager=SimpleNamespace(), ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["fingerprint", "add"]) assert result.exit_code == 0 assert called.get("add") is True @@ -238,7 +240,7 @@ def test_fingerprint_remove(monkeypatch): fingerprint_manager=SimpleNamespace(remove_fingerprint=remove), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["fingerprint", "remove", "abc"]) assert result.exit_code == 0 assert called.get("fp") == "abc" @@ -253,7 +255,7 @@ def test_fingerprint_switch(monkeypatch): pm = SimpleNamespace( select_fingerprint=switch, fingerprint_manager=SimpleNamespace() ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["fingerprint", "switch", "def"], input="pw\n") assert result.exit_code == 0 assert called.get("fp") == "def" @@ -266,7 +268,7 @@ def test_config_get(monkeypatch): ), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["config", "get", "x"]) assert result.exit_code == 0 assert "1" in result.stdout @@ -282,7 +284,7 @@ def test_config_set(monkeypatch): config_manager=SimpleNamespace(set_inactivity_timeout=set_timeout), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["config", "set", "inactivity_timeout", "5"]) assert result.exit_code == 0 assert called["timeout"] == 5.0 @@ -293,7 +295,7 @@ def test_config_set_unknown_key(monkeypatch): pm = SimpleNamespace( config_manager=SimpleNamespace(), select_fingerprint=lambda fp: None ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["config", "set", "bogus", "val"]) assert result.exit_code != 0 assert "Unknown key" in result.stdout @@ -311,7 +313,7 @@ def test_nostr_sync(monkeypatch): } pm = SimpleNamespace(sync_vault=sync_vault, select_fingerprint=lambda fp: None) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["nostr", "sync"]) assert result.exit_code == 0 assert called.get("called") is True @@ -329,12 +331,14 @@ def test_generate_password(monkeypatch): return "secretpw" monkeypatch.setattr( - cli, + cli_common, "PasswordManager", lambda: SimpleNamespace(select_fingerprint=lambda fp: None), ) monkeypatch.setattr( - cli, "UtilityService", lambda pm: SimpleNamespace(generate_password=gen_pw) + cli_common, + "UtilityService", + lambda pm: SimpleNamespace(generate_password=gen_pw), ) result = runner.invoke( app, @@ -382,8 +386,8 @@ def test_api_start_passes_fingerprint(monkeypatch): called["fp"] = fp return "tok" - monkeypatch.setattr(cli.api_module, "start_server", fake_start) - monkeypatch.setattr(cli, "uvicorn", SimpleNamespace(run=lambda *a, **k: None)) + monkeypatch.setattr(cli_api.api_module, "start_server", fake_start) + monkeypatch.setattr(cli_api, "uvicorn", SimpleNamespace(run=lambda *a, **k: None)) result = runner.invoke(app, ["--fingerprint", "abc", "api", "start"]) assert result.exit_code == 0 @@ -399,7 +403,7 @@ def test_entry_list_passes_fingerprint(monkeypatch): called["fp"] = fingerprint self.entry_manager = SimpleNamespace(list_entries=lambda *a, **k: []) - monkeypatch.setattr(cli, "PasswordManager", PM) + monkeypatch.setattr(cli_common, "PasswordManager", PM) result = runner.invoke(app, ["--fingerprint", "abc", "entry", "list"]) assert result.exit_code == 0 assert called.get("fp") == "abc" @@ -418,7 +422,7 @@ def test_entry_add(monkeypatch): select_fingerprint=lambda fp: None, start_background_vault_sync=lambda: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke( app, [ @@ -475,7 +479,7 @@ def test_entry_modify(monkeypatch): select_fingerprint=lambda fp: None, start_background_vault_sync=lambda: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) assert result.exit_code == 0 assert called["args"][:6] == (1, "alice", None, None, None, None) @@ -490,7 +494,7 @@ def test_entry_modify_invalid(monkeypatch): select_fingerprint=lambda fp: None, start_background_vault_sync=lambda: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) assert result.exit_code == 1 assert "bad" in result.stdout @@ -507,7 +511,7 @@ def test_entry_archive(monkeypatch): select_fingerprint=lambda fp: None, start_background_vault_sync=lambda: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "archive", "3"]) assert result.exit_code == 0 assert "3" in result.stdout @@ -525,7 +529,7 @@ def test_entry_unarchive(monkeypatch): select_fingerprint=lambda fp: None, start_background_vault_sync=lambda: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "unarchive", "4"]) assert result.exit_code == 0 assert "4" in result.stdout @@ -543,7 +547,7 @@ def test_entry_export_totp(monkeypatch, tmp_path): parent_seed="seed", select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) out = tmp_path / "t.json" result = runner.invoke(app, ["entry", "export-totp", "--file", str(out)]) @@ -559,7 +563,7 @@ def test_entry_totp_codes(monkeypatch): handle_display_totp_codes=lambda: called.setdefault("called", True), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "totp-codes"]) assert result.exit_code == 0 assert called.get("called") is True @@ -573,7 +577,7 @@ def test_verify_checksum_command(monkeypatch): handle_update_script_checksum=lambda: None, select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["util", "verify-checksum"]) assert result.exit_code == 0 assert called.get("called") is True @@ -587,7 +591,7 @@ def test_update_checksum_command(monkeypatch): handle_update_script_checksum=lambda: called.setdefault("called", True), select_fingerprint=lambda fp: None, ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm) result = runner.invoke(app, ["util", "update-checksum"]) assert result.exit_code == 0 assert called.get("called") is True