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