diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 885ae6a..1b4a44a 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -1,6 +1,11 @@ -import typer +from pathlib import Path from typing import Optional +import typer + +from password_manager.manager import PasswordManager +from password_manager.entry_types import EntryType + app = typer.Typer(help="SeedPass command line interface") # Global option shared across all commands @@ -27,6 +32,16 @@ app.add_typer(fingerprint_app, name="fingerprint") app.add_typer(util_app, name="util") +def _get_pm(ctx: typer.Context) -> PasswordManager: + """Return a PasswordManager optionally selecting a fingerprint.""" + pm = PasswordManager() + fp = ctx.obj.get("fingerprint") + if fp: + # `select_fingerprint` will initialize managers + pm.select_fingerprint(fp) + return pm + + @app.callback() def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point.""" @@ -34,9 +49,77 @@ def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> @entry_app.command("list") -def entry_list(ctx: typer.Context) -> None: +def entry_list( + ctx: typer.Context, + sort: str = typer.Option( + "index", "--sort", help="Sort by 'index', 'label', or 'username'" + ), + 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.""" - typer.echo(f"Listing entries for fingerprint: {ctx.obj.get('fingerprint')}") + pm = _get_pm(ctx) + entries = pm.entry_manager.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) -> None: + """Search entries.""" + pm = _get_pm(ctx) + results = pm.entry_manager.search_entries(query) + 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.""" + pm = _get_pm(ctx) + matches = pm.entry_manager.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 = pm.entry_manager.retrieve_entry(index) + etype = entry.get("type", entry.get("kind")) + if etype == EntryType.PASSWORD.value: + length = int(entry.get("length", 12)) + password = pm.password_generator.generate_password(length, index) + typer.echo(password) + elif etype == EntryType.TOTP.value: + code = pm.entry_manager.get_totp_code(index, pm.parent_seed) + typer.echo(code) + else: + typer.echo("Unsupported entry type") + raise typer.Exit(code=1) @vault_app.command("export") @@ -44,9 +127,9 @@ def vault_export( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: """Export the vault.""" - typer.echo( - f"Exporting vault for fingerprint {ctx.obj.get('fingerprint')} to {file}" - ) + pm = _get_pm(ctx) + pm.handle_export_database(Path(file)) + typer.echo(str(file)) @nostr_app.command("sync") @@ -55,16 +138,31 @@ def nostr_sync(ctx: typer.Context) -> None: typer.echo(f"Syncing vault for fingerprint: {ctx.obj.get('fingerprint')}") +@nostr_app.command("get-pubkey") +def nostr_get_pubkey(ctx: typer.Context) -> None: + """Display the active profile's npub.""" + pm = _get_pm(ctx) + npub = pm.nostr_client.key_manager.get_npub() + typer.echo(npub) + + @config_app.command("get") def config_get(ctx: typer.Context, key: str) -> None: """Get a configuration value.""" - typer.echo(f"Get config '{key}' for fingerprint: {ctx.obj.get('fingerprint')}") + pm = _get_pm(ctx) + value = pm.config_manager.load_config(require_pin=False).get(key) + if value is None: + typer.echo("Key not found") + else: + typer.echo(str(value)) @fingerprint_app.command("list") def fingerprint_list(ctx: typer.Context) -> None: """List available seed profiles.""" - typer.echo("Listing seed profiles") + pm = _get_pm(ctx) + for fp in pm.fingerprint_manager.list_fingerprints(): + typer.echo(fp) @util_app.command("generate-password") diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py new file mode 100644 index 0000000..d4df89f --- /dev/null +++ b/src/tests/test_typer_cli.py @@ -0,0 +1,115 @@ +from types import SimpleNamespace +from pathlib import Path + +from typer.testing import CliRunner + +from seedpass.cli import app, PasswordManager +from seedpass import cli +from password_manager.entry_types import EntryType + +runner = CliRunner() + + +def test_entry_list(monkeypatch): + called = {} + + def list_entries(sort_by="index", filter_kind=None, include_archived=False): + called["args"] = (sort_by, filter_kind, include_archived) + return [(0, "Site", "user", "", False)] + + pm = SimpleNamespace( + entry_manager=SimpleNamespace(list_entries=list_entries), + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["entry", "list"]) + assert result.exit_code == 0 + assert "Site" in result.stdout + assert called["args"] == ("index", None, False) + + +def test_entry_search(monkeypatch): + pm = SimpleNamespace( + entry_manager=SimpleNamespace( + search_entries=lambda q: [(1, "L", None, None, False)] + ), + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["entry", "search", "l"]) + assert result.exit_code == 0 + assert "1: L" in result.stdout + + +def test_entry_get_password(monkeypatch): + def search(q): + return [(2, "Example", "", "", False)] + + entry = {"type": EntryType.PASSWORD.value, "length": 8} + pm = SimpleNamespace( + entry_manager=SimpleNamespace( + search_entries=search, + retrieve_entry=lambda i: entry, + get_totp_code=lambda i, s: "", + ), + password_generator=SimpleNamespace(generate_password=lambda l, i: "pw"), + parent_seed="seed", + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["entry", "get", "ex"]) + assert result.exit_code == 0 + assert "pw" in result.stdout + + +def test_vault_export(monkeypatch, tmp_path): + called = {} + + def export_db(path): + called["path"] = path + + pm = SimpleNamespace( + handle_export_database=export_db, select_fingerprint=lambda fp: None + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + out_path = tmp_path / "out.json" + result = runner.invoke(app, ["vault", "export", "--file", str(out_path)]) + assert result.exit_code == 0 + assert called["path"] == out_path + + +def test_nostr_get_pubkey(monkeypatch): + pm = SimpleNamespace( + nostr_client=SimpleNamespace( + key_manager=SimpleNamespace(get_npub=lambda: "np") + ), + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["nostr", "get-pubkey"]) + assert result.exit_code == 0 + assert "np" in result.stdout + + +def test_fingerprint_list(monkeypatch): + pm = SimpleNamespace( + fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["a", "b"]), + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["fingerprint", "list"]) + assert result.exit_code == 0 + assert "a" in result.stdout and "b" in result.stdout + + +def test_config_get(monkeypatch): + pm = SimpleNamespace( + config_manager=SimpleNamespace( + load_config=lambda require_pin=False: {"x": "1"} + ), + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + result = runner.invoke(app, ["config", "get", "x"]) + assert result.exit_code == 0 + assert "1" in result.stdout