mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
798 lines
24 KiB
Python
798 lines
24 KiB
Python
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 in results:
|
|
line = f"{idx}: {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 in matches:
|
|
name = f"{idx}: {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"),
|
|
) -> None:
|
|
"""Add a new password entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
index = service.add_entry(label, length, username, url)
|
|
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) -> None:
|
|
"""Generate a strong password."""
|
|
service = _get_util_service(ctx)
|
|
password = service.generate_password(length)
|
|
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() -> None:
|
|
"""Launch the BeeWare GUI.
|
|
|
|
If the platform specific backend is missing, attempt to install it and
|
|
retry launching the GUI.
|
|
"""
|
|
if not _gui_backend_available():
|
|
if sys.platform.startswith("linux"):
|
|
pkg = "toga-gtk"
|
|
elif sys.platform == "win32":
|
|
pkg = "toga-winforms"
|
|
elif sys.platform == "darwin":
|
|
pkg = "toga-cocoa"
|
|
else:
|
|
typer.echo(
|
|
f"Unsupported platform '{sys.platform}' for BeeWare GUI.",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
typer.echo(f"Attempting to install {pkg} for GUI support...")
|
|
try:
|
|
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
|
|
typer.echo(f"Successfully installed {pkg}.")
|
|
except subprocess.CalledProcessError as exc:
|
|
typer.echo(f"Failed to install {pkg}: {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()
|