mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 16:02:05 +00:00
Add _stdin_is_tty() helper and check at start of _launch_tui3_or_exit. When stdin is not a TTY, emit a clear error message and exit 1 instead of propagating an EOF exception from seed setup prompts. Add test for non-interactive stdin error path; patch _stdin_is_tty in existing tests that expect tui3 launch to proceed. Fixes SeedPass-6d26
442 lines
14 KiB
Python
442 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib
|
|
import importlib.util
|
|
import importlib.metadata
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from .common import _get_entry_service, _get_services
|
|
from seedpass.core.errors import SeedPassError
|
|
from constants import GUI_BACKEND_CONFIG
|
|
from seedpass.tui_v2.app import check_tui2_runtime, launch_tui2
|
|
from seedpass.tui_v3 import check_tui3_runtime, launch_tui3
|
|
|
|
app = typer.Typer(
|
|
help=(
|
|
"SeedPass command line interface. "
|
|
"Run `seedpass capabilities` for a deterministic feature map "
|
|
"and `seedpass <group> --help` for group-specific commands, "
|
|
"including document I/O and entry graph links."
|
|
),
|
|
invoke_without_command=True,
|
|
)
|
|
|
|
# Global option shared across all commands
|
|
fingerprint_option = typer.Option(
|
|
None,
|
|
"--fingerprint",
|
|
"-f",
|
|
help="Specify which seed profile to use",
|
|
)
|
|
|
|
no_clipboard_option = typer.Option(
|
|
False,
|
|
"--no-clipboard",
|
|
help="Disable clipboard support and print secrets instead",
|
|
)
|
|
|
|
deterministic_totp_option = typer.Option(
|
|
False,
|
|
"--deterministic-totp",
|
|
help="Derive TOTP secrets deterministically",
|
|
)
|
|
|
|
legacy_tui_option = typer.Option(
|
|
False,
|
|
"--legacy-tui",
|
|
help="Use legacy interactive TUI explicitly",
|
|
)
|
|
|
|
tui_version_option = typer.Option(
|
|
"v3",
|
|
"--tui",
|
|
help="Select TUI version: legacy, v2, v3",
|
|
)
|
|
|
|
# Sub command groups
|
|
from . import entry, vault, nostr, config, fingerprint, util, api, agent, semantic
|
|
from .capabilities import register_capabilities_command
|
|
|
|
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")
|
|
app.add_typer(agent.app, name="agent")
|
|
app.add_typer(semantic.app, name="semantic")
|
|
register_capabilities_command(app)
|
|
|
|
|
|
def _get_cli_version() -> str:
|
|
"""Return installed package version, falling back to a dev label."""
|
|
try:
|
|
return importlib.metadata.version("seedpass")
|
|
except importlib.metadata.PackageNotFoundError:
|
|
return "dev"
|
|
|
|
|
|
def _version_callback(value: bool) -> None:
|
|
"""Print version and exit early when --version is provided."""
|
|
if not value:
|
|
return
|
|
typer.echo(f"SeedPass {_get_cli_version()}")
|
|
raise typer.Exit()
|
|
|
|
|
|
def run() -> None:
|
|
"""Invoke the CLI, handling SeedPass errors gracefully."""
|
|
try:
|
|
app()
|
|
except SeedPassError as exc:
|
|
typer.echo(str(exc), err=True)
|
|
raise typer.Exit(1) from exc
|
|
|
|
|
|
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
|
|
|
|
|
|
def _prime_tui2_service(
|
|
ctx: typer.Context,
|
|
) -> tuple[Optional[dict[str, object]], Optional[Exception]]:
|
|
"""Initialize entry service before Textual mounts.
|
|
|
|
PasswordManager initialization may prompt for credentials. Doing that before
|
|
entering Textual prevents hidden prompt stalls that appear as a blank screen.
|
|
"""
|
|
try:
|
|
from seedpass.core.api import (
|
|
ProfileService,
|
|
ConfigService,
|
|
NostrService,
|
|
SemanticIndexService,
|
|
SyncService,
|
|
UtilityService,
|
|
VaultService,
|
|
)
|
|
|
|
entry_service = _get_entry_service(ctx)
|
|
pm = getattr(entry_service, "_manager", None)
|
|
return {
|
|
"entry": entry_service,
|
|
"profile": ProfileService(pm) if pm is not None else None,
|
|
"config": ConfigService(pm) if pm is not None else None,
|
|
"nostr": NostrService(pm) if pm is not None else None,
|
|
"sync": SyncService(pm) if pm is not None else None,
|
|
"utility": UtilityService(pm) if pm is not None else None,
|
|
"vault": VaultService(pm) if pm is not None else None,
|
|
"semantic": SemanticIndexService(pm) if pm is not None else None,
|
|
}, None
|
|
except Exception as exc: # pragma: no cover - defensive runtime path
|
|
return None, exc
|
|
|
|
|
|
def _launch_legacy_tui(*, fingerprint: Optional[str]) -> typer.Exit:
|
|
"""Launch legacy interactive TUI and return Typer exit."""
|
|
tui = importlib.import_module("main")
|
|
legacy_main = getattr(tui, "main")
|
|
# Ensure fallback from newer subcommands (e.g., tui2/tui3) does not leak
|
|
# unsupported argv into legacy argparse.
|
|
try:
|
|
return typer.Exit(legacy_main(argv=[], fingerprint=fingerprint))
|
|
except TypeError:
|
|
return typer.Exit(legacy_main(fingerprint=fingerprint))
|
|
|
|
|
|
def _stdin_is_tty() -> bool:
|
|
return sys.stdin.isatty()
|
|
|
|
|
|
def _launch_tui3_or_exit(
|
|
*,
|
|
fingerprint: Optional[str],
|
|
services: Optional[dict[str, object]] = None,
|
|
) -> None:
|
|
if not _stdin_is_tty():
|
|
typer.echo(
|
|
"seedpass tui3: requires an interactive terminal (stdin is not a TTY).",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
kwargs: dict[str, object] = {"fingerprint": fingerprint}
|
|
if services is not None:
|
|
kwargs.update(
|
|
{
|
|
"entry_service_factory": lambda: services["entry"],
|
|
"profile_service_factory": lambda: services["profile"],
|
|
"config_service_factory": lambda: services["config"],
|
|
"nostr_service_factory": lambda: services["nostr"],
|
|
"sync_service_factory": lambda: services["sync"],
|
|
"utility_service_factory": lambda: services["utility"],
|
|
"vault_service_factory": lambda: services["vault"],
|
|
"semantic_service_factory": lambda: services["semantic"],
|
|
}
|
|
)
|
|
launched = launch_tui3(**kwargs)
|
|
if launched:
|
|
return
|
|
typer.echo(
|
|
"TUI v3 runtime unavailable. Run `seedpass tui3 --check` for diagnostics, "
|
|
"`seedpass tui2` to launch TUI v2, or `seedpass legacy` for the legacy interface.",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@app.callback(invoke_without_command=True)
|
|
def main(
|
|
ctx: typer.Context,
|
|
version: bool = typer.Option(
|
|
False,
|
|
"--version",
|
|
callback=_version_callback,
|
|
is_eager=True,
|
|
help="Show SeedPass version and exit",
|
|
),
|
|
fingerprint: Optional[str] = fingerprint_option,
|
|
no_clipboard: bool = no_clipboard_option,
|
|
deterministic_totp: bool = deterministic_totp_option,
|
|
legacy_tui: bool = legacy_tui_option,
|
|
tui_version: str = tui_version_option,
|
|
) -> None:
|
|
"""SeedPass CLI entry point.
|
|
|
|
When called without a subcommand this launches the interactive TUI.
|
|
Use ``seedpass capabilities --format json`` for machine-readable command
|
|
and security feature discovery.
|
|
"""
|
|
ctx.obj = {
|
|
"version": version,
|
|
"fingerprint": fingerprint,
|
|
"no_clipboard": no_clipboard,
|
|
"deterministic_totp": deterministic_totp,
|
|
"legacy_tui": legacy_tui,
|
|
"tui_version": tui_version,
|
|
}
|
|
if ctx.invoked_subcommand is None:
|
|
if legacy_tui or tui_version == "legacy":
|
|
raise _launch_legacy_tui(fingerprint=fingerprint)
|
|
|
|
if tui_version == "v3":
|
|
_launch_tui3_or_exit(fingerprint=fingerprint)
|
|
return
|
|
|
|
services, preflight_error = _prime_tui2_service(ctx)
|
|
if preflight_error is not None:
|
|
typer.echo(
|
|
f"TUI {tui_version} preflight failed: {preflight_error}",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
launched = launch_tui2(
|
|
fingerprint=fingerprint,
|
|
entry_service_factory=lambda: services["entry"],
|
|
profile_service_factory=lambda: services["profile"],
|
|
config_service_factory=lambda: services["config"],
|
|
nostr_service_factory=lambda: services["nostr"],
|
|
sync_service_factory=lambda: services["sync"],
|
|
utility_service_factory=lambda: services["utility"],
|
|
vault_service_factory=lambda: services["vault"],
|
|
semantic_service_factory=lambda: services["semantic"],
|
|
)
|
|
if launched:
|
|
return
|
|
|
|
typer.echo(
|
|
"TUI v2 runtime unavailable. Run `seedpass tui2 --check` for diagnostics, "
|
|
"or use `seedpass legacy` for the legacy interface."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@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("tui2")
|
|
def tui2(
|
|
ctx: typer.Context,
|
|
check: bool = typer.Option(
|
|
False,
|
|
"--check",
|
|
help="Check whether TUI v2 runtime dependencies are available",
|
|
),
|
|
fallback_legacy: bool = typer.Option(
|
|
True,
|
|
"--fallback-legacy/--no-fallback-legacy",
|
|
help="Fall back to legacy TUI when TUI v2 runtime is unavailable",
|
|
),
|
|
) -> None:
|
|
"""Launch the Textual-based TUI v2 (search/edit/graph workflows)."""
|
|
if check:
|
|
typer.echo(json.dumps(check_tui2_runtime(), indent=2, sort_keys=True))
|
|
return
|
|
|
|
fingerprint = (ctx.obj or {}).get("fingerprint")
|
|
services, preflight_error = _prime_tui2_service(ctx)
|
|
if preflight_error is not None:
|
|
if fallback_legacy:
|
|
typer.echo(
|
|
"TUI v2 preflight failed; falling back to legacy TUI. "
|
|
f"Details: {preflight_error}"
|
|
)
|
|
raise _launch_legacy_tui(fingerprint=fingerprint)
|
|
typer.echo(
|
|
f"TUI v2 preflight failed: {preflight_error}",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
launched = launch_tui2(
|
|
fingerprint=fingerprint,
|
|
entry_service_factory=lambda: services["entry"],
|
|
profile_service_factory=lambda: services["profile"],
|
|
config_service_factory=lambda: services["config"],
|
|
nostr_service_factory=lambda: services["nostr"],
|
|
sync_service_factory=lambda: services["sync"],
|
|
utility_service_factory=lambda: services["utility"],
|
|
vault_service_factory=lambda: services["vault"],
|
|
semantic_service_factory=lambda: services["semantic"],
|
|
)
|
|
if launched:
|
|
return
|
|
|
|
if fallback_legacy:
|
|
typer.echo(
|
|
"TUI v2 runtime unavailable; falling back to legacy TUI. "
|
|
"Run `seedpass tui2 --check` for diagnostics."
|
|
)
|
|
raise _launch_legacy_tui(fingerprint=fingerprint)
|
|
|
|
typer.echo(
|
|
"TUI v2 runtime unavailable. Install `textual` and retry.",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@app.command("tui3")
|
|
def tui3(
|
|
ctx: typer.Context,
|
|
check: bool = typer.Option(
|
|
False,
|
|
"--check",
|
|
help="Check whether TUI v3 runtime dependencies are available",
|
|
),
|
|
) -> None:
|
|
"""Launch the current TUI v3 interface."""
|
|
if check:
|
|
typer.echo(json.dumps(check_tui3_runtime(), indent=2, sort_keys=True))
|
|
return
|
|
|
|
fingerprint = (ctx.obj or {}).get("fingerprint")
|
|
_launch_tui3_or_exit(fingerprint=fingerprint)
|
|
|
|
|
|
@app.command("legacy")
|
|
def legacy_tui(ctx: typer.Context) -> None:
|
|
"""Launch the legacy interactive TUI explicitly."""
|
|
fingerprint = (ctx.obj or {}).get("fingerprint")
|
|
raise _launch_legacy_tui(fingerprint=fingerprint)
|
|
|
|
|
|
@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():
|
|
platform_key = "linux" if sys.platform.startswith("linux") else sys.platform
|
|
backend_info = GUI_BACKEND_CONFIG.get(platform_key)
|
|
|
|
if not backend_info:
|
|
typer.echo(
|
|
f"Unsupported platform '{sys.platform}' for BeeWare GUI.",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
pkg = backend_info["pkg"]
|
|
version = backend_info["version"]
|
|
sha256 = backend_info["sha256"]
|
|
|
|
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
|
|
run()
|