Merge pull request #317 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-06 11:06:05 -04:00
committed by GitHub
16 changed files with 530 additions and 199 deletions

View File

@@ -220,18 +220,18 @@ python src/main.py
Example menu: Example menu:
```bash ```bash
Select an option: Select an option:
1. Add Entry 1. Add Entry
2. Retrieve Entry 2. Retrieve Entry
3. Search Entries 3. Search Entries
4. Modify an Existing Entry 4. List Entries
5. 2FA Codes 5. Modify an Existing Entry
6. Settings 6. 2FA Codes
7. Exit 7. Settings
Enter your choice (1-7): Enter your choice (1-7) or press Enter to exit:
``` ```
When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**,
**SSH Key**, **Seed Phrase**, or **PGP Key**. **SSH Key**, **Seed Phrase**, or **PGP Key**.
@@ -324,7 +324,8 @@ Back in the Settings menu you can:
* Select `7` to export the database to an encrypted file. * Select `7` to export the database to an encrypted file.
* Choose `8` to import a database from a backup file. * Choose `8` to import a database from a backup file.
* Select `9` to export all 2FA codes. * Select `9` to export all 2FA codes.
* Choose `10` to set an additional backup location. * Choose `10` to set an additional backup location. A backup is created
immediately after the directory is configured.
* Select `11` to change the inactivity timeout. * Select `11` to change the inactivity timeout.
* Choose `12` to lock the vault and require re-entry of your password. * Choose `12` to lock the vault and require re-entry of your password.
* Select `13` to view seed profile stats. The summary lists counts for * Select `13` to view seed profile stats. The summary lists counts for

View File

@@ -105,12 +105,13 @@
Select an option: Select an option:
1. Add Entry 1. Add Entry
2. Retrieve Entry 2. Retrieve Entry
3. Modify an Existing Entry 3. Search Entries
4. 2FA Codes 4. List Entries
5. Settings 5. Modify an Existing Entry
6. Exit 6. 2FA Codes
7. Settings
Enter your choice (1-6): Enter your choice (1-7) or press Enter to exit:
</pre> </pre>
<h3 class="subsection-title">Secret Mode</h3> <h3 class="subsection-title">Secret Mode</h3>
<p>When Secret Mode is enabled, retrieved passwords are copied directly to your clipboard instead of displayed. The clipboard clears automatically after a delay you set.</p> <p>When Secret Mode is enabled, retrieved passwords are copied directly to your clipboard instead of displayed. The clipboard clears automatically after a delay you set.</p>

View File

@@ -12,6 +12,7 @@ import gzip
import tomli import tomli
from colorama import init as colorama_init from colorama import init as colorama_init
from termcolor import colored from termcolor import colored
from utils.color_scheme import color_text
import traceback import traceback
from password_manager.manager import PasswordManager from password_manager.manager import PasswordManager
@@ -19,7 +20,13 @@ from nostr.client import NostrClient
from password_manager.entry_types import EntryType 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, copy_to_clipboard from utils import (
timed_input,
copy_to_clipboard,
clear_screen,
pause,
clear_and_print_fingerprint,
)
from local_bip85.bip85 import Bip85Error from local_bip85.bip85 import Bip85Error
@@ -201,6 +208,7 @@ def handle_list_fingerprints(password_manager: PasswordManager):
print(colored("Available Seed Profiles:", "cyan")) print(colored("Available Seed Profiles:", "cyan"))
for fp in fingerprints: for fp in fingerprints:
print(colored(f"- {fp}", "cyan")) print(colored(f"- {fp}", "cyan"))
pause()
except Exception as e: except Exception as e:
logging.error(f"Error listing seed profiles: {e}", exc_info=True) logging.error(f"Error listing seed profiles: {e}", exc_info=True)
print(colored(f"Error: Failed to list seed profiles: {e}", "red")) print(colored(f"Error: Failed to list seed profiles: {e}", "red"))
@@ -218,6 +226,7 @@ def handle_display_npub(password_manager: PasswordManager):
else: else:
print(colored("Nostr public key not available.", "red")) print(colored("Nostr public key not available.", "red"))
logging.error("Nostr public key not available.") logging.error("Nostr public key not available.")
pause()
except Exception as e: except Exception as e:
logging.error(f"Failed to display npub: {e}", exc_info=True) logging.error(f"Failed to display npub: {e}", exc_info=True)
print(colored(f"Error: Failed to display npub: {e}", "red")) print(colored(f"Error: Failed to display npub: {e}", "red"))
@@ -248,26 +257,28 @@ def print_matches(
if data if data
else EntryType.PASSWORD.value else EntryType.PASSWORD.value
) )
print(colored(f"Index: {idx}", "cyan")) print(color_text(f"Index: {idx}", "index"))
if etype == EntryType.TOTP.value: if etype == EntryType.TOTP.value:
print(colored(f" Label: {data.get('label', website)}", "cyan")) print(color_text(f" Label: {data.get('label', website)}", "index"))
print(colored(f" Derivation Index: {data.get('index', idx)}", "cyan")) print(color_text(f" Derivation Index: {data.get('index', idx)}", "index"))
elif etype == EntryType.SEED.value: elif etype == EntryType.SEED.value:
print(colored(" Type: Seed Phrase", "cyan")) print(color_text(" Type: Seed Phrase", "index"))
elif etype == EntryType.SSH.value: elif etype == EntryType.SSH.value:
print(colored(" Type: SSH Key", "cyan")) print(color_text(" Type: SSH Key", "index"))
elif etype == EntryType.PGP.value: elif etype == EntryType.PGP.value:
print(colored(" Type: PGP Key", "cyan")) print(color_text(" Type: PGP Key", "index"))
elif etype == EntryType.NOSTR.value: elif etype == EntryType.NOSTR.value:
print(colored(" Type: Nostr Key", "cyan")) print(color_text(" Type: Nostr Key", "index"))
else: else:
if website: if website:
print(colored(f" Label: {website}", "cyan")) print(color_text(f" Label: {website}", "index"))
if username: if username:
print(colored(f" Username: {username}", "cyan")) print(color_text(f" Username: {username}", "index"))
if url: if url:
print(colored(f" URL: {url}", "cyan")) print(color_text(f" URL: {url}", "index"))
print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan")) print(
color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index")
)
print("-" * 40) print("-" * 40)
@@ -338,6 +349,7 @@ def handle_view_relays(cfg_mgr: "ConfigManager") -> None:
print(colored("\nCurrent Relays:", "cyan")) print(colored("\nCurrent Relays:", "cyan"))
for idx, relay in enumerate(relays, start=1): for idx, relay in enumerate(relays, start=1):
print(colored(f"{idx}. {relay}", "cyan")) print(colored(f"{idx}. {relay}", "cyan"))
pause()
except Exception as e: except Exception as e:
logging.error(f"Error displaying relays: {e}") logging.error(f"Error displaying relays: {e}")
print(colored(f"Error: {e}", "red")) print(colored(f"Error: {e}", "red"))
@@ -514,6 +526,8 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None:
try: try:
cfg_mgr.set_additional_backup_path(str(path)) cfg_mgr.set_additional_backup_path(str(path))
print(colored(f"Additional backups will be copied to {path}", "green")) print(colored(f"Additional backups will be copied to {path}", "green"))
if pm.backup_manager is not None:
pm.backup_manager.create_backup()
except Exception as e: except Exception as e:
logging.error(f"Error saving backup path: {e}") logging.error(f"Error saving backup path: {e}")
print(colored(f"Error: {e}", "red")) print(colored(f"Error: {e}", "red"))
@@ -563,13 +577,16 @@ def handle_toggle_secret_mode(pm: PasswordManager) -> None:
def handle_profiles_menu(password_manager: PasswordManager) -> None: def handle_profiles_menu(password_manager: PasswordManager) -> None:
"""Submenu for managing seed profiles.""" """Submenu for managing seed profiles."""
while True: while True:
print("\nProfiles:") clear_and_print_fingerprint(
print("1. Switch Seed Profile") getattr(password_manager, "current_fingerprint", None),
print("2. Add a New Seed Profile") "Main Menu > Settings > Profiles",
print("3. Remove an Existing Seed Profile") )
print("4. List All Seed Profiles") print(color_text("\nProfiles:", "menu"))
print("5. Back") print(color_text("1. Switch Seed Profile", "menu"))
choice = input("Select an option: ").strip() print(color_text("2. Add a New Seed Profile", "menu"))
print(color_text("3. Remove an Existing Seed Profile", "menu"))
print(color_text("4. List All Seed Profiles", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
password_manager.update_activity() password_manager.update_activity()
if choice == "1": if choice == "1":
if not password_manager.handle_switch_fingerprint(): if not password_manager.handle_switch_fingerprint():
@@ -580,7 +597,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
handle_remove_fingerprint(password_manager) handle_remove_fingerprint(password_manager)
elif choice == "4": elif choice == "4":
handle_list_fingerprints(password_manager) handle_list_fingerprints(password_manager)
elif choice == "5": elif not choice:
break break
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
@@ -599,16 +616,19 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
return return
while True: while True:
print("\nNostr Settings:") clear_and_print_fingerprint(
print("1. Backup to Nostr") getattr(password_manager, "current_fingerprint", None),
print("2. Restore from Nostr") "Main Menu > Settings > Nostr",
print("3. View current relays") )
print("4. Add a relay URL") print(color_text("\nNostr Settings:", "menu"))
print("5. Remove a relay by number") print(color_text("1. Backup to Nostr", "menu"))
print("6. Reset to default relays") print(color_text("2. Restore from Nostr", "menu"))
print("7. Display Nostr Public Key") print(color_text("3. View current relays", "menu"))
print("8. Back") print(color_text("4. Add a relay URL", "menu"))
choice = input("Select an option: ").strip() print(color_text("5. Remove a relay by number", "menu"))
print(color_text("6. Reset to default relays", "menu"))
print(color_text("7. Display Nostr Public Key", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
password_manager.update_activity() password_manager.update_activity()
if choice == "1": if choice == "1":
handle_post_to_nostr(password_manager) handle_post_to_nostr(password_manager)
@@ -624,7 +644,7 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
handle_reset_relays(password_manager) handle_reset_relays(password_manager)
elif choice == "7": elif choice == "7":
handle_display_npub(password_manager) handle_display_npub(password_manager)
elif choice == "8": elif not choice:
break break
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
@@ -633,56 +653,73 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
def handle_settings(password_manager: PasswordManager) -> None: def handle_settings(password_manager: PasswordManager) -> None:
"""Interactive settings menu with submenus for profiles and Nostr.""" """Interactive settings menu with submenus for profiles and Nostr."""
while True: while True:
print("\nSettings:") clear_and_print_fingerprint(
print("1. Profiles") getattr(password_manager, "current_fingerprint", None),
print("2. Nostr") "Main Menu > Settings",
print("3. Change password") )
print("4. Verify Script Checksum") print(color_text("\nSettings:", "menu"))
print("5. Generate Script Checksum") print(color_text("1. Profiles", "menu"))
print("6. Backup Parent Seed") print(color_text("2. Nostr", "menu"))
print("7. Export database") print(color_text("3. Change password", "menu"))
print("8. Import database") print(color_text("4. Verify Script Checksum", "menu"))
print("9. Export 2FA codes") print(color_text("5. Generate Script Checksum", "menu"))
print("10. Set additional backup location") print(color_text("6. Backup Parent Seed", "menu"))
print("11. Set inactivity timeout") print(color_text("7. Export database", "menu"))
print("12. Lock Vault") print(color_text("8. Import database", "menu"))
print("13. Stats") print(color_text("9. Export 2FA codes", "menu"))
print("14. Toggle Secret Mode") print(color_text("10. Set additional backup location", "menu"))
print("15. Back") print(color_text("11. Set inactivity timeout", "menu"))
choice = input("Select an option: ").strip() print(color_text("12. Lock Vault", "menu"))
print(color_text("13. Stats", "menu"))
print(color_text("14. Toggle Secret Mode", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
if choice == "1": if choice == "1":
handle_profiles_menu(password_manager) handle_profiles_menu(password_manager)
pause()
elif choice == "2": elif choice == "2":
handle_nostr_menu(password_manager) handle_nostr_menu(password_manager)
pause()
elif choice == "3": elif choice == "3":
password_manager.change_password() password_manager.change_password()
pause()
elif choice == "4": elif choice == "4":
password_manager.handle_verify_checksum() password_manager.handle_verify_checksum()
pause()
elif choice == "5": elif choice == "5":
password_manager.handle_update_script_checksum() password_manager.handle_update_script_checksum()
pause()
elif choice == "6": elif choice == "6":
password_manager.handle_backup_reveal_parent_seed() password_manager.handle_backup_reveal_parent_seed()
pause()
elif choice == "7": elif choice == "7":
password_manager.handle_export_database() password_manager.handle_export_database()
pause()
elif choice == "8": elif choice == "8":
path = input("Enter path to backup file: ").strip() path = input("Enter path to backup file: ").strip()
if path: if path:
password_manager.handle_import_database(Path(path)) password_manager.handle_import_database(Path(path))
pause()
elif choice == "9": elif choice == "9":
password_manager.handle_export_totp_codes() password_manager.handle_export_totp_codes()
pause()
elif choice == "10": elif choice == "10":
handle_set_additional_backup_location(password_manager) handle_set_additional_backup_location(password_manager)
pause()
elif choice == "11": elif choice == "11":
handle_set_inactivity_timeout(password_manager) handle_set_inactivity_timeout(password_manager)
pause()
elif choice == "12": elif choice == "12":
password_manager.lock_vault() password_manager.lock_vault()
print(colored("Vault locked. Please re-enter your password.", "yellow")) print(colored("Vault locked. Please re-enter your password.", "yellow"))
password_manager.unlock_vault() password_manager.unlock_vault()
pause()
elif choice == "13": elif choice == "13":
handle_display_stats(password_manager) handle_display_stats(password_manager)
pause()
elif choice == "14": elif choice == "14":
handle_toggle_secret_mode(password_manager) handle_toggle_secret_mode(password_manager)
elif choice == "15": pause()
elif not choice:
break break
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
@@ -705,12 +742,16 @@ def display_menu(
5. Modify an Existing Entry 5. Modify an Existing Entry
6. 2FA Codes 6. 2FA Codes
7. Settings 7. Settings
8. Exit
""" """
display_fn = getattr(password_manager, "display_stats", None) display_fn = getattr(password_manager, "display_stats", None)
if callable(display_fn): if callable(display_fn):
display_fn() display_fn()
pause()
while True: while True:
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
"Main Menu",
)
if time.time() - password_manager.last_activity > inactivity_timeout: if time.time() - password_manager.last_activity > inactivity_timeout:
print(colored("Session timed out. Vault locked.", "yellow")) print(colored("Session timed out. Vault locked.", "yellow"))
password_manager.lock_vault() password_manager.lock_vault()
@@ -727,10 +768,11 @@ def display_menu(
# Flush logging handlers # Flush logging handlers
for handler in logging.getLogger().handlers: for handler in logging.getLogger().handlers:
handler.flush() handler.flush()
print(colored(menu, "cyan")) print(color_text(menu, "menu"))
try: try:
choice = timed_input( choice = timed_input(
"Enter your choice (1-8): ", inactivity_timeout "Enter your choice (1-7) or press Enter to exit: ",
inactivity_timeout,
).strip() ).strip()
except TimeoutError: except TimeoutError:
print(colored("Session timed out. Vault locked.", "yellow")) print(colored("Session timed out. Vault locked.", "yellow"))
@@ -739,24 +781,26 @@ def display_menu(
continue continue
password_manager.update_activity() password_manager.update_activity()
if not choice: if not choice:
print( logging.info("Exiting the program.")
colored( print(colored("Exiting the program.", "green"))
"No input detected. Please enter a number between 1 and 8.", password_manager.nostr_client.close_client_pool()
"yellow", sys.exit(0)
)
)
continue # Re-display the menu without marking as invalid
if choice == "1": if choice == "1":
while True: while True:
print("\nAdd Entry:") clear_and_print_fingerprint(
print("1. Password") getattr(password_manager, "current_fingerprint", None),
print("2. 2FA (TOTP)") "Main Menu > Add Entry",
print("3. SSH Key") )
print("4. Seed Phrase") print(color_text("\nAdd Entry:", "menu"))
print("5. Nostr Key Pair") print(color_text("1. Password", "menu"))
print("6. PGP Key") print(color_text("2. 2FA (TOTP)", "menu"))
print("7. Back") print(color_text("3. SSH Key", "menu"))
sub_choice = input("Select entry type: ").strip() print(color_text("4. Seed Phrase", "menu"))
print(color_text("5. Nostr Key Pair", "menu"))
print(color_text("6. PGP Key", "menu"))
sub_choice = input(
"Select entry type or press Enter to go back: "
).strip()
password_manager.update_activity() password_manager.update_activity()
if sub_choice == "1": if sub_choice == "1":
password_manager.handle_add_password() password_manager.handle_add_password()
@@ -776,13 +820,17 @@ def display_menu(
elif sub_choice == "6": elif sub_choice == "6":
password_manager.handle_add_pgp() password_manager.handle_add_pgp()
break break
elif sub_choice == "7": elif not sub_choice:
break break
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
elif choice == "2": elif choice == "2":
password_manager.update_activity() password_manager.update_activity()
password_manager.handle_retrieve_entry() password_manager.handle_retrieve_entry()
clear_and_print_fingerprint(
getattr(password_manager, "current_fingerprint", None),
"Main Menu",
)
elif choice == "3": elif choice == "3":
password_manager.update_activity() password_manager.update_activity()
password_manager.handle_search_entries() password_manager.handle_search_entries()
@@ -798,11 +846,6 @@ def display_menu(
elif choice == "7": elif choice == "7":
password_manager.update_activity() password_manager.update_activity()
handle_settings(password_manager) handle_settings(password_manager)
elif choice == "8":
logging.info("Exiting the program.")
print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool()
sys.exit(0)
else: else:
print(colored("Invalid choice. Please select a valid option.", "red")) print(colored("Invalid choice. Please select a valid option.", "red"))

View File

@@ -20,6 +20,7 @@ import shutil
import time import time
import builtins import builtins
from termcolor import colored from termcolor import colored
from utils.color_scheme import color_text
from utils.input_utils import timed_input from utils.input_utils import timed_input
from password_manager.encryption import EncryptionManager from password_manager.encryption import EncryptionManager
@@ -51,6 +52,11 @@ from utils.password_prompt import (
) )
from utils.memory_protection import InMemorySecret from utils.memory_protection import InMemorySecret
from utils.clipboard import copy_to_clipboard from utils.clipboard import copy_to_clipboard
from utils.terminal_utils import (
clear_screen,
pause,
clear_and_print_fingerprint,
)
from constants import MIN_HEALTHY_RELAYS from constants import MIN_HEALTHY_RELAYS
from constants import ( from constants import (
@@ -141,7 +147,7 @@ class PasswordManager:
colored( colored(
"Warning: script checksum mismatch. " "Warning: script checksum mismatch. "
"Run 'Generate Script Checksum' in Settings if you've updated the app.", "Run 'Generate Script Checksum' in Settings if you've updated the app.",
"red", "yellow",
) )
) )
@@ -187,6 +193,7 @@ class PasswordManager:
self.initialize_managers() self.initialize_managers()
self.locked = False self.locked = False
self.update_activity() self.update_activity()
self.sync_index_from_nostr()
def initialize_fingerprint_manager(self): def initialize_fingerprint_manager(self):
""" """
@@ -833,6 +840,29 @@ class PasswordManager:
print(colored(f"Error: Failed to initialize managers: {e}", "red")) print(colored(f"Error: Failed to initialize managers: {e}", "red"))
sys.exit(1) sys.exit(1)
def sync_index_from_nostr(self) -> None:
"""Always fetch the latest vault data from Nostr and update the local index."""
try:
result = asyncio.run(self.nostr_client.fetch_latest_snapshot())
if not result:
return
manifest, chunks = result
encrypted = gzip.decompress(b"".join(chunks))
if manifest.delta_since:
try:
version = int(manifest.delta_since)
deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version))
if deltas:
encrypted = deltas[-1]
except ValueError:
pass
current = self.vault.get_encrypted_index()
if current != encrypted:
self.vault.decrypt_and_save_index_from_nostr(encrypted)
logger.info("Local database synchronized from Nostr.")
except Exception as e:
logger.warning(f"Unable to sync index from Nostr: {e}")
def sync_index_from_nostr_if_missing(self) -> None: def sync_index_from_nostr_if_missing(self) -> None:
"""Retrieve the password database from Nostr if it doesn't exist locally.""" """Retrieve the password database from Nostr if it doesn't exist locally."""
index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc" index_file = self.fingerprint_dir / "seedpass_entries_db.json.enc"
@@ -860,6 +890,10 @@ class PasswordManager:
def handle_add_password(self) -> None: def handle_add_password(self) -> None:
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Add Entry > Password",
)
website_name = input("Enter the label or website name: ").strip() website_name = input("Enter the label or website name: ").strip()
if not website_name: if not website_name:
print(colored("Error: Label cannot be empty.", "red")) print(colored("Error: Label cannot be empty.", "red"))
@@ -936,20 +970,25 @@ class PasswordManager:
f"Failed to post updated index to Nostr: {nostr_error}", f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True, exc_info=True,
) )
pause()
except Exception as e: except Exception as e:
logging.error(f"Error during password generation: {e}", exc_info=True) logging.error(f"Error during password generation: {e}", exc_info=True)
print(colored(f"Error: Failed to generate password: {e}", "red")) print(colored(f"Error: Failed to generate password: {e}", "red"))
pause()
def handle_add_totp(self) -> None: def handle_add_totp(self) -> None:
"""Add a TOTP entry either derived from the seed or imported.""" """Add a TOTP entry either derived from the seed or imported."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Add Entry > 2FA (TOTP)",
)
while True: while True:
print("\nAdd TOTP:") print("\nAdd TOTP:")
print("1. Make 2FA (derive from seed)") print("1. Make 2FA (derive from seed)")
print("2. Import 2FA (paste otpauth URI or secret)") print("2. Import 2FA (paste otpauth URI or secret)")
print("3. Back") choice = input("Select option or press Enter to go back: ").strip()
choice = input("Select option: ").strip()
if choice == "1": if choice == "1":
label = input("Label: ").strip() label = input("Label: ").strip()
if not label: if not label:
@@ -982,7 +1021,7 @@ class PasswordManager:
print(colored("Add this URI to your authenticator app:", "cyan")) print(colored("Add this URI to your authenticator app:", "cyan"))
print(colored(uri, "yellow")) print(colored(uri, "yellow"))
TotpManager.print_qr_code(uri) TotpManager.print_qr_code(uri)
print(colored(f"Secret: {secret}\n", "cyan")) print(color_text(f"Secret: {secret}\n", "deterministic"))
try: try:
self.sync_vault() self.sync_vault()
except Exception as nostr_error: except Exception as nostr_error:
@@ -990,6 +1029,7 @@ class PasswordManager:
f"Failed to post updated index to Nostr: {nostr_error}", f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True, exc_info=True,
) )
pause()
break break
elif choice == "2": elif choice == "2":
raw = input("Paste otpauth URI or secret: ").strip() raw = input("Paste otpauth URI or secret: ").strip()
@@ -1027,20 +1067,26 @@ class PasswordManager:
f"Failed to post updated index to Nostr: {nostr_error}", f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True, exc_info=True,
) )
pause()
break break
except ValueError as err: except ValueError as err:
print(colored(f"Error: {err}", "red")) print(colored(f"Error: {err}", "red"))
elif choice == "3": elif not choice:
return return
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
except Exception as e: except Exception as e:
logging.error(f"Error during TOTP setup: {e}", exc_info=True) logging.error(f"Error during TOTP setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add TOTP: {e}", "red")) print(colored(f"Error: Failed to add TOTP: {e}", "red"))
pause()
def handle_add_ssh_key(self) -> None: def handle_add_ssh_key(self) -> None:
"""Add an SSH key pair entry and display the derived keys.""" """Add an SSH key pair entry and display the derived keys."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Add Entry > SSH Key",
)
label = input("Label: ").strip() label = input("Label: ").strip()
if not label: if not label:
print(colored("Error: Label cannot be empty.", "red")) print(colored("Error: Label cannot be empty.", "red"))
@@ -1063,9 +1109,9 @@ class PasswordManager:
if notes: if notes:
print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Notes: {notes}", "cyan"))
print(colored("Public Key:", "cyan")) print(colored("Public Key:", "cyan"))
print(pub_pem) print(color_text(pub_pem, "default"))
print(colored("Private Key:", "cyan")) print(colored("Private Key:", "cyan"))
print(priv_pem) print(color_text(priv_pem, "deterministic"))
try: try:
self.sync_vault() self.sync_vault()
except Exception as nostr_error: except Exception as nostr_error:
@@ -1073,13 +1119,19 @@ class PasswordManager:
f"Failed to post updated index to Nostr: {nostr_error}", f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True, exc_info=True,
) )
pause()
except Exception as e: except Exception as e:
logging.error(f"Error during SSH key setup: {e}", exc_info=True) logging.error(f"Error during SSH key setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add SSH key: {e}", "red")) print(colored(f"Error: Failed to add SSH key: {e}", "red"))
pause()
def handle_add_seed(self) -> None: def handle_add_seed(self) -> None:
"""Add a derived BIP-39 seed phrase entry.""" """Add a derived BIP-39 seed phrase entry."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Add Entry > Seed Phrase",
)
label = input("Label: ").strip() label = input("Label: ").strip()
if not label: if not label:
print(colored("Error: Label cannot be empty.", "red")) print(colored("Error: Label cannot be empty.", "red"))
@@ -1103,11 +1155,18 @@ class PasswordManager:
print(colored("Seed phrase display cancelled.", "yellow")) print(colored("Seed phrase display cancelled.", "yellow"))
return return
print(colored(f"\n[+] Seed entry added with ID {index}.\n", "green")) print(
colored(
f"\n[+] Seed entry '{label}' added with ID {index}.\n",
"green",
)
)
print(colored(f"Index: {index}", "cyan"))
print(colored(f"Label: {label}", "cyan"))
if notes: if notes:
print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Notes: {notes}", "cyan"))
print(colored("Seed Phrase:", "cyan")) print(colored("Seed Phrase:", "cyan"))
print(colored(phrase, "yellow")) print(color_text(phrase, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "): if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr from password_manager.seedqr import encode_seedqr
@@ -1119,13 +1178,19 @@ class PasswordManager:
f"Failed to post updated index to Nostr: {nostr_error}", f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True, exc_info=True,
) )
pause()
except Exception as e: except Exception as e:
logging.error(f"Error during seed phrase setup: {e}", exc_info=True) logging.error(f"Error during seed phrase setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add seed phrase: {e}", "red")) print(colored(f"Error: Failed to add seed phrase: {e}", "red"))
pause()
def handle_add_pgp(self) -> None: def handle_add_pgp(self) -> None:
"""Add a PGP key entry and display the generated key.""" """Add a PGP key entry and display the generated key."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Add Entry > PGP Key",
)
label = input("Label: ").strip() label = input("Label: ").strip()
if not label: if not label:
print(colored("Error: Label cannot be empty.", "red")) print(colored("Error: Label cannot be empty.", "red"))
@@ -1161,7 +1226,7 @@ class PasswordManager:
if notes: if notes:
print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Notes: {notes}", "cyan"))
print(colored(f"Fingerprint: {fingerprint}", "cyan")) print(colored(f"Fingerprint: {fingerprint}", "cyan"))
print(priv_key) print(color_text(priv_key, "deterministic"))
try: try:
self.sync_vault() self.sync_vault()
except Exception as nostr_error: # pragma: no cover - best effort except Exception as nostr_error: # pragma: no cover - best effort
@@ -1169,13 +1234,19 @@ class PasswordManager:
f"Failed to post updated index to Nostr: {nostr_error}", f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True, exc_info=True,
) )
pause()
except Exception as e: except Exception as e:
logging.error(f"Error during PGP key setup: {e}", exc_info=True) logging.error(f"Error during PGP key setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add PGP key: {e}", "red")) print(colored(f"Error: Failed to add PGP key: {e}", "red"))
pause()
def handle_add_nostr_key(self) -> None: def handle_add_nostr_key(self) -> None:
"""Add a Nostr key entry and display the derived keys.""" """Add a Nostr key entry and display the derived keys."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Add Entry > Nostr Key Pair",
)
label = input("Label: ").strip() label = input("Label: ").strip()
if not label: if not label:
print(colored("Error: Label cannot be empty.", "red")) print(colored("Error: Label cannot be empty.", "red"))
@@ -1196,7 +1267,7 @@ class PasswordManager:
) )
) )
else: else:
print(colored(f"nsec: {nsec}", "cyan")) print(color_text(f"nsec: {nsec}", "deterministic"))
if confirm_action("Show QR code for npub? (Y/N): "): if confirm_action("Show QR code for npub? (Y/N): "):
TotpManager.print_qr_code(f"nostr:{npub}") TotpManager.print_qr_code(f"nostr:{npub}")
if confirm_action( if confirm_action(
@@ -1210,9 +1281,11 @@ class PasswordManager:
f"Failed to post updated index to Nostr: {nostr_error}", f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True, exc_info=True,
) )
pause()
except Exception as e: except Exception as e:
logging.error(f"Error during Nostr key setup: {e}", exc_info=True) logging.error(f"Error during Nostr key setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add Nostr key: {e}", "red")) print(colored(f"Error: Failed to add Nostr key: {e}", "red"))
pause()
def show_entry_details_by_index(self, index: int) -> None: def show_entry_details_by_index(self, index: int) -> None:
"""Display entry details using :meth:`handle_retrieve_entry` for the """Display entry details using :meth:`handle_retrieve_entry` for the
@@ -1240,16 +1313,22 @@ class PasswordManager:
and displaying the corresponding password and associated details. and displaying the corresponding password and associated details.
""" """
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Retrieve Entry",
)
index_input = input( index_input = input(
"Enter the index number of the entry to retrieve: " "Enter the index number of the entry to retrieve: "
).strip() ).strip()
if not index_input.isdigit(): if not index_input.isdigit():
print(colored("Error: Index must be a number.", "red")) print(colored("Error: Index must be a number.", "red"))
pause()
return return
index = int(index_input) index = int(index_input)
entry = self.entry_manager.retrieve_entry(index) entry = self.entry_manager.retrieve_entry(index)
if not entry: if not entry:
pause()
return return
entry_type = entry.get("type", EntryType.PASSWORD.value) entry_type = entry.get("type", EntryType.PASSWORD.value)
@@ -1259,7 +1338,7 @@ class PasswordManager:
period = int(entry.get("period", 30)) period = int(entry.get("period", 30))
notes = entry.get("notes", "") notes = entry.get("notes", "")
print(colored(f"Retrieving 2FA code for '{label}'.", "cyan")) print(colored(f"Retrieving 2FA code for '{label}'.", "cyan"))
print(colored("Press 'b' then Enter to return to the menu.", "cyan")) print(colored("Press Enter to return to the menu.", "cyan"))
try: try:
while True: while True:
code = self.entry_manager.get_totp_code(index, self.parent_seed) code = self.entry_manager.get_totp_code(index, self.parent_seed)
@@ -1274,7 +1353,9 @@ class PasswordManager:
else: else:
print(colored("\n[+] Retrieved 2FA Code:\n", "green")) print(colored("\n[+] Retrieved 2FA Code:\n", "green"))
print(colored(f"Label: {label}", "cyan")) print(colored(f"Label: {label}", "cyan"))
print(colored(f"Code: {code}", "yellow")) imported = "secret" in entry
category = "imported" if imported else "deterministic"
print(color_text(f"Code: {code}", category))
if notes: if notes:
print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Notes: {notes}", "cyan"))
remaining = self.entry_manager.get_totp_time_remaining(index) remaining = self.entry_manager.get_totp_time_remaining(index)
@@ -1286,7 +1367,10 @@ class PasswordManager:
sys.stdout.flush() sys.stdout.flush()
try: try:
user_input = timed_input("", 1) user_input = timed_input("", 1)
if user_input.strip().lower() == "b": if (
user_input.strip() == ""
or user_input.strip().lower() == "b"
):
exit_loop = True exit_loop = True
break break
except TimeoutError: except TimeoutError:
@@ -1303,6 +1387,7 @@ class PasswordManager:
except Exception as e: except Exception as e:
logging.error(f"Error generating TOTP code: {e}", exc_info=True) logging.error(f"Error generating TOTP code: {e}", exc_info=True)
print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
pause()
return return
if entry_type == EntryType.SSH.value: if entry_type == EntryType.SSH.value:
notes = entry.get("notes", "") notes = entry.get("notes", "")
@@ -1322,7 +1407,7 @@ class PasswordManager:
if notes: if notes:
print(colored(f"Notes: {notes}", "cyan")) print(colored(f"Notes: {notes}", "cyan"))
print(colored("Public Key:", "cyan")) print(colored("Public Key:", "cyan"))
print(pub_pem) print(color_text(pub_pem, "default"))
if self.secret_mode_enabled: if self.secret_mode_enabled:
copy_to_clipboard(priv_pem, self.clipboard_clear_delay) copy_to_clipboard(priv_pem, self.clipboard_clear_delay)
print( print(
@@ -1333,10 +1418,11 @@ class PasswordManager:
) )
else: else:
print(colored("Private Key:", "cyan")) print(colored("Private Key:", "cyan"))
print(priv_pem) print(color_text(priv_pem, "deterministic"))
except Exception as e: except Exception as e:
logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) logging.error(f"Error deriving SSH key pair: {e}", exc_info=True)
print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) print(colored(f"Error: Failed to derive SSH keys: {e}", "red"))
pause()
return return
if entry_type == EntryType.SEED.value: if entry_type == EntryType.SEED.value:
notes = entry.get("notes", "") notes = entry.get("notes", "")
@@ -1349,6 +1435,7 @@ class PasswordManager:
try: try:
phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed)
print(colored("\n[+] Retrieved Seed Phrase:\n", "green")) print(colored("\n[+] Retrieved Seed Phrase:\n", "green"))
print(colored(f"Index: {index}", "cyan"))
if label: if label:
print(colored(f"Label: {label}", "cyan")) print(colored(f"Label: {label}", "cyan"))
if notes: if notes:
@@ -1362,7 +1449,7 @@ class PasswordManager:
) )
) )
else: else:
print(colored(phrase, "yellow")) print(color_text(phrase, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "): if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr from password_manager.seedqr import encode_seedqr
@@ -1381,12 +1468,11 @@ class PasswordManager:
app_no=39, app_no=39,
words_len=words, words_len=words,
) )
print(colored(f"Entropy: {entropy.hex()}", "cyan")) print(color_text(f"Entropy: {entropy.hex()}", "deterministic"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
except Exception as e: except Exception as e:
logging.error(f"Error deriving seed phrase: {e}", exc_info=True) logging.error(f"Error deriving seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) print(colored(f"Error: Failed to derive seed phrase: {e}", "red"))
pause()
return return
if entry_type == EntryType.PGP.value: if entry_type == EntryType.PGP.value:
notes = entry.get("notes", "") notes = entry.get("notes", "")
@@ -1415,10 +1501,11 @@ class PasswordManager:
) )
) )
else: else:
print(priv_key) print(color_text(priv_key, "deterministic"))
except Exception as e: except Exception as e:
logging.error(f"Error deriving PGP key: {e}", exc_info=True) logging.error(f"Error deriving PGP key: {e}", exc_info=True)
print(colored(f"Error: Failed to derive PGP key: {e}", "red")) print(colored(f"Error: Failed to derive PGP key: {e}", "red"))
pause()
return return
if entry_type == EntryType.NOSTR.value: if entry_type == EntryType.NOSTR.value:
label = entry.get("label", "") label = entry.get("label", "")
@@ -1439,7 +1526,7 @@ class PasswordManager:
) )
) )
else: else:
print(colored(f"nsec: {nsec}", "cyan")) print(color_text(f"nsec: {nsec}", "deterministic"))
if confirm_action("Show QR code for npub? (Y/N): "): if confirm_action("Show QR code for npub? (Y/N): "):
TotpManager.print_qr_code(f"nostr:{npub}") TotpManager.print_qr_code(f"nostr:{npub}")
if confirm_action( if confirm_action(
@@ -1451,6 +1538,7 @@ class PasswordManager:
except Exception as e: except Exception as e:
logging.error(f"Error deriving Nostr keys: {e}", exc_info=True) logging.error(f"Error deriving Nostr keys: {e}", exc_info=True)
print(colored(f"Error: Failed to derive Nostr keys: {e}", "red")) print(colored(f"Error: Failed to derive Nostr keys: {e}", "red"))
pause()
return return
website_name = entry.get("website") website_name = entry.get("website")
@@ -1474,7 +1562,7 @@ class PasswordManager:
print( print(
colored( colored(
f"Warning: This password is blacklisted and should not be used.", f"Warning: This password is blacklisted and should not be used.",
"red", "yellow",
) )
) )
@@ -1496,7 +1584,7 @@ class PasswordManager:
"green", "green",
) )
) )
print(colored(f"Password: {password}", "yellow")) print(color_text(f"Password: {password}", "deterministic"))
print(colored(f"Associated Username: {username or 'N/A'}", "cyan")) print(colored(f"Associated Username: {username or 'N/A'}", "cyan"))
print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) print(colored(f"Associated URL: {url or 'N/A'}", "cyan"))
print( print(
@@ -1537,9 +1625,11 @@ class PasswordManager:
print(colored(f" {label}: {value}", "cyan")) print(colored(f" {label}: {value}", "cyan"))
else: else:
print(colored("Error: Failed to retrieve the password.", "red")) print(colored("Error: Failed to retrieve the password.", "red"))
pause()
except Exception as e: except Exception as e:
logging.error(f"Error during password retrieval: {e}", exc_info=True) logging.error(f"Error during password retrieval: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve password: {e}", "red")) print(colored(f"Error: Failed to retrieve password: {e}", "red"))
pause()
def handle_modify_entry(self) -> None: def handle_modify_entry(self) -> None:
""" """
@@ -1547,6 +1637,10 @@ class PasswordManager:
and new details to update. and new details to update.
""" """
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Modify Entry",
)
index_input = input( index_input = input(
"Enter the index number of the entry to modify: " "Enter the index number of the entry to modify: "
).strip() ).strip()
@@ -1782,24 +1876,52 @@ class PasswordManager:
print(colored(f"Error: Failed to modify entry: {e}", "red")) print(colored(f"Error: Failed to modify entry: {e}", "red"))
def handle_search_entries(self) -> None: def handle_search_entries(self) -> None:
"""Prompt for a query and display matching entries.""" """Prompt for a query, list matches and optionally show details."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Search Entries",
)
query = input("Enter search string: ").strip() query = input("Enter search string: ").strip()
if not query: if not query:
print(colored("No search string provided.", "yellow")) print(colored("No search string provided.", "yellow"))
pause()
return return
results = self.entry_manager.search_entries(query) results = self.entry_manager.search_entries(query)
if not results: if not results:
print(colored("No matching entries found.", "yellow")) print(colored("No matching entries found.", "yellow"))
pause()
return return
print(colored("\n[+] Search Results:\n", "green")) while True:
for match in results: clear_and_print_fingerprint(
self.display_entry_details(match[0]) getattr(self, "current_fingerprint", None),
"Main Menu > Search Entries",
)
print(colored("\n[+] Search Results:\n", "green"))
for idx, label, username, _url, _b in results:
display_label = label
if username:
display_label += f" ({username})"
print(colored(f"{idx}. {display_label}", "cyan"))
idx_input = input(
"Enter index to view details or press Enter to go back: "
).strip()
if not idx_input:
break
if not idx_input.isdigit() or int(idx_input) not in [
r[0] for r in results
]:
print(colored("Invalid index.", "red"))
pause()
continue
self.show_entry_details_by_index(int(idx_input))
except Exception as e: except Exception as e:
logging.error(f"Failed to search entries: {e}", exc_info=True) logging.error(f"Failed to search entries: {e}", exc_info=True)
print(colored(f"Error: Failed to search entries: {e}", "red")) print(colored(f"Error: Failed to search entries: {e}", "red"))
pause()
def display_entry_details(self, index: int) -> None: def display_entry_details(self, index: int) -> None:
"""Print detailed information for a single entry.""" """Print detailed information for a single entry."""
@@ -1808,77 +1930,94 @@ class PasswordManager:
return return
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
print(colored(f"Index: {index}", "cyan")) print(color_text(f"Index: {index}", "index"))
if etype == EntryType.TOTP.value: if etype == EntryType.TOTP.value:
print(colored(f" Label: {entry.get('label', '')}", "cyan")) print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
print( print(
colored( color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
print(
color_text(
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}", f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
"cyan", "index",
) )
) )
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(color_text(f" Notes: {notes}", "index"))
elif etype == EntryType.SEED.value: elif etype == EntryType.SEED.value:
print(colored(" Type: Seed Phrase", "cyan")) print(color_text(" Type: Seed Phrase", "index"))
print(colored(f" Label: {entry.get('label', '')}", "cyan")) print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(colored(f" Words: {entry.get('words', 24)}", "cyan")) print(color_text(f" Words: {entry.get('words', 24)}", "index"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(color_text(f" Notes: {notes}", "index"))
elif etype == EntryType.SSH.value: elif etype == EntryType.SSH.value:
print(colored(" Type: SSH Key", "cyan")) print(color_text(" Type: SSH Key", "index"))
print(colored(f" Label: {entry.get('label', '')}", "cyan")) print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(color_text(f" Notes: {notes}", "index"))
elif etype == EntryType.PGP.value: elif etype == EntryType.PGP.value:
print(colored(" Type: PGP Key", "cyan")) print(color_text(" Type: PGP Key", "index"))
print(colored(f" Label: {entry.get('label', '')}", "cyan")) print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(colored(f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan")) print(
color_text(f" Key Type: {entry.get('key_type', 'ed25519')}", "index")
)
uid = entry.get("user_id", "") uid = entry.get("user_id", "")
if uid: if uid:
print(colored(f" User ID: {uid}", "cyan")) print(color_text(f" User ID: {uid}", "index"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(color_text(f" Notes: {notes}", "index"))
elif etype == EntryType.NOSTR.value: elif etype == EntryType.NOSTR.value:
print(colored(" Type: Nostr Key", "cyan")) print(color_text(" Type: Nostr Key", "index"))
print(colored(f" Label: {entry.get('label', '')}", "cyan")) print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan")) print(
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(color_text(f" Notes: {notes}", "index"))
else: else:
website = entry.get("label", entry.get("website", "")) website = entry.get("label", entry.get("website", ""))
username = entry.get("username", "") username = entry.get("username", "")
url = entry.get("url", "") url = entry.get("url", "")
blacklisted = entry.get("blacklisted", False) blacklisted = entry.get("blacklisted", False)
print(colored(f" Label: {website}", "cyan")) print(color_text(f" Label: {website}", "index"))
print(colored(f" Username: {username or 'N/A'}", "cyan")) print(color_text(f" Username: {username or 'N/A'}", "index"))
print(colored(f" URL: {url or 'N/A'}", "cyan")) print(color_text(f" URL: {url or 'N/A'}", "index"))
print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan")) print(
color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index")
)
print("-" * 40) print("-" * 40)
def handle_list_entries(self) -> None: def handle_list_entries(self) -> None:
"""List entries and optionally show details.""" """List entries and optionally show details."""
try: try:
while True: while True:
print("\nList Entries:") clear_and_print_fingerprint(
print("1. All") getattr(self, "current_fingerprint", None),
print("2. Passwords") "Main Menu > List Entries",
print("3. 2FA (TOTP)") )
print("4. SSH Key") print(color_text("\nList Entries:", "menu"))
print("5. Seed Phrase") print(color_text("1. All", "menu"))
print("6. Nostr Key Pair") print(color_text("2. Passwords", "menu"))
print("7. PGP") print(color_text("3. 2FA (TOTP)", "menu"))
print("8. Back") print(color_text("4. SSH Key", "menu"))
choice = input("Select entry type: ").strip() print(color_text("5. Seed Phrase", "menu"))
print(color_text("6. Nostr Key Pair", "menu"))
print(color_text("7. PGP", "menu"))
choice = input("Select entry type or press Enter to go back: ").strip()
if choice == "1": if choice == "1":
filter_kind = None filter_kind = None
elif choice == "2": elif choice == "2":
@@ -1893,7 +2032,7 @@ class PasswordManager:
filter_kind = EntryType.NOSTR.value filter_kind = EntryType.NOSTR.value
elif choice == "7": elif choice == "7":
filter_kind = EntryType.PGP.value filter_kind = EntryType.PGP.value
elif choice == "8": elif not choice:
return return
else: else:
print(colored("Invalid choice.", "red")) print(colored("Invalid choice.", "red"))
@@ -1903,6 +2042,10 @@ class PasswordManager:
if not summaries: if not summaries:
continue continue
while True: while True:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > List Entries",
)
print(colored("\n[+] Entries:\n", "green")) print(colored("\n[+] Entries:\n", "green"))
for idx, etype, label in summaries: for idx, etype, label in summaries:
if filter_kind is None: if filter_kind is None:
@@ -1963,6 +2106,10 @@ class PasswordManager:
def handle_display_totp_codes(self) -> None: def handle_display_totp_codes(self) -> None:
"""Display all stored TOTP codes with a countdown progress bar.""" """Display all stored TOTP codes with a countdown progress bar."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > 2FA Codes",
)
data = self.entry_manager.vault.load_index() data = self.entry_manager.vault.load_index()
entries = data.get("entries", {}) entries = data.get("entries", {})
totp_list: list[tuple[str, int, int, bool]] = [] totp_list: list[tuple[str, int, int, bool]] = []
@@ -1980,10 +2127,13 @@ class PasswordManager:
return return
totp_list.sort(key=lambda t: t[0].lower()) totp_list.sort(key=lambda t: t[0].lower())
print(colored("Press 'b' then Enter to return to the menu.", "cyan")) print(colored("Press Enter to return to the menu.", "cyan"))
while True: while True:
print("\033c", end="") clear_and_print_fingerprint(
print(colored("Press 'b' then Enter to return to the menu.", "cyan")) getattr(self, "current_fingerprint", None),
"Main Menu > 2FA Codes",
)
print(colored("Press Enter to return to the menu.", "cyan"))
generated = [t for t in totp_list if not t[3]] generated = [t for t in totp_list if not t[3]]
imported_list = [t for t in totp_list if t[3]] imported_list = [t for t in totp_list if t[3]]
if generated: if generated:
@@ -1999,7 +2149,9 @@ class PasswordManager:
f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard"
) )
else: else:
print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") print(
f"[{idx}] {label}: {color_text(code, 'deterministic')} {bar} {remaining:2d}s"
)
if imported_list: if imported_list:
print(colored("\nImported 2FA Codes:", "green")) print(colored("\nImported 2FA Codes:", "green"))
for label, idx, period, _ in imported_list: for label, idx, period, _ in imported_list:
@@ -2013,11 +2165,13 @@ class PasswordManager:
f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard"
) )
else: else:
print(f"[{idx}] {label}: {code} {bar} {remaining:2d}s") print(
f"[{idx}] {label}: {color_text(code, 'imported')} {bar} {remaining:2d}s"
)
sys.stdout.flush() sys.stdout.flush()
try: try:
user_input = timed_input("", 1) user_input = timed_input("", 1)
if user_input.strip().lower() == "b": if user_input.strip() == "" or user_input.strip().lower() == "b":
break break
except TimeoutError: except TimeoutError:
pass pass
@@ -2033,6 +2187,10 @@ class PasswordManager:
Handles verifying the script's checksum against the stored checksum to ensure integrity. Handles verifying the script's checksum against the stored checksum to ensure integrity.
""" """
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Settings > Verify Script Checksum",
)
current_checksum = calculate_checksum(__file__) current_checksum = calculate_checksum(__file__)
try: try:
verified = verify_checksum(current_checksum, SCRIPT_CHECKSUM_FILE) verified = verify_checksum(current_checksum, SCRIPT_CHECKSUM_FILE)
@@ -2067,6 +2225,10 @@ class PasswordManager:
print(colored("Operation cancelled.", "yellow")) print(colored("Operation cancelled.", "yellow"))
return return
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Settings > Generate Script Checksum",
)
script_path = Path(__file__).resolve() script_path = Path(__file__).resolve()
if update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)): if update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
print( print(
@@ -2176,6 +2338,10 @@ class PasswordManager:
) -> Path | None: ) -> Path | None:
"""Export the current database to an encrypted portable file.""" """Export the current database to an encrypted portable file."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Settings > Export database",
)
path = export_backup( path = export_backup(
self.vault, self.vault,
self.backup_manager, self.backup_manager,
@@ -2192,6 +2358,10 @@ class PasswordManager:
def handle_import_database(self, src: Path) -> None: def handle_import_database(self, src: Path) -> None:
"""Import a portable database file, replacing the current index.""" """Import a portable database file, replacing the current index."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Settings > Import database",
)
import_backup( import_backup(
self.vault, self.vault,
self.backup_manager, self.backup_manager,
@@ -2206,6 +2376,10 @@ class PasswordManager:
def handle_export_totp_codes(self) -> Path | None: def handle_export_totp_codes(self) -> Path | None:
"""Export all 2FA codes to a JSON file for other authenticator apps.""" """Export all 2FA codes to a JSON file for other authenticator apps."""
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Settings > Export 2FA codes",
)
data = self.entry_manager.vault.load_index() data = self.entry_manager.vault.load_index()
entries = data.get("entries", {}) entries = data.get("entries", {})
@@ -2265,17 +2439,21 @@ class PasswordManager:
Handles the backup and reveal of the parent seed. Handles the backup and reveal of the parent seed.
""" """
try: try:
clear_and_print_fingerprint(
getattr(self, "current_fingerprint", None),
"Main Menu > Settings > Backup Parent Seed",
)
print(colored("\n=== Backup Parent Seed ===", "yellow")) print(colored("\n=== Backup Parent Seed ===", "yellow"))
print( print(
colored( colored(
"Warning: Revealing your parent seed is a highly sensitive operation.", "Warning: Revealing your parent seed is a highly sensitive operation.",
"red", "yellow",
) )
) )
print( print(
colored( colored(
"Ensure you're in a secure, private environment and no one is watching your screen.", "Ensure you're in a secure, private environment and no one is watching your screen.",
"red", "yellow",
) )
) )
@@ -2296,7 +2474,7 @@ class PasswordManager:
# Reveal the parent seed # Reveal the parent seed
print(colored("\n=== Your BIP-85 Parent Seed ===", "green")) print(colored("\n=== Your BIP-85 Parent Seed ===", "green"))
print(colored(self.parent_seed, "yellow")) print(color_text(self.parent_seed, "imported"))
print( print(
colored( colored(
"\nPlease write this down and store it securely. Do not share it with anyone.", "\nPlease write this down and store it securely. Do not share it with anyone.",
@@ -2578,34 +2756,37 @@ class PasswordManager:
print(colored("No statistics available.", "red")) print(colored("No statistics available.", "red"))
return return
print(colored("\n=== Seed Profile Stats ===", "yellow")) print(color_text("\n=== Seed Profile Stats ===", "stats"))
print(colored(f"Total entries: {stats['total_entries']}", "cyan")) print(color_text(f"Total entries: {stats['total_entries']}", "stats"))
for etype, count in stats["entries"].items(): for etype, count in stats["entries"].items():
print(colored(f" {etype}: {count}", "cyan")) print(color_text(f" {etype}: {count}", "stats"))
print(colored(f"Relays configured: {stats['relay_count']}", "cyan")) print(color_text(f"Relays configured: {stats['relay_count']}", "stats"))
print( print(
colored( color_text(
f"Backups: {stats['backup_count']} (dir: {stats['backup_dir']})", "cyan" f"Backups: {stats['backup_count']} (dir: {stats['backup_dir']})",
"stats",
) )
) )
if stats.get("additional_backup_path"): if stats.get("additional_backup_path"):
print( print(
colored(f"Additional backup: {stats['additional_backup_path']}", "cyan") color_text(
f"Additional backup: {stats['additional_backup_path']}", "stats"
)
) )
print(colored(f"Schema version: {stats['schema_version']}", "cyan")) print(color_text(f"Schema version: {stats['schema_version']}", "stats"))
print( print(
colored( color_text(
f"Database checksum ok: {'yes' if stats['checksum_ok'] else 'no'}", f"Database checksum ok: {'yes' if stats['checksum_ok'] else 'no'}",
"cyan", "stats",
) )
) )
print( print(
colored( color_text(
f"Script checksum ok: {'yes' if stats['script_checksum_ok'] else 'no'}", f"Script checksum ok: {'yes' if stats['script_checksum_ok'] else 'no'}",
"cyan", "stats",
) )
) )
print(colored(f"Snapshot chunks: {stats['chunk_count']}", "cyan")) print(color_text(f"Snapshot chunks: {stats['chunk_count']}", "stats"))
print(colored(f"Pending deltas: {stats['pending_deltas']}", "cyan")) print(color_text(f"Pending deltas: {stats['pending_deltas']}", "stats"))
if stats.get("delta_since"): if stats.get("delta_since"):
print(colored(f"Latest delta id: {stats['delta_since']}", "cyan")) print(color_text(f"Latest delta id: {stats['delta_since']}", "stats"))

View File

@@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch):
called = True called = True
monkeypatch.setattr(main, "handle_post_to_nostr", fake_post) monkeypatch.setattr(main, "handle_post_to_nostr", fake_post)
monkeypatch.setattr(main, "timed_input", lambda *_: "8") monkeypatch.setattr(main, "timed_input", lambda *_: "")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=0.1) main.display_menu(pm, sync_interval=0.1)

View File

@@ -52,12 +52,11 @@ def _make_pm(called, locked=None):
def test_empty_and_non_numeric_choice(monkeypatch, capsys): def test_empty_and_non_numeric_choice(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) pm, _ = _make_pm(called)
inputs = iter(["", "abc", "8"]) inputs = iter(["abc", ""])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
out = capsys.readouterr().out out = capsys.readouterr().out
assert "No input detected" in out
assert "Invalid choice. Please select a valid option." in out assert "Invalid choice. Please select a valid option." in out
assert not any(called.values()) assert not any(called.values())
@@ -65,7 +64,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys):
def test_out_of_range_menu(monkeypatch, capsys): def test_out_of_range_menu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) pm, _ = _make_pm(called)
inputs = iter(["9", "8"]) inputs = iter(["9", ""])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
@@ -77,7 +76,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
def test_invalid_add_entry_submenu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) pm, _ = _make_pm(called)
inputs = iter(["1", "8", "7", "8"]) inputs = iter(["1", "8", "", ""])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
@@ -92,7 +91,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys):
pm, locked = _make_pm(called) pm, locked = _make_pm(called)
pm.last_activity = 0 pm.last_activity = 0
monkeypatch.setattr(time, "time", lambda: 100.0) monkeypatch.setattr(time, "time", lambda: 100.0)
monkeypatch.setattr(main, "timed_input", lambda *_: "8") monkeypatch.setattr(main, "timed_input", lambda *_: "")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
out = capsys.readouterr().out out = capsys.readouterr().out

View File

@@ -36,7 +36,7 @@ def test_inactivity_triggers_lock(monkeypatch):
unlock_vault=unlock_vault, unlock_vault=unlock_vault,
) )
monkeypatch.setattr(main, "timed_input", lambda *_: "8") monkeypatch.setattr(main, "timed_input", lambda *_: "")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
@@ -72,7 +72,7 @@ def test_input_timeout_triggers_lock(monkeypatch):
unlock_vault=unlock_vault, unlock_vault=unlock_vault,
) )
responses = iter([TimeoutError(), "8"]) responses = iter([TimeoutError(), ""])
def fake_input(*_args, **_kwargs): def fake_input(*_args, **_kwargs):
val = next(responses) val = next(responses)

View File

@@ -13,7 +13,7 @@ from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager from password_manager.config_manager import ConfigManager
def test_search_entries_shows_totp_details(monkeypatch, capsys): def test_search_entries_prompt_for_details(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
@@ -27,15 +27,25 @@ def test_search_entries_shows_totp_details(monkeypatch, capsys):
pm.vault = vault pm.vault = vault
pm.entry_manager = entry_mgr pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace() pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False pm.secret_mode_enabled = False
entry_mgr.add_totp("Example", TEST_SEED) entry_mgr.add_totp("Example", TEST_SEED)
monkeypatch.setattr("builtins.input", lambda *a, **k: "Example") monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
inputs = iter(["Example", "0", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_search_entries() pm.handle_search_entries()
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Label: Example" in out assert "0. Example" in out
assert "Derivation Index" in out assert "Retrieved 2FA Code" in out
assert "123456" in out

View File

@@ -30,7 +30,7 @@ def _make_pm(calls):
def test_menu_totp_option(monkeypatch): def test_menu_totp_option(monkeypatch):
calls = [] calls = []
pm = _make_pm(calls) pm = _make_pm(calls)
inputs = iter(["6", "8"]) inputs = iter(["6", ""])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr(main, "handle_settings", lambda *_: None) monkeypatch.setattr(main, "handle_settings", lambda *_: None)
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
@@ -41,7 +41,7 @@ def test_menu_totp_option(monkeypatch):
def test_menu_settings_option(monkeypatch): def test_menu_settings_option(monkeypatch):
calls = [] calls = []
pm = _make_pm(calls) pm = _make_pm(calls)
inputs = iter(["7", "8"]) inputs = iter(["7", ""])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings")) monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings"))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):

View File

@@ -30,7 +30,7 @@ def _make_pm(called):
def test_menu_search_option(monkeypatch): def test_menu_search_option(monkeypatch):
called = [] called = []
pm = _make_pm(called) pm = _make_pm(called)
inputs = iter(["3", "8"]) inputs = iter(["3", ""])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: "query") monkeypatch.setattr("builtins.input", lambda *_: "query")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):

View File

@@ -81,6 +81,7 @@ def test_password_change_and_unlock(monkeypatch):
) )
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)
monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None)
monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", lambda self: None)
pm.unlock_vault() pm.unlock_vault()

View File

@@ -93,7 +93,7 @@ def test_settings_menu_additional_backup(monkeypatch):
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch)
inputs = iter(["10", "15"]) inputs = iter(["10", ""])
with patch("main.handle_set_additional_backup_location") as handler: with patch("main.handle_set_additional_backup_location") as handler:
with patch("builtins.input", side_effect=lambda *_: next(inputs)): with patch("builtins.input", side_effect=lambda *_: next(inputs)):
main.handle_settings(pm) main.handle_settings(pm)

View File

@@ -0,0 +1,26 @@
import time
from types import SimpleNamespace
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.manager import PasswordManager
def test_unlock_triggers_sync(monkeypatch, tmp_path):
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_dir = tmp_path
pm.setup_encryption_manager = lambda *a, **k: None
pm.initialize_bip85 = lambda: None
pm.initialize_managers = lambda: None
called = {"sync": False}
def fake_sync(self):
called["sync"] = True
monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", fake_sync)
pm.unlock_vault()
assert called["sync"]

View File

@@ -28,6 +28,7 @@ try:
from .input_utils import timed_input from .input_utils import timed_input
from .memory_protection import InMemorySecret from .memory_protection import InMemorySecret
from .clipboard import copy_to_clipboard from .clipboard import copy_to_clipboard
from .terminal_utils import clear_screen, pause, clear_and_print_fingerprint
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.info("Modules imported successfully.") logger.info("Modules imported successfully.")
@@ -55,4 +56,7 @@ __all__ = [
"timed_input", "timed_input",
"InMemorySecret", "InMemorySecret",
"copy_to_clipboard", "copy_to_clipboard",
"clear_screen",
"clear_and_print_fingerprint",
"pause",
] ]

32
src/utils/color_scheme.py Normal file
View File

@@ -0,0 +1,32 @@
"""Utility functions for SeedPass CLI color scheme."""
from termcolor import colored
# ANSI escape for 256-color orange (color code 208)
_ORANGE = "\033[38;5;208m"
_RESET = "\033[0m"
def _apply_orange(text: str) -> str:
"""Return text wrapped in ANSI codes for orange."""
return f"{_ORANGE}{text}{_RESET}"
# Mapping of semantic color categories to actual colors
_COLOR_MAP = {
"deterministic": "red",
"imported": "orange",
"index": "yellow",
"menu": "cyan",
"stats": "green",
"default": "white",
}
def color_text(text: str, category: str = "default") -> str:
"""Colorize ``text`` according to the given category."""
color = _COLOR_MAP.get(category, "white")
if color == "orange":
return _apply_orange(text)
return colored(text, color)

View File

@@ -0,0 +1,33 @@
"""Utility functions for terminal output."""
import sys
from termcolor import colored
def clear_screen() -> None:
"""Clear the terminal screen using an ANSI escape code."""
print("\033c", end="")
def clear_and_print_fingerprint(
fingerprint: str | None, breadcrumb: str | None = None
) -> None:
"""Clear the screen and optionally display the current fingerprint and path."""
clear_screen()
if fingerprint:
header = f"Seed Profile: {fingerprint}"
if breadcrumb:
header += f" > {breadcrumb}"
print(colored(header, "green"))
def pause(message: str = "Press Enter to continue...") -> None:
"""Wait for the user to press Enter before proceeding."""
if not sys.stdin or not sys.stdin.isatty():
return
try:
input(message)
except EOFError:
pass