Ctrl K
ComunidadGuías…

main.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import os
import platform
import re
import sys
from dataclasses import dataclass
from typing import Annotated

import typer
from typer._click.exceptions import NoArgsIsHelpError, UsageError

from commands.agent import AgentCommands
from commands.build import BuildCommand
from commands.config import ConfigCommands
from commands.dashboard import DashboardCommands
from commands.decrypt import DecryptCommand
from commands.lifecycle import (
    LogsCommand,
    RestartCommand,
    StartCommand,
    StopCommand,
    UninstallCommand,
)
from commands.update import UpdateCommand
from core.config import GlobalConfig
from core.errors import PortabaseError, UserAbort, ValidationError
from core.version import current_version
from engines import registry as engine_registry
from services.docker import DockerRunner
from services.http import HttpClient
from services.ports import PortAllocator
from services.renderer import ComposeRenderer
from services.telemetry import NoopTelemetry, Telemetry
from services.templates import TemplateRepository
from services.updater import UpdateChecker, Updater, is_frozen
from ui import UI


@dataclass
class Settings:
    non_interactive: bool = False
    verbose: bool = False
    no_color: bool = False

    @classmethod
    def from_env(cls, argv: list[str]) -> "Settings":
        env_flag = os.environ.get("PORTABASE_NON_INTERACTIVE", "").lower()
        settings = cls(
            non_interactive=env_flag in ("1", "true", "yes") or not sys.stdin.isatty(),
            no_color=bool(os.environ.get("NO_COLOR")) or "--no-color" in argv,
        )
        if settings.no_color:
            os.environ["NO_COLOR"] = "1"
        return settings


def build_app(
    ui: UI, telemetry: Telemetry, config: GlobalConfig, settings: Settings
) -> tuple[typer.Typer, UpdateChecker]:
    app = typer.Typer(
        no_args_is_help=True, add_completion=False, rich_markup_mode="rich"
    )
    http = HttpClient()
    docker = DockerRunner()
    version = current_version()
    checker = UpdateChecker(http, config, version)
    updater = Updater(http, version)
    templates = TemplateRepository.bundled()
    ports = PortAllocator()
    renderer = ComposeRenderer(templates, engine_registry, version)

    def version_callback(value: bool) -> None:
        if value:
            ui.print(f"Portabase CLI version: {version}")
            latest = checker.available(force=True)
            if latest:
                ui.warning(f"A new version is available: [bold]{latest}[/bold]")
            raise typer.Exit()

    @app.callback(
        help="Portabase CLI to manage agents, dashboards and databases.",
        invoke_without_command=True,
    )
    def root(
        ctx: typer.Context,
        _version: Annotated[
            bool | None,
            typer.Option(
                "--version",
                help="Show the version and exit.",
                callback=version_callback,
                is_eager=True,
            ),
        ] = None,
        verbose: Annotated[
            bool, typer.Option("--verbose", help="Show error causes and tracebacks.")
        ] = False,
        no_color: Annotated[
            bool, typer.Option("--no-color", help="Disable colors.")
        ] = False,
        non_interactive: Annotated[
            bool,
            typer.Option(
                "--non-interactive",
                envvar="PORTABASE_NON_INTERACTIVE",
                help="Never prompt; fail on missing input.",
            ),
        ] = False,
    ) -> None:
        settings.verbose = verbose
        settings.no_color = settings.no_color or no_color
        settings.non_interactive = settings.non_interactive or non_interactive
        ui.configure(
            verbose=settings.verbose,
            no_color=settings.no_color,
            non_interactive=settings.non_interactive,
        )
        if ctx.invoked_subcommand is None:
            ui.out(ctx.get_help() + "\n")
            raise typer.Exit()

    agent = AgentCommands(
        ui, telemetry, docker, templates, renderer, engine_registry, ports
    )
    commands = [
        StartCommand(ui, telemetry, docker),
        StopCommand(ui, telemetry, docker),
        RestartCommand(ui, telemetry, docker),
        LogsCommand(ui, telemetry, docker),
        UninstallCommand(ui, telemetry, docker),
        BuildCommand(ui, telemetry, templates, renderer),
        DecryptCommand(ui, telemetry),
        UpdateCommand(ui, telemetry, checker, updater),
    ]
    agent.register(app)
    DashboardCommands(ui, telemetry, docker, templates, renderer, ports).register(app)
    for cmd in commands:
        cmd.register(app)
    ConfigCommands(ui, telemetry, config).register(app)
    return app, checker


def _usage_hint(error: UsageError) -> str:
    group = error.ctx.command.name if error.ctx and error.ctx.command else None
    match = re.match(r"No such command '(.+)'", error.format_message())
    if match and match.group(1) == "db":
        return "Database commands belong to the agent: portabase agent db ..."
    if group in ("agent", "dashboard") and match:
        return f"Did you mean: portabase {group} create {match.group(1)}?"
    return "Run 'portabase --help' for usage."


def _notify_update(
    ui: UI, checker: UpdateChecker, settings: Settings, invoked: str | None
) -> None:
    if not is_frozen() or settings.non_interactive or invoked in ("update", None):
        return
    if "--stdout" in sys.argv:
        return
    latest = checker.available()
    if latest:
        ui.print("")
        ui.warning(
            f"A new version of Portabase CLI is available: [bold]{latest}[/bold] "
            f"(current: {checker.current})"
        )
        ui.info("Run [bold]portabase update[/bold] to update.")


def main() -> None:
    settings = Settings.from_env(sys.argv[1:])
    config = GlobalConfig()
    ui = UI(non_interactive=settings.non_interactive, no_color=settings.no_color)
    telemetry = NoopTelemetry()
    app, checker = build_app(ui, telemetry, config, settings)
    invoked = next(
        (argument for argument in sys.argv[1:] if not argument.startswith("-")), None
    )
    exit_code = 0

    try:
        with telemetry.session(cli_version=current_version(), os=platform.system()):
            result = app(standalone_mode=False)
            if isinstance(result, int):
                exit_code = result
    except UserAbort as error:
        ui.warning(error.message)
        telemetry.event("abort")
        exit_code = error.exit_code
    except PortabaseError as error:
        ui.error(error)
        telemetry.error(error)
        exit_code = error.exit_code
    except NoArgsIsHelpError:
        exit_code = 0
    except typer.Exit as error:
        exit_code = error.exit_code
    except UsageError as error:
        err = ValidationError(error.format_message(), hint=_usage_hint(error))
        ui.error(err)
        telemetry.error(err)
        exit_code = err.exit_code
    except KeyboardInterrupt:
        ui.print("")
        ui.warning("Canceled.")
        exit_code = 130
    except Exception as error:  # noqa: BLE001 — last resort: a bug, not an expected error
        wrapped = PortabaseError("Unexpected error: " + str(error), cause=error)
        ui.error(wrapped, unexpected=True)
        telemetry.error(error, unexpected=True)
        exit_code = 1
    finally:
        telemetry.flush()

    if exit_code == 0:
        _notify_update(ui, checker, settings, invoked)
    raise SystemExit(exit_code)


if __name__ == "__main__":
    main()