Add CLI subcommands and tests

This commit is contained in:
thePR0M3TH3AN
2025-07-03 16:09:13 -04:00
parent 8fd571e26f
commit 9a22d537a5
3 changed files with 175 additions and 38 deletions

View File

@@ -16,10 +16,12 @@ import traceback
from password_manager.manager import PasswordManager from password_manager.manager import PasswordManager
from nostr.client import NostrClient from nostr.client import NostrClient
from password_manager.entry_types import EntryType
from constants import INACTIVITY_TIMEOUT, initialize_app from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError from utils.password_prompt import PasswordPromptError
from utils import timed_input from utils import timed_input
from local_bip85.bip85 import Bip85Error from local_bip85.bip85 import Bip85Error
import pyperclip
colorama_init() colorama_init()
@@ -233,6 +235,22 @@ def handle_display_stats(password_manager: PasswordManager) -> None:
print(colored(f"Error: Failed to display stats: {e}", "red")) print(colored(f"Error: Failed to display stats: {e}", "red"))
def print_matches(matches: list[tuple[int, str, str | None, str | None, bool]]) -> None:
"""Print a list of search matches."""
print(colored("\n[+] Matches:\n", "green"))
for entry in matches:
idx, website, username, url, blacklisted = entry
print(colored(f"Index: {idx}", "cyan"))
if website:
print(colored(f" Website: {website}", "cyan"))
if username:
print(colored(f" Username: {username}", "cyan"))
if url:
print(colored(f" URL: {url}", "cyan"))
print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan"))
print("-" * 40)
def handle_post_to_nostr( def handle_post_to_nostr(
password_manager: PasswordManager, alt_summary: str | None = None password_manager: PasswordManager, alt_summary: str | None = None
): ):
@@ -702,15 +720,14 @@ def display_menu(
print(colored("Invalid choice. Please select a valid option.", "red")) print(colored("Invalid choice. Please select a valid option.", "red"))
if __name__ == "__main__": def main(argv: list[str] | None = None) -> int:
# Configure logging with both file and console handlers """Entry point for the SeedPass CLI."""
configure_logging() configure_logging()
initialize_app() initialize_app()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info("Starting SeedPass Password Manager") logger.info("Starting SeedPass Password Manager")
# Load config from disk and parse command-line arguments load_global_config()
cfg = load_global_config()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
@@ -720,48 +737,96 @@ if __name__ == "__main__":
imp = sub.add_parser("import") imp = sub.add_parser("import")
imp.add_argument("--file") imp.add_argument("--file")
args = parser.parse_args() search_p = sub.add_parser("search")
search_p.add_argument("query")
get_p = sub.add_parser("get")
get_p.add_argument("query")
totp_p = sub.add_parser("totp")
totp_p.add_argument("query")
args = parser.parse_args(argv)
# Initialize PasswordManager and proceed with application logic
try: try:
password_manager = PasswordManager() password_manager = PasswordManager()
logger.info("PasswordManager initialized successfully.") logger.info("PasswordManager initialized successfully.")
except (PasswordPromptError, Bip85Error) as e: except (PasswordPromptError, Bip85Error) as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1) return 1
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True) logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1) return 1
if args.command == "export": if args.command == "export":
password_manager.handle_export_database(Path(args.file)) password_manager.handle_export_database(Path(args.file))
sys.exit(0) return 0
elif args.command == "import": if args.command == "import":
password_manager.handle_import_database(Path(args.file)) password_manager.handle_import_database(Path(args.file))
sys.exit(0) return 0
if args.command == "search":
matches = password_manager.entry_manager.search_entries(args.query)
if matches:
print_matches(matches)
else:
print(colored("No matching entries found.", "yellow"))
return 0
if args.command == "get":
matches = password_manager.entry_manager.search_entries(args.query)
if len(matches) != 1:
if not matches:
print(colored("No matching entries found.", "yellow"))
else:
print_matches(matches)
return 1
idx = matches[0][0]
entry = password_manager.entry_manager.retrieve_entry(idx)
if entry.get("type", EntryType.PASSWORD.value) != EntryType.PASSWORD.value:
print(colored("Entry is not a password entry.", "red"))
return 1
length = int(entry.get("length", 0))
pw = password_manager.password_generator.generate_password(length, idx)
print(pw)
return 0
if args.command == "totp":
matches = password_manager.entry_manager.search_entries(args.query)
if len(matches) != 1:
if not matches:
print(colored("No matching entries found.", "yellow"))
else:
print_matches(matches)
return 1
idx = matches[0][0]
entry = password_manager.entry_manager.retrieve_entry(idx)
if entry.get("type") != EntryType.TOTP.value:
print(colored("Entry is not a TOTP entry.", "red"))
return 1
code = password_manager.entry_manager.get_totp_code(
idx, password_manager.parent_seed
)
print(code)
try:
pyperclip.copy(code)
except Exception as exc:
logging.warning(f"Clipboard copy failed: {exc}")
return 0
# Register signal handlers for graceful shutdown def signal_handler(sig, _frame):
def signal_handler(sig, frame):
"""
Handles termination signals to gracefully shutdown the NostrClient.
"""
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow")) print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.") logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.")
try: try:
password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool password_manager.nostr_client.close_client_pool()
logging.info("NostrClient closed successfully.") logging.info("NostrClient closed successfully.")
except Exception as e: except Exception as exc:
logging.error(f"Error during shutdown: {e}") logging.error(f"Error during shutdown: {exc}")
print(colored(f"Error during shutdown: {e}", "red")) print(colored(f"Error during shutdown: {exc}", "red"))
sys.exit(0) sys.exit(0)
# Register the signal handlers signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) # Handle termination signals
# Display the interactive menu to the user
try: try:
display_menu( display_menu(
password_manager, inactivity_timeout=password_manager.inactivity_timeout password_manager, inactivity_timeout=password_manager.inactivity_timeout
@@ -770,29 +835,34 @@ if __name__ == "__main__":
logger.info("Program terminated by user via KeyboardInterrupt.") logger.info("Program terminated by user via KeyboardInterrupt.")
print(colored("\nProgram terminated by user.", "yellow")) print(colored("\nProgram terminated by user.", "yellow"))
try: try:
password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool password_manager.nostr_client.close_client_pool()
logging.info("NostrClient closed successfully.") logging.info("NostrClient closed successfully.")
except Exception as e: except Exception as exc:
logging.error(f"Error during shutdown: {e}") logging.error(f"Error during shutdown: {exc}")
print(colored(f"Error during shutdown: {e}", "red")) print(colored(f"Error during shutdown: {exc}", "red"))
sys.exit(0) return 0
except (PasswordPromptError, Bip85Error) as e: except (PasswordPromptError, Bip85Error) as e:
logger.error(f"A user-related error occurred: {e}", exc_info=True) logger.error(f"A user-related error occurred: {e}", exc_info=True)
print(colored(f"Error: {e}", "red")) print(colored(f"Error: {e}", "red"))
try: try:
password_manager.nostr_client.close_client_pool() password_manager.nostr_client.close_client_pool()
logging.info("NostrClient closed successfully.") logging.info("NostrClient closed successfully.")
except Exception as close_error: except Exception as exc:
logging.error(f"Error during shutdown: {close_error}") logging.error(f"Error during shutdown: {exc}")
print(colored(f"Error during shutdown: {close_error}", "red")) print(colored(f"Error during shutdown: {exc}", "red"))
sys.exit(1) return 1
except Exception as e: except Exception as e:
logger.error(f"An unexpected error occurred: {e}", exc_info=True) logger.error(f"An unexpected error occurred: {e}", exc_info=True)
print(colored(f"Error: An unexpected error occurred: {e}", "red")) print(colored(f"Error: An unexpected error occurred: {e}", "red"))
try: try:
password_manager.nostr_client.close_client_pool() # Attempt to close the ClientPool password_manager.nostr_client.close_client_pool()
logging.info("NostrClient closed successfully.") logging.info("NostrClient closed successfully.")
except Exception as close_error: except Exception as exc:
logging.error(f"Error during shutdown: {close_error}") logging.error(f"Error during shutdown: {exc}")
print(colored(f"Error during shutdown: {close_error}", "red")) print(colored(f"Error during shutdown: {exc}", "red"))
sys.exit(1) return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -21,3 +21,4 @@ mutmut==2.4.4
pyotp>=2.8.0 pyotp>=2.8.0
freezegun freezegun
pyperclip

View File

@@ -0,0 +1,66 @@
import sys
from types import SimpleNamespace
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
import main
from password_manager.entry_types import EntryType
def make_pm(search_results, entry=None, totp_code="123456"):
entry_mgr = SimpleNamespace(
search_entries=lambda q: search_results,
retrieve_entry=lambda idx: entry,
get_totp_code=lambda idx, seed: totp_code,
)
pg = SimpleNamespace(generate_password=lambda l, i: "pw")
pm = SimpleNamespace(
entry_manager=entry_mgr,
password_generator=pg,
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
parent_seed="seed",
inactivity_timeout=1,
)
return pm
def test_search_command(monkeypatch, capsys):
pm = make_pm([(0, "Example", "user", "", False)])
monkeypatch.setattr(main, "PasswordManager", lambda: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
rc = main.main(["search", "ex"])
assert rc == 0
out = capsys.readouterr().out
assert "Example" in out
def test_get_command(monkeypatch, capsys):
entry = {"type": EntryType.PASSWORD.value, "length": 8}
pm = make_pm([(0, "Example", "user", "", False)], entry=entry)
monkeypatch.setattr(main, "PasswordManager", lambda: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
rc = main.main(["get", "ex"])
assert rc == 0
out = capsys.readouterr().out
assert "pw" in out
def test_totp_command(monkeypatch, capsys):
entry = {"type": EntryType.TOTP.value, "period": 30, "index": 0}
pm = make_pm([(0, "Example", None, None, False)], entry=entry)
called = {}
monkeypatch.setattr(main, "PasswordManager", lambda: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
monkeypatch.setattr(main.pyperclip, "copy", lambda v: called.setdefault("val", v))
rc = main.main(["totp", "ex"])
assert rc == 0
out = capsys.readouterr().out
assert "123456" in out
assert called.get("val") == "123456"