diff --git a/astrbot/cli/__main__.py b/astrbot/cli/__main__.py index 3dc0d0e419..b91b23662a 100644 --- a/astrbot/cli/__main__.py +++ b/astrbot/cli/__main__.py @@ -5,7 +5,7 @@ import click from . import __version__ -from .commands import conf, init, password, plug, run +from .commands import config, init, password, plugin, run, service logo_tmpl = r""" ___ _______.___________..______ .______ ______ .___________. @@ -17,7 +17,23 @@ """ -@click.group() +class AstrBotCLIGroup(click.Group): + COMMAND_ALIASES = { + "conf": "config", + "plug": "plugin", + } + + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: + command = super().get_command(ctx, cmd_name) + if command is not None: + return command + alias_target = self.COMMAND_ALIASES.get(cmd_name) + if alias_target is None: + return None + return super().get_command(ctx, alias_target) + + +@click.group(cls=AstrBotCLIGroup) @click.version_option(__version__, prog_name="AstrBot") def cli() -> None: """The AstrBot CLI""" @@ -52,9 +68,10 @@ def help(command_name: str | None) -> None: cli.add_command(init) cli.add_command(run) cli.add_command(help) -cli.add_command(plug) -cli.add_command(conf) +cli.add_command(plugin) +cli.add_command(config) cli.add_command(password) +cli.add_command(service) if __name__ == "__main__": cli() diff --git a/astrbot/cli/commands/__init__.py b/astrbot/cli/commands/__init__.py index d1765e5c21..5b05a46b6e 100644 --- a/astrbot/cli/commands/__init__.py +++ b/astrbot/cli/commands/__init__.py @@ -1,7 +1,11 @@ -from .cmd_conf import conf +from .cmd_conf import conf as config from .cmd_init import init from .cmd_password import password -from .cmd_plug import plug +from .cmd_plug import plug as plugin from .cmd_run import run +from .cmd_service import service -__all__ = ["conf", "init", "password", "plug", "run"] +conf = config +plug = plugin + +__all__ = ["config", "conf", "init", "password", "plugin", "plug", "run", "service"] diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py index ac626e0d11..d3dd5d0606 100644 --- a/astrbot/cli/commands/cmd_conf.py +++ b/astrbot/cli/commands/cmd_conf.py @@ -153,7 +153,7 @@ def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None: _set_nested_item(config, "dashboard.password_change_required", False) -@click.group(name="conf") +@click.group(name="config") def conf() -> None: """Configuration management commands diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py index 462c8e8b9e..b0fe0f3dc9 100644 --- a/astrbot/cli/commands/cmd_plug.py +++ b/astrbot/cli/commands/cmd_plug.py @@ -14,7 +14,7 @@ ) -@click.group() +@click.group(name="plugin") def plug() -> None: """Plugin management""" diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py index de09e58521..4da818a097 100644 --- a/astrbot/cli/commands/cmd_run.py +++ b/astrbot/cli/commands/cmd_run.py @@ -9,6 +9,8 @@ from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root +DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_DASHBOARD_RESET_PASSWORD" + async def run_astrbot(astrbot_root: Path) -> None: """Run AstrBot""" @@ -28,8 +30,13 @@ async def run_astrbot(astrbot_root: Path) -> None: @click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins") @click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str) +@click.option( + "--reset-password", + is_flag=True, + help="Force reset the dashboard initial password on startup.", +) @click.command() -def run(reload: bool, port: str) -> None: +def run(reload: bool, port: str, reset_password: bool) -> None: """Run AstrBot""" try: os.environ["ASTRBOT_CLI"] = "1" @@ -50,6 +57,9 @@ def run(reload: bool, port: str) -> None: click.echo("Plugin auto-reload enabled") os.environ["ASTRBOT_RELOAD"] = "1" + if reset_password: + os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1" + lock_file = astrbot_root / "astrbot.lock" lock = FileLock(lock_file, timeout=5) with lock.acquire(): diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py new file mode 100644 index 0000000000..077a36fb74 --- /dev/null +++ b/astrbot/cli/commands/cmd_service.py @@ -0,0 +1,1175 @@ +import copy +import getpass +import json +import os +import platform +import plistlib +import shutil +import subprocess +import sys +import time +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from textwrap import dedent +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +import click + +from ..utils import check_astrbot_root, get_astrbot_root + +DEFAULT_SERVICE_NAME = "astrbot" +DEFAULT_DASHBOARD_PORT = 6185 +DEFAULT_STATUS_TIMEOUT_SECONDS = 2.0 +DEFAULT_LOG_LINES = 200 +MACOS_LABEL_PREFIX = "app.astrbot" + + +@dataclass(frozen=True) +class ServiceState: + manager: str + installed: bool + state: str + path: Path | None = None + enabled: str | None = None + detail: str | None = None + + +@dataclass(frozen=True) +class DashboardPort: + port: int + detail: str | None = None + + +@dataclass(frozen=True) +class WebUIStatus: + url: str + accessible: bool + status_code: int | None = None + detail: str | None = None + + +@dataclass(frozen=True) +class AppLogConfig: + enabled: bool + path: Path + configured_path: str | None = None + + +@click.group(name="service") +def service() -> None: + """Install and manage AstrBot as a background service.""" + + +def _validate_service_name(name: str) -> str: + allowed_chars = set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-" + ) + if not name or any(char not in allowed_chars for char in name): + raise click.ClickException( + "Service name can only contain letters, numbers, dots, underscores, and hyphens" + ) + return name + + +def _resolve_workdir(workdir: Path | None) -> Path: + astrbot_root = (workdir or get_astrbot_root()).expanduser().resolve() + if not check_astrbot_root(astrbot_root): + raise click.ClickException( + f"{astrbot_root} is not a valid AstrBot root directory. " + "Use 'astrbot init' before installing the service" + ) + return astrbot_root + + +def _resolve_astrbot_executable(executable: str | None) -> Path: + if executable: + discovered = shutil.which(executable) + if discovered: + return Path(discovered).expanduser().absolute() + + explicit_path = Path(executable).expanduser() + if explicit_path.exists(): + return explicit_path.absolute() + + raise click.ClickException(f"AstrBot executable not found: {executable}") + + discovered = shutil.which("astrbot") + if discovered: + return Path(discovered).expanduser().absolute() + + current_argv = Path(sys.argv[0]).expanduser() + if current_argv.name.startswith("astrbot") and current_argv.exists(): + return current_argv.absolute() + + raise click.ClickException( + "Cannot find the astrbot executable. Install AstrBot with " + "'uv tool install astrbot --python 3.12', or pass --executable" + ) + + +def _run_checked(command: list[str], failure_message: str) -> None: + try: + subprocess.run(command, check=True) + except FileNotFoundError: + raise click.ClickException(f"Command not found: {command[0]}") + except subprocess.CalledProcessError as e: + raise click.ClickException(f"{failure_message}: {e}") + + +def _run_capture(command: list[str]) -> subprocess.CompletedProcess[str] | None: + try: + return subprocess.run( + command, + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + return None + + +def _quote_systemd_value(value: Path | str) -> str: + raw = str(value) + escaped = raw.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + if any(char.isspace() for char in raw) or any( + char in raw for char in ['"', "\\", "%", ";"] + ): + return f'"{escaped}"' + return escaped + + +def _build_systemd_unit( + service_name: str, + executable: Path, + workdir: Path, +) -> str: + return dedent( + f"""\ + [Unit] + Description=AstrBot Service + Documentation=https://docs.astrbot.app + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + WorkingDirectory={_quote_systemd_value(workdir)} + ExecStart={_quote_systemd_value(executable)} run + Restart=on-failure + RestartSec=5 + StandardOutput=journal + StandardError=journal + SyslogIdentifier={service_name} + Environment=PYTHONUNBUFFERED=1 + + [Install] + WantedBy=default.target + """ + ) + + +def _systemd_unit_path(service_name: str) -> Path: + return Path.home() / ".config" / "systemd" / "user" / f"{service_name}.service" + + +def _systemd_unit_name(service_name: str) -> str: + return f"{service_name}.service" + + +def _install_systemd_user_service( + service_name: str, + executable: Path, + workdir: Path, + *, + force: bool, + now: bool, +) -> Path: + if platform.system() != "Linux": + raise click.ClickException( + "systemd service installation is only available on Linux" + ) + if shutil.which("systemctl") is None: + raise click.ClickException("systemctl was not found") + + unit_path = _systemd_unit_path(service_name) + if unit_path.exists() and not force: + raise click.ClickException( + f"{unit_path} already exists. Use --force to overwrite" + ) + + unit_path.parent.mkdir(parents=True, exist_ok=True) + unit_path.write_text( + _build_systemd_unit(service_name, executable, workdir), + encoding="utf-8", + ) + + _run_checked( + ["systemctl", "--user", "daemon-reload"], + "Failed to reload the systemd user daemon", + ) + _run_checked( + ["systemctl", "--user", "enable", unit_path.name], + "Failed to enable the systemd user service", + ) + if now: + _run_checked( + ["systemctl", "--user", "restart", unit_path.name], + "Failed to start the systemd user service", + ) + + return unit_path + + +def _macos_label(service_name: str) -> str: + return f"{MACOS_LABEL_PREFIX}.{service_name}" + + +def _launch_agent_path(service_name: str) -> Path: + return ( + Path.home() / "Library" / "LaunchAgents" / f"{_macos_label(service_name)}.plist" + ) + + +def _macos_log_dir() -> Path: + return Path.home() / "Library" / "Logs" / "AstrBot" + + +def _service_log_paths(service_name: str) -> tuple[Path, Path]: + system = platform.system() + if system == "Darwin": + log_dir = _macos_log_dir() + else: + log_dir = get_astrbot_root() / "data" / "logs" + return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" + + +def _build_launchd_plist( + service_name: str, + executable: Path, + workdir: Path, + log_dir: Path, +) -> dict: + label = _macos_label(service_name) + return { + "Label": label, + "ProgramArguments": [str(executable), "run"], + "WorkingDirectory": str(workdir), + "RunAtLoad": True, + "KeepAlive": {"SuccessfulExit": False}, + "StandardOutPath": str(log_dir / f"{service_name}.out.log"), + "StandardErrorPath": str(log_dir / f"{service_name}.err.log"), + "EnvironmentVariables": {"PYTHONUNBUFFERED": "1"}, + } + + +def _install_launch_agent( + service_name: str, + executable: Path, + workdir: Path, + *, + force: bool, + now: bool, +) -> Path: + if platform.system() != "Darwin": + raise click.ClickException( + "launchd service installation is only available on macOS" + ) + if shutil.which("launchctl") is None: + raise click.ClickException("launchctl was not found") + + plist_path = _launch_agent_path(service_name) + if plist_path.exists() and not force: + raise click.ClickException( + f"{plist_path} already exists. Use --force to overwrite" + ) + + log_dir = _macos_log_dir() + log_dir.mkdir(parents=True, exist_ok=True) + plist_path.parent.mkdir(parents=True, exist_ok=True) + with plist_path.open("wb") as f: + plistlib.dump( + _build_launchd_plist(service_name, executable, workdir, log_dir), + f, + sort_keys=False, + ) + + if now: + if force: + _stop_launch_agent(service_name, allow_missing=True) + _start_launch_agent(service_name) + + return plist_path + + +def _first_output_line(result: subprocess.CompletedProcess[str]) -> str | None: + text = (result.stdout or result.stderr).strip() + if not text: + return None + return text.splitlines()[0].strip() + + +def _get_systemd_state(service_name: str) -> ServiceState: + unit_path = _systemd_unit_path(service_name) + installed = unit_path.exists() + if shutil.which("systemctl") is None: + return ServiceState( + manager="systemd --user", + installed=installed, + state="unknown", + path=unit_path, + detail="systemctl was not found", + ) + + unit_name = _systemd_unit_name(service_name) + active_result = _run_capture(["systemctl", "--user", "is-active", unit_name]) + enabled_result = _run_capture(["systemctl", "--user", "is-enabled", unit_name]) + if active_result is None: + return ServiceState( + manager="systemd --user", + installed=installed, + state="unknown", + path=unit_path, + detail="systemctl was not found", + ) + + state = (active_result.stdout or "").strip() or "unknown" + detail = ( + None if active_result.returncode == 0 else _first_output_line(active_result) + ) + enabled = None + if enabled_result is not None: + enabled = (enabled_result.stdout or "").strip() or None + + if not installed and state in {"inactive", "unknown"}: + state = "not-installed" + + return ServiceState( + manager="systemd --user", + installed=installed, + state=state, + path=unit_path, + enabled=enabled, + detail=detail, + ) + + +def _get_launchd_state(service_name: str) -> ServiceState: + plist_path = _launch_agent_path(service_name) + installed = plist_path.exists() + if shutil.which("launchctl") is None: + return ServiceState( + manager="launchd", + installed=installed, + state="unknown", + path=plist_path, + detail="launchctl was not found", + ) + + label = _macos_label(service_name) + target = f"gui/{os.getuid()}/{label}" + result = _run_capture(["launchctl", "print", target]) + if result is None: + return ServiceState( + manager="launchd", + installed=installed, + state="unknown", + path=plist_path, + detail="launchctl was not found", + ) + + if result.returncode != 0: + return ServiceState( + manager="launchd", + installed=installed, + state="not-loaded" if installed else "not-installed", + path=plist_path, + detail=_first_output_line(result), + ) + + output = result.stdout or "" + state = "loaded" + detail = None + for line in output.splitlines(): + normalized = line.strip() + if normalized.startswith("state = "): + state = normalized.removeprefix("state = ").strip() + elif normalized.startswith("pid = "): + detail = normalized + + return ServiceState( + manager="launchd", + installed=installed, + state=state, + path=plist_path, + detail=detail, + ) + + +def _get_service_state(service_name: str) -> ServiceState: + system = platform.system() + if system == "Linux": + return _get_systemd_state(service_name) + if system == "Darwin": + return _get_launchd_state(service_name) + return ServiceState( + manager="unknown", + installed=False, + state="unsupported", + detail=f"Unsupported platform: {system}", + ) + + +def _load_dashboard_port(astrbot_root: Path) -> DashboardPort: + config_path = astrbot_root / "data" / "cmd_config.json" + if not config_path.exists(): + return DashboardPort( + DEFAULT_DASHBOARD_PORT, + f"{config_path} does not exist; using default port", + ) + + try: + config = json.loads(config_path.read_text(encoding="utf-8-sig")) + port = int(config.get("dashboard", {}).get("port", DEFAULT_DASHBOARD_PORT)) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as e: + return DashboardPort( + DEFAULT_DASHBOARD_PORT, + f"Failed to read dashboard port from {config_path}: {e}; using default port", + ) + + if port < 1 or port > 65535: + return DashboardPort( + DEFAULT_DASHBOARD_PORT, + f"Invalid dashboard port {port}; using default port", + ) + return DashboardPort(port) + + +def _check_webui(port: int, timeout: float) -> WebUIStatus: + url = f"http://127.0.0.1:{port}/" + request = Request(url, headers={"User-Agent": "AstrBot CLI health check"}) + try: + with urlopen(request, timeout=timeout) as response: + status_code = response.getcode() + except HTTPError as e: + return WebUIStatus( + url=url, + accessible=False, + status_code=e.code, + detail=f"HTTP {e.code}", + ) + except URLError as e: + return WebUIStatus(url=url, accessible=False, detail=str(e.reason)) + except TimeoutError: + return WebUIStatus(url=url, accessible=False, detail="request timed out") + except OSError as e: + return WebUIStatus(url=url, accessible=False, detail=str(e)) + + return WebUIStatus( + url=url, + accessible=200 <= status_code < 400, + status_code=status_code, + detail=f"HTTP {status_code}", + ) + + +def _is_service_running(service_state: ServiceState) -> bool: + return service_state.state.lower() in {"active", "running"} + + +def _health_label(service_state: ServiceState, webui_status: WebUIStatus) -> str: + service_running = _is_service_running(service_state) + if service_running and webui_status.accessible: + return "healthy" + if service_running or webui_status.accessible: + return "degraded" + return "unhealthy" + + +def _format_yes_no(value: bool) -> str: + return "yes" if value else "no" + + +def _control_systemd_service(service_name: str, action: str) -> None: + if shutil.which("systemctl") is None: + raise click.ClickException("systemctl was not found") + + unit_path = _systemd_unit_path(service_name) + if not unit_path.exists(): + raise click.ClickException( + f"{unit_path} does not exist. Run 'service install' first" + ) + + _run_checked( + ["systemctl", "--user", action, _systemd_unit_name(service_name)], + f"Failed to {action} the systemd user service", + ) + + +def _launchd_target(service_name: str) -> str: + return f"gui/{os.getuid()}/{_macos_label(service_name)}" + + +def _launchd_domain() -> str: + return f"gui/{os.getuid()}" + + +def _is_launch_agent_loaded(service_name: str) -> bool: + result = _run_capture(["launchctl", "print", _launchd_target(service_name)]) + return result is not None and result.returncode == 0 + + +def _wait_for_launch_agent_state( + service_name: str, + *, + loaded: bool, + timeout: float = 5.0, +) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _is_launch_agent_loaded(service_name) is loaded: + return True + time.sleep(0.1) + return _is_launch_agent_loaded(service_name) is loaded + + +def _bootstrap_launch_agent(service_name: str, plist_path: Path) -> None: + _run_checked( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + f"Failed to load the LaunchAgent from {plist_path}", + ) + if not _wait_for_launch_agent_state(service_name, loaded=True): + raise click.ClickException( + "LaunchAgent was bootstrapped but did not appear in launchd. " + f"Label: {_macos_label(service_name)}; plist: {plist_path}" + ) + + +def _enable_launch_agent(service_name: str) -> None: + _run_checked( + ["launchctl", "enable", _launchd_target(service_name)], + f"Failed to enable the LaunchAgent {_macos_label(service_name)}", + ) + + +def _kickstart_launch_agent(service_name: str) -> None: + target = _launchd_target(service_name) + result = _run_capture(["launchctl", "kickstart", "-k", target]) + if result is None: + raise click.ClickException("launchctl was not found") + if result.returncode == 0: + return + + detail = _first_output_line(result) + message = f"Failed to start the LaunchAgent {target}" + if detail: + message = f"{message}: {detail}" + raise click.ClickException(message) + + +def _start_launch_agent(service_name: str) -> None: + if shutil.which("launchctl") is None: + raise click.ClickException("launchctl was not found") + + plist_path = _launch_agent_path(service_name) + if not plist_path.exists(): + raise click.ClickException( + f"{plist_path} does not exist. Run 'service install' first" + ) + + if not _is_launch_agent_loaded(service_name): + _bootstrap_launch_agent(service_name, plist_path) + + _enable_launch_agent(service_name) + _kickstart_launch_agent(service_name) + + +def _stop_launch_agent(service_name: str, *, allow_missing: bool = False) -> None: + if shutil.which("launchctl") is None: + raise click.ClickException("launchctl was not found") + + result = _run_capture(["launchctl", "bootout", _launchd_target(service_name)]) + if result is None: + raise click.ClickException("launchctl was not found") + if result.returncode != 0 and not allow_missing: + detail = _first_output_line(result) + message = "Failed to stop the LaunchAgent" + if detail: + message = f"{message}: {detail}" + raise click.ClickException(message) + if result.returncode == 0: + _wait_for_launch_agent_state(service_name, loaded=False) + + +def _control_service(service_name: str, action: str) -> None: + system = platform.system() + if system == "Linux": + _control_systemd_service(service_name, action) + return + + if system == "Darwin": + match action: + case "start": + _start_launch_agent(service_name) + case "stop": + _stop_launch_agent(service_name) + case "restart": + _stop_launch_agent(service_name, allow_missing=True) + _start_launch_agent(service_name) + case _: + raise click.ClickException(f"Unsupported launchd action: {action}") + return + + raise click.ClickException(f"Unsupported platform: {system}") + + +def _uninstall_systemd_service(service_name: str) -> Path: + unit_path = _systemd_unit_path(service_name) + if not unit_path.exists(): + raise click.ClickException(f"{unit_path} does not exist") + + if shutil.which("systemctl") is not None: + subprocess.run( + [ + "systemctl", + "--user", + "disable", + "--now", + _systemd_unit_name(service_name), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + unit_path.unlink() + if shutil.which("systemctl") is not None: + _run_checked( + ["systemctl", "--user", "daemon-reload"], + "Failed to reload the systemd user daemon", + ) + return unit_path + + +def _uninstall_launch_agent(service_name: str) -> Path: + plist_path = _launch_agent_path(service_name) + if not plist_path.exists(): + raise click.ClickException(f"{plist_path} does not exist") + + _stop_launch_agent(service_name, allow_missing=True) + plist_path.unlink() + return plist_path + + +def _uninstall_service(service_name: str) -> Path: + system = platform.system() + if system == "Linux": + return _uninstall_systemd_service(service_name) + if system == "Darwin": + return _uninstall_launch_agent(service_name) + raise click.ClickException(f"Unsupported platform: {system}") + + +def _resolve_data_path(astrbot_root: Path, configured_path: str | None) -> Path: + if not configured_path: + configured_path = "logs/astrbot.log" + + path = Path(configured_path).expanduser() + if path.is_absolute(): + return path + return astrbot_root / "data" / path + + +def _resolve_app_log_path(astrbot_root: Path) -> Path: + config_path = astrbot_root / "data" / "cmd_config.json" + if not config_path.exists(): + return _resolve_data_path(astrbot_root, None) + + try: + config = json.loads(config_path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError): + return _resolve_data_path(astrbot_root, None) + + if "log_file" in config: + log_file_config = config.get("log_file") or {} + return _resolve_data_path(astrbot_root, log_file_config.get("path")) + + return _resolve_data_path(astrbot_root, config.get("log_file_path")) + + +def _get_config_path(astrbot_root: Path) -> Path: + return astrbot_root / "data" / "cmd_config.json" + + +def _load_or_init_config(astrbot_root: Path) -> dict: + config_path = _get_config_path(astrbot_root) + if not config_path.exists(): + from astrbot.core.config.default import DEFAULT_CONFIG + + return copy.deepcopy(DEFAULT_CONFIG) + + try: + return json.loads(config_path.read_text(encoding="utf-8-sig")) + except json.JSONDecodeError as e: + raise click.ClickException(f"Failed to parse config file: {e}") + + +def _save_config(astrbot_root: Path, config: dict) -> None: + config_path = _get_config_path(astrbot_root) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps(config, ensure_ascii=False, indent=2), + encoding="utf-8-sig", + ) + + +def _get_app_log_config(astrbot_root: Path, config: dict) -> AppLogConfig: + if isinstance(config.get("log_file"), dict): + log_file_config = config["log_file"] + configured_path = log_file_config.get("path") + return AppLogConfig( + enabled=bool(log_file_config.get("enable", False)), + path=_resolve_data_path(astrbot_root, configured_path), + configured_path=configured_path, + ) + + configured_path = config.get("log_file_path") + return AppLogConfig( + enabled=bool(config.get("log_file_enable", False)), + path=_resolve_data_path(astrbot_root, configured_path), + configured_path=configured_path, + ) + + +def _set_app_log_config( + config: dict, + *, + enabled: bool, + path: str | None = None, +) -> None: + if isinstance(config.get("log_file"), dict): + config["log_file"]["enable"] = enabled + if path is not None: + config["log_file"]["path"] = path + return + + config["log_file_enable"] = enabled + if path is not None: + config["log_file_path"] = path + + +def _read_last_lines(path: Path, lines: int) -> list[str]: + with path.open("r", encoding="utf-8", errors="replace") as f: + return list(deque(f, maxlen=lines)) + + +def _echo_log_line(line: str) -> None: + click.echo(line.rstrip("\r\n")) + + +def _show_log_files(paths: list[Path], lines: int, follow: bool) -> None: + existing_paths = [path for path in paths if path.exists()] + if not existing_paths: + joined_paths = ", ".join(str(path) for path in paths) + raise click.ClickException(f"No log files found: {joined_paths}") + + show_headers = len(existing_paths) > 1 + for index, path in enumerate(existing_paths): + if show_headers: + if index: + click.echo() + click.echo(f"==> {path} <==") + for line in _read_last_lines(path, lines): + _echo_log_line(line) + + if follow: + _follow_log_files(existing_paths) + + +def _follow_log_files(paths: list[Path]) -> None: + positions = {path: path.stat().st_size for path in paths if path.exists()} + click.echo("Following logs. Press Ctrl+C to stop.") + try: + while True: + for path in paths: + if not path.exists(): + continue + + current_size = path.stat().st_size + previous_position = positions.get(path, 0) + if current_size < previous_position: + previous_position = 0 + + with path.open("r", encoding="utf-8", errors="replace") as f: + f.seek(previous_position) + for line in f: + if len(paths) > 1: + click.echo(f"[{path.name}] ", nl=False) + _echo_log_line(line) + positions[path] = f.tell() + + time.sleep(1) + except KeyboardInterrupt: + return + + +def _run_passthrough(command: list[str], failure_message: str) -> None: + try: + result = subprocess.run(command, check=False) + except FileNotFoundError: + raise click.ClickException(f"Command not found: {command[0]}") + except KeyboardInterrupt: + return + + if result.returncode != 0: + raise click.ClickException(f"{failure_message}: exit code {result.returncode}") + + +def _show_journal_logs(service_name: str, lines: int, follow: bool) -> None: + command = [ + "journalctl", + "--user", + "-u", + _systemd_unit_name(service_name), + "-n", + str(lines), + "--no-pager", + ] + if follow: + command.append("-f") + _run_passthrough(command, "Failed to read systemd user service logs") + + +def _show_service_logs( + service_name: str, + lines: int, + follow: bool, + *, + include_stderr: bool, +) -> None: + system = platform.system() + if system == "Linux": + _show_journal_logs(service_name, lines, follow) + return + + if system == "Darwin": + out_log, err_log = _service_log_paths(service_name) + paths = [out_log] + if include_stderr: + paths.append(err_log) + _show_log_files(paths, lines, follow) + return + + raise click.ClickException(f"Unsupported platform: {system}") + + +@service.command(name="install") +@click.option( + "--name", + default=DEFAULT_SERVICE_NAME, + show_default=True, + help="Service name to install.", +) +@click.option( + "--workdir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="AstrBot root directory. Defaults to the current directory.", +) +@click.option( + "--executable", + type=str, + help="Path to the astrbot executable. Defaults to the executable found on PATH.", +) +@click.option("--force", is_flag=True, help="Overwrite an existing service definition.") +@click.option( + "--now", is_flag=True, help="Start or restart the service after installing it." +) +def install( + name: str, + workdir: Path | None, + executable: str | None, + force: bool, + now: bool, +) -> None: + """Install AstrBot as a user-level background service.""" + service_name = _validate_service_name(name) + system = platform.system() + if system not in {"Linux", "Darwin"}: + raise click.ClickException(f"Unsupported platform: {system}") + + astrbot_root = _resolve_workdir(workdir) + astrbot_executable = _resolve_astrbot_executable(executable) + + if system == "Linux": + service_path = _install_systemd_user_service( + service_name, + astrbot_executable, + astrbot_root, + force=force, + now=now, + ) + click.echo(f"Installed systemd user service: {service_path}") + click.echo(f"Manage it with: systemctl --user status {service_path.name}") + click.echo( + "To start it at boot before login, enable lingering with: " + f"loginctl enable-linger {getpass.getuser()}" + ) + return + + if system == "Darwin": + plist_path = _install_launch_agent( + service_name, + astrbot_executable, + astrbot_root, + force=force, + now=now, + ) + click.echo(f"Installed LaunchAgent: {plist_path}") + click.echo(f"LaunchAgent label: {_macos_label(service_name)}") + return + + raise click.ClickException(f"Unsupported platform: {system}") + + +@service.command(name="start") +@click.option( + "--name", + default=DEFAULT_SERVICE_NAME, + show_default=True, + help="Service name to start.", +) +def start(name: str) -> None: + """Start the installed background service.""" + service_name = _validate_service_name(name) + click.echo("Starting service...") + _control_service(service_name, "start") + click.echo(f"Started service: {service_name}") + + +@service.command(name="stop") +@click.option( + "--name", + default=DEFAULT_SERVICE_NAME, + show_default=True, + help="Service name to stop.", +) +def stop(name: str) -> None: + """Stop the installed background service.""" + service_name = _validate_service_name(name) + click.echo("Stopping service...") + _control_service(service_name, "stop") + click.echo(f"Stopped service: {service_name}") + + +@service.command(name="restart") +@click.option( + "--name", + default=DEFAULT_SERVICE_NAME, + show_default=True, + help="Service name to restart.", +) +def restart(name: str) -> None: + """Restart the installed background service.""" + service_name = _validate_service_name(name) + click.echo("Restarting service...") + _control_service(service_name, "restart") + click.echo(f"Restarted service: {service_name}") + + +@service.command(name="uninstall") +@click.option( + "--name", + default=DEFAULT_SERVICE_NAME, + show_default=True, + help="Service name to uninstall.", +) +@click.option("--force", is_flag=True, help="Do not ask for confirmation.") +def uninstall(name: str, force: bool) -> None: + """Remove the installed background service.""" + service_name = _validate_service_name(name) + click.echo("Uninstalling service...") + + if not force: + click.confirm( + f"Uninstall AstrBot service {service_name}?", + default=False, + abort=True, + ) + + removed = _uninstall_service(service_name) + click.echo(f"Uninstalled service: {removed}") + + +@service.group(name="logs", invoke_without_command=True) +@click.pass_context +@click.option( + "--name", + default=DEFAULT_SERVICE_NAME, + show_default=True, + help="Service name to read logs for.", +) +@click.option( + "--workdir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="AstrBot root directory. Required only with --source app.", +) +@click.option( + "--source", + type=click.Choice(["service", "app"]), + default="service", + show_default=True, + help="Read service manager output or AstrBot application log file.", +) +@click.option( + "--lines", + "-n", + default=DEFAULT_LOG_LINES, + show_default=True, + type=int, + help="Number of lines to show.", +) +@click.option("--follow", "-f", is_flag=True, help="Follow log output.") +@click.option( + "--include-stderr", + is_flag=True, + help="Also show stderr logs on macOS.", +) +def logs( + ctx: click.Context, + name: str, + workdir: Path | None, + source: str, + lines: int, + follow: bool, + include_stderr: bool, +) -> None: + """View service logs or configure the application log file.""" + if ctx.invoked_subcommand is not None: + return + + if lines <= 0: + raise click.ClickException("Lines must be greater than 0") + + service_name = _validate_service_name(name) + if source == "app": + astrbot_root = _resolve_workdir(workdir) + _show_log_files([_resolve_app_log_path(astrbot_root)], lines, follow) + return + + _show_service_logs(service_name, lines, follow, include_stderr=include_stderr) + + +@logs.command(name="status") +@click.option( + "--workdir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="AstrBot root directory. Defaults to the current directory.", +) +def logs_status(workdir: Path | None) -> None: + """Show application log file configuration.""" + astrbot_root = _resolve_workdir(workdir) + config = _load_or_init_config(astrbot_root) + log_config = _get_app_log_config(astrbot_root, config) + + click.echo("AstrBot application log file") + click.echo(f" Enabled: {_format_yes_no(log_config.enabled)}") + click.echo(f" Configured path: {log_config.configured_path or 'logs/astrbot.log'}") + click.echo(f" Resolved path: {log_config.path}") + click.echo(f" Exists: {_format_yes_no(log_config.path.exists())}") + + +@logs.command(name="enable") +@click.option( + "--workdir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="AstrBot root directory. Defaults to the current directory.", +) +@click.option( + "--path", + "log_path", + help="Log file path. Relative paths are resolved from the AstrBot data directory.", +) +def logs_enable(workdir: Path | None, log_path: str | None) -> None: + """Enable the AstrBot application log file.""" + astrbot_root = _resolve_workdir(workdir) + config = _load_or_init_config(astrbot_root) + _set_app_log_config(config, enabled=True, path=log_path) + _save_config(astrbot_root, config) + + log_config = _get_app_log_config(astrbot_root, config) + click.echo("Enabled AstrBot application log file.") + click.echo(f"Log path: {log_config.path}") + click.echo("Restart AstrBot for this change to take effect.") + + +@logs.command(name="disable") +@click.option( + "--workdir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="AstrBot root directory. Defaults to the current directory.", +) +def logs_disable(workdir: Path | None) -> None: + """Disable the AstrBot application log file.""" + astrbot_root = _resolve_workdir(workdir) + config = _load_or_init_config(astrbot_root) + _set_app_log_config(config, enabled=False) + _save_config(astrbot_root, config) + + click.echo("Disabled AstrBot application log file.") + click.echo("Restart AstrBot for this change to take effect.") + + +@service.command(name="status") +@click.option( + "--name", + default=DEFAULT_SERVICE_NAME, + show_default=True, + help="Service name to inspect.", +) +@click.option( + "--workdir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="AstrBot root directory. Defaults to the current directory.", +) +@click.option( + "--timeout", + default=DEFAULT_STATUS_TIMEOUT_SECONDS, + show_default=True, + type=float, + help="WebUI probe timeout in seconds.", +) +def status(name: str, workdir: Path | None, timeout: float) -> None: + """Check background service state, WebUI health, and port.""" + if timeout <= 0: + raise click.ClickException("Timeout must be greater than 0") + + service_name = _validate_service_name(name) + astrbot_root = _resolve_workdir(workdir) + service_state = _get_service_state(service_name) + dashboard_port = _load_dashboard_port(astrbot_root) + webui_status = _check_webui(dashboard_port.port, timeout) + health = _health_label(service_state, webui_status) + + click.echo("AstrBot service status") + click.echo(f" Health: {health}") + click.echo(f" Platform: {platform.system()}") + click.echo(f" Service name: {service_name}") + click.echo(f" Service manager: {service_state.manager}") + click.echo(f" Installed: {_format_yes_no(service_state.installed)}") + if service_state.path is not None: + click.echo(f" Definition: {service_state.path}") + click.echo(f" Service state: {service_state.state}") + if service_state.enabled is not None: + click.echo(f" Enabled: {service_state.enabled}") + if service_state.detail: + click.echo(f" Service detail: {service_state.detail}") + click.echo(f" AstrBot root: {astrbot_root}") + click.echo(f" Dashboard port: {dashboard_port.port}") + if dashboard_port.detail: + click.echo(f" Port detail: {dashboard_port.detail}") + click.echo(f" WebUI URL: {webui_status.url}") + click.echo(f" WebUI accessible: {_format_yes_no(webui_status.accessible)}") + if webui_status.status_code is not None: + click.echo(f" WebUI HTTP status: {webui_status.status_code}") + if webui_status.detail: + click.echo(f" WebUI detail: {webui_status.detail}") diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py index 4d62becb55..117489be60 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -15,6 +15,7 @@ ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json") DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD" +DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_DASHBOARD_RESET_PASSWORD" logger = logging.getLogger("astrbot") @@ -76,21 +77,21 @@ def __init__( ) # 检查配置完整性,并插入 has_new = self.check_config_integrity(default_config, conf) + dashboard_reset_requested = self._is_dashboard_password_reset_requested() if ( "dashboard" in conf and isinstance(conf["dashboard"], dict) - and not conf["dashboard"].get("pbkdf2_password") - and not conf["dashboard"].get("password") - ): - self._reset_generated_dashboard_password(conf) - has_new = True - elif ( - "dashboard" in conf - and isinstance(conf["dashboard"], dict) - and legacy_dashboard_password_change_required - and conf["dashboard"].get("pbkdf2_password") + and ( + dashboard_reset_requested + or ( + not conf["dashboard"].get("pbkdf2_password") + and not conf["dashboard"].get("password") + ) + ) ): self._reset_generated_dashboard_password(conf) + if dashboard_reset_requested: + os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "0" has_new = True self.update(conf) if has_new: @@ -127,6 +128,15 @@ def _resolve_initial_dashboard_password() -> str: validate_dashboard_password(env_password) return env_password + @staticmethod + def _is_dashboard_password_reset_requested() -> bool: + return os.environ.get(DASHBOARD_RESET_PASSWORD_ENV, "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + def _config_schema_to_default_config(self, schema: dict) -> dict: """将 Schema 转换成 Config""" conf = {} diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 458bc8dabe..f6590a4ac3 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -159,6 +159,7 @@ export default defineConfig({ base: "/use", items: [ { text: "WebUI", link: "/webui" }, + { text: "CLI 指令", link: "/cli" }, { text: "插件", link: "/plugin" }, { text: "内置指令", link: "/command" }, { text: "工具使用 Tools", link: "/function-calling" }, @@ -403,6 +404,7 @@ export default defineConfig({ collapsed: true, items: [ { text: "WebUI", link: "/webui" }, + { text: "CLI Commands", link: "/cli" }, { text: "Plugins", link: "/plugin" }, { text: "Built-in Commands", link: "/command" }, { text: "Tool Use", link: "/function-calling" }, diff --git a/docs/en/deploy/astrbot/package.md b/docs/en/deploy/astrbot/package.md index c921f209d9..36a7e84f10 100644 --- a/docs/en/deploy/astrbot/package.md +++ b/docs/en/deploy/astrbot/package.md @@ -20,5 +20,73 @@ AstrBot requires Python 3.12 or later. Use `--python 3.12` to ensure that `uv` c ```bash uv tool install astrbot --python 3.12 -astrbot +astrbot init # Only required for the first deployment +astrbot run +``` + +## Install as a System Service + +After initialization, install AstrBot as a user-level service so it starts with the user session: + +```bash +astrbot service install --now +``` + +The command uses the `astrbot` executable found on `PATH` (usually generated by `uv tool install`) and uses the current directory as the AstrBot working directory. Each platform uses its native user-level service mechanism: + +- Linux: `systemd --user` +- macOS: `LaunchAgent` + +> [!NOTE] +> `astrbot service` is not supported on Windows. Use `astrbot run` in the foreground or another process manager. + +To specify the AstrBot working directory or executable path explicitly: + +```bash +astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now +``` + +To inspect the service state and WebUI health: + +```bash +astrbot service status +``` + +The status output includes the service manager state, AstrBot working directory, Dashboard port, WebUI URL, WebUI accessibility, and the overall health state. + +You can also manage the service lifecycle with: + +```bash +astrbot service start +astrbot service stop +astrbot service restart +astrbot service uninstall +``` + +To view service logs: + +```bash +astrbot service logs +astrbot service logs -f +``` + +On macOS, this shows stdout logs by default. To include stderr: + +```bash +astrbot service logs --include-stderr +``` + +To read the AstrBot application log file at `data/logs/astrbot.log`, enable application file logging first and restart the service: + +```bash +astrbot service logs enable +astrbot service restart +astrbot service logs --source app +``` + +To inspect or disable application file logging: + +```bash +astrbot service logs status +astrbot service logs disable ``` diff --git a/docs/en/use/cli.md b/docs/en/use/cli.md new file mode 100644 index 0000000000..6f0a7a2524 --- /dev/null +++ b/docs/en/use/cli.md @@ -0,0 +1,311 @@ +# CLI Commands + +The AstrBot CLI initializes instances, starts AstrBot, installs background services, reads logs, updates common config values, and manages plugins. + +If you install AstrBot with `uv`: + +```bash +uv tool install astrbot --python 3.12 +``` + +`uv` creates the `astrbot` executable and puts it on `PATH`. You can inspect the path with: + +::: code-group + +```bash [Linux / macOS] +which astrbot +``` + +```powershell [Windows] +where.exe astrbot +``` + +::: + +> [!TIP] +> Run the commands below from the AstrBot working directory unless the command provides a `--workdir` option. + +## Quick Start + +Initialize the directory once, then start AstrBot: + +```bash +astrbot init +astrbot run +``` + +`astrbot init` creates the data directories and configuration files required by AstrBot. After initialization, use `astrbot run` for later starts. + +## Top-Level Commands + +| Command | Purpose | +| --- | --- | +| `astrbot init` | Initialize the current directory as an AstrBot working directory. | +| `astrbot run` | Start AstrBot in the foreground. | +| `astrbot service` | Install and manage AstrBot as a background service. | +| `astrbot config` | Read or update common config values. | +| `astrbot password` | Change the WebUI login password interactively. | +| `astrbot plugin` | Create, install, update, remove, or search plugins. | +| `astrbot help` | Show CLI help. | +| `astrbot --version` | Show the AstrBot CLI version. | + +`conf` and `plug` are compatibility aliases and still work: + +```bash +astrbot conf get +astrbot plug list +``` + +Prefer `config` and `plugin` in new docs and scripts. + +## Start AstrBot + +```bash +astrbot run +``` + +Common options: + +| Option | Purpose | +| --- | --- | +| `-p, --port ` | Set the WebUI port. | +| `-r, --reload` | Enable plugin auto-reload for plugin development. | +| `--reset-password` | Reset the WebUI initial password on startup and print the new initial password in startup logs. | + +Examples: + +```bash +astrbot run --port 6185 +astrbot run --reload +astrbot run --reset-password +``` + +## Background Service + +`astrbot service` installs AstrBot as a user-level background service for long-running deployments. + +Each platform uses its native service manager: + +| Platform | Service manager | +| --- | --- | +| Linux | `systemd --user` | +| macOS | LaunchAgent | + +> [!NOTE] +> `astrbot service` is not supported on Windows. Use `astrbot run` in the foreground or another process manager. + +### Install + +```bash +astrbot service install --now +``` + +By default, this command uses the `astrbot` executable found on `PATH` and the current directory as the AstrBot working directory. `--now` starts or restarts the service after installation. + +Common options: + +| Option | Purpose | +| --- | --- | +| `--name ` | Service name. Default: `astrbot`. | +| `--workdir ` | AstrBot working directory. | +| `--executable ` | Path to the `astrbot` executable. | +| `--force` | Overwrite an existing service definition. | +| `--now` | Start or restart the service after installation. | + +If `astrbot` is not on `PATH`, pass the executable explicitly: + +```bash +astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now +``` + +### Manage + +```bash +astrbot service start +astrbot service stop +astrbot service restart +astrbot service uninstall +``` + +These commands support `--name ` for non-default service names: + +```bash +astrbot service restart --name astrbot-test +``` + +To remove a service without an interactive confirmation: + +```bash +astrbot service uninstall --force +``` + +### Status + +```bash +astrbot service status +``` + +The status output includes: + +- Overall health. +- Current platform and service manager. +- Whether the service is installed, enabled, and running. +- AstrBot working directory. +- Dashboard port. +- WebUI URL and accessibility. + +Common options: + +| Option | Purpose | +| --- | --- | +| `--name ` | Service name. Default: `astrbot`. | +| `--workdir ` | AstrBot working directory used to read the port config. | +| `--timeout ` | WebUI health probe timeout. Default: 2 seconds. | + +Example: + +```bash +astrbot service status --timeout 5 +``` + +## Logs + +The CLI exposes two kinds of logs: + +| Type | Command | Notes | +| --- | --- | --- | +| Service logs | `astrbot service logs` | Reads console output captured by the service manager. | +| Application log file | `astrbot service logs --source app` | Reads `data/logs/astrbot.log`; file logging must be enabled first. | + +### Service Logs + +```bash +astrbot service logs +astrbot service logs -n 100 +astrbot service logs -f +``` + +Common options: + +| Option | Purpose | +| --- | --- | +| `--name ` | Service name. | +| `-n, --lines ` | Show the latest N lines. Default: 200. | +| `-f, --follow` | Follow log output. | +| `--include-stderr` | Also show stderr logs on macOS. | + +On macOS, `astrbot service logs` shows stdout logs by default, which are the `.out.log` files. Add `--include-stderr` when you also need error output. + +### Application Log File + +`data/logs/astrbot.log` is not written by default. Enable application file logging first, then restart AstrBot: + +```bash +astrbot service logs enable +astrbot service restart +astrbot service logs --source app +``` + +Inspect the application log file configuration: + +```bash +astrbot service logs status +``` + +Disable the application log file: + +```bash +astrbot service logs disable +astrbot service restart +``` + +Use a custom application log path: + +```bash +astrbot service logs enable --path logs/astrbot.log +``` + +Relative paths are resolved from the AstrBot data directory. + +## Config + +`astrbot config` reads and updates common config values. + +```bash +astrbot config get +astrbot config get dashboard.port +astrbot config set dashboard.port 6185 +``` + +Supported keys: + +| Key | Description | +| --- | --- | +| `timezone` | Time zone, for example `Asia/Shanghai`. | +| `log_level` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL`. | +| `dashboard.port` | WebUI port. | +| `dashboard.username` | WebUI username. | +| `dashboard.password` | WebUI password. | +| `callback_api_base` | Callback API base URL. Must start with `http://` or `https://`. | + +Changing the dashboard password writes the current password hashes automatically: + +```bash +astrbot config set dashboard.password "new-password" +``` + +You can also use the dedicated interactive password command: + +```bash +astrbot password +astrbot password --username admin +``` + +## Plugins + +`astrbot plugin` manages plugins under `data/plugins`. + +| Command | Purpose | +| --- | --- | +| `astrbot plugin list` | List installed plugins. | +| `astrbot plugin list --all` | Also show uninstalled plugins. | +| `astrbot plugin search ` | Search plugins. | +| `astrbot plugin install ` | Install a plugin. | +| `astrbot plugin update [NAME]` | Update one plugin, or all updatable plugins if no name is given. | +| `astrbot plugin remove ` | Remove an installed plugin. | +| `astrbot plugin new ` | Create a new plugin from the template. | + +Use a GitHub proxy when installing or updating plugins: + +```bash +astrbot plugin install example-plugin --proxy https://gh-proxy.example.com/ +astrbot plugin update --proxy https://gh-proxy.example.com/ +``` + +Creating a new plugin asks for the author, description, version, and repository URL: + +```bash +astrbot plugin new my-plugin +``` + +## Help + +Show general CLI help: + +```bash +astrbot help +``` + +Show help for a specific command: + +```bash +astrbot help service +astrbot service --help +astrbot service logs --help +``` + +Show the version: + +```bash +astrbot --version +``` diff --git a/docs/zh/deploy/astrbot/package.md b/docs/zh/deploy/astrbot/package.md index 96a50f55a3..b841609950 100644 --- a/docs/zh/deploy/astrbot/package.md +++ b/docs/zh/deploy/astrbot/package.md @@ -22,3 +22,70 @@ uv tool install astrbot --python 3.12 astrbot init # 只需要在第一次部署时执行,后续启动不需要执行 astrbot run ``` + +## 安装为系统服务 + +初始化完成后,可以安装用户级服务,让 AstrBot 随用户会话自动启动: + +```bash +astrbot service install --now +``` + +该命令会自动使用当前 `PATH` 中的 `astrbot` 可执行文件(通常由 `uv tool install` 生成),并将当前目录作为 AstrBot 工作目录。不同系统会使用对应的用户级服务机制: + +- Linux:`systemd --user` +- macOS:`LaunchAgent` + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。 + +如果需要指定 AstrBot 工作目录或可执行文件路径,可以使用: + +```bash +astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now +``` + +查看服务状态和 WebUI 健康状态: + +```bash +astrbot service status +``` + +状态输出会包含服务管理器状态、AstrBot 工作目录、Dashboard 端口、WebUI URL、WebUI 是否可访问,以及整体健康状态。 + +也可以使用以下命令管理服务生命周期: + +```bash +astrbot service start +astrbot service stop +astrbot service restart +astrbot service uninstall +``` + +查看服务日志: + +```bash +astrbot service logs +astrbot service logs -f +``` + +macOS 下默认只显示标准输出日志;如需同时查看 stderr: + +```bash +astrbot service logs --include-stderr +``` + +如果需要查看 AstrBot 应用日志文件 `data/logs/astrbot.log`,先启用应用日志文件并重启服务: + +```bash +astrbot service logs enable +astrbot service restart +astrbot service logs --source app +``` + +查看或关闭应用日志文件: + +```bash +astrbot service logs status +astrbot service logs disable +``` diff --git a/docs/zh/use/cli.md b/docs/zh/use/cli.md new file mode 100644 index 0000000000..adb2837a02 --- /dev/null +++ b/docs/zh/use/cli.md @@ -0,0 +1,311 @@ +# CLI 指令 + +AstrBot CLI 用于初始化实例、启动 AstrBot、安装后台服务、查看日志、修改常用配置和管理插件。 + +如果你使用 `uv` 安装: + +```bash +uv tool install astrbot --python 3.12 +``` + +`uv` 会生成 `astrbot` 可执行文件,并把它放到 `PATH` 中。可以用下面的命令确认路径: + +::: code-group + +```bash [Linux / macOS] +which astrbot +``` + +```powershell [Windows] +where.exe astrbot +``` + +::: + +> [!TIP] +> 下面的命令都需要在 AstrBot 工作目录中执行,除非命令提供了 `--workdir` 选项。 + +## 快速开始 + +第一次部署时先初始化目录,再启动 AstrBot: + +```bash +astrbot init +astrbot run +``` + +`astrbot init` 会在当前目录创建 AstrBot 所需的数据目录和配置文件。初始化完成后,后续启动只需要执行 `astrbot run`。 + +## 顶层指令 + +| 指令 | 用途 | +| --- | --- | +| `astrbot init` | 初始化当前目录为 AstrBot 工作目录。 | +| `astrbot run` | 在前台启动 AstrBot。 | +| `astrbot service` | 安装和管理 AstrBot 后台服务。 | +| `astrbot config` | 查看或修改常用配置项。 | +| `astrbot password` | 交互式修改 WebUI 登录密码。 | +| `astrbot plugin` | 创建、安装、更新、删除或搜索插件。 | +| `astrbot help` | 查看 CLI 帮助。 | +| `astrbot --version` | 查看 AstrBot CLI 版本。 | + +`conf` 和 `plug` 是兼容别名,仍然可用: + +```bash +astrbot conf get +astrbot plug list +``` + +推荐在新文档和脚本中使用 `config` 和 `plugin`。 + +## 启动 AstrBot + +```bash +astrbot run +``` + +常用选项: + +| 选项 | 用途 | +| --- | --- | +| `-p, --port ` | 指定 WebUI 端口。 | +| `-r, --reload` | 启用插件自动重载,适合插件开发调试。 | +| `--reset-password` | 启动时重置 WebUI 初始密码,并在启动日志中打印新的初始密码。 | + +示例: + +```bash +astrbot run --port 6185 +astrbot run --reload +astrbot run --reset-password +``` + +## 后台服务 + +`astrbot service` 可以把 AstrBot 安装为用户级后台服务,适合长期运行。 + +不同系统会使用对应的服务管理机制: + +| 系统 | 服务管理器 | +| --- | --- | +| Linux | `systemd --user` | +| macOS | LaunchAgent | + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。 + +### 安装服务 + +```bash +astrbot service install --now +``` + +该命令默认使用当前 `PATH` 中的 `astrbot` 可执行文件,并把当前目录作为 AstrBot 工作目录。`--now` 表示安装后立即启动或重启服务。 + +常用选项: + +| 选项 | 用途 | +| --- | --- | +| `--name ` | 指定服务名,默认 `astrbot`。 | +| `--workdir ` | 指定 AstrBot 工作目录。 | +| `--executable ` | 指定 `astrbot` 可执行文件路径。 | +| `--force` | 覆盖已有服务定义。 | +| `--now` | 安装后立即启动或重启服务。 | + +如果 `astrbot` 不在 `PATH` 中,可以显式指定可执行文件: + +```bash +astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now +``` + +### 管理服务 + +```bash +astrbot service start +astrbot service stop +astrbot service restart +astrbot service uninstall +``` + +这些命令都支持 `--name `,用于管理非默认服务名: + +```bash +astrbot service restart --name astrbot-test +``` + +卸载服务时,如果不希望交互确认,可以使用: + +```bash +astrbot service uninstall --force +``` + +### 查看服务状态 + +```bash +astrbot service status +``` + +状态输出会包含: + +- 整体健康状态。 +- 当前系统和服务管理器。 +- 服务是否已安装、是否启用、当前运行状态。 +- AstrBot 工作目录。 +- Dashboard 端口。 +- WebUI URL 和是否可访问。 + +常用选项: + +| 选项 | 用途 | +| --- | --- | +| `--name ` | 指定服务名,默认 `astrbot`。 | +| `--workdir ` | 指定 AstrBot 工作目录,用于读取端口配置。 | +| `--timeout ` | 指定 WebUI 健康检查超时时间,默认 2 秒。 | + +示例: + +```bash +astrbot service status --timeout 5 +``` + +## 日志 + +AstrBot CLI 中有两类日志: + +| 类型 | 命令 | 说明 | +| --- | --- | --- | +| 服务日志 | `astrbot service logs` | 查看服务管理器捕获的控制台输出。 | +| 应用日志文件 | `astrbot service logs --source app` | 查看 `data/logs/astrbot.log`,需要先启用文件日志。 | + +### 查看服务日志 + +```bash +astrbot service logs +astrbot service logs -n 100 +astrbot service logs -f +``` + +常用选项: + +| 选项 | 用途 | +| --- | --- | +| `--name ` | 指定服务名。 | +| `-n, --lines ` | 显示最近 N 行,默认 200。 | +| `-f, --follow` | 持续跟随日志输出。 | +| `--include-stderr` | 在 macOS 上同时显示 stderr 日志。 | + +macOS 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 + +### 启用应用日志文件 + +`data/logs/astrbot.log` 默认不会写入。需要先启用应用日志文件,然后重启 AstrBot: + +```bash +astrbot service logs enable +astrbot service restart +astrbot service logs --source app +``` + +查看应用日志文件配置: + +```bash +astrbot service logs status +``` + +关闭应用日志文件: + +```bash +astrbot service logs disable +astrbot service restart +``` + +自定义应用日志文件路径: + +```bash +astrbot service logs enable --path logs/astrbot.log +``` + +相对路径会以 AstrBot 数据目录为基准解析。 + +## 配置 + +`astrbot config` 用于查看和修改常用配置项。 + +```bash +astrbot config get +astrbot config get dashboard.port +astrbot config set dashboard.port 6185 +``` + +支持的配置项: + +| 配置项 | 说明 | +| --- | --- | +| `timezone` | 时区,例如 `Asia/Shanghai`。 | +| `log_level` | 日志等级:`DEBUG`、`INFO`、`WARNING`、`ERROR`、`CRITICAL`。 | +| `dashboard.port` | WebUI 端口。 | +| `dashboard.username` | WebUI 用户名。 | +| `dashboard.password` | WebUI 密码。 | +| `callback_api_base` | 回调 API 基础地址,需要以 `http://` 或 `https://` 开头。 | + +修改密码时会自动写入新版密码哈希: + +```bash +astrbot config set dashboard.password "new-password" +``` + +也可以使用专门的交互式密码指令: + +```bash +astrbot password +astrbot password --username admin +``` + +## 插件 + +`astrbot plugin` 用于管理 `data/plugins` 下的插件。 + +| 指令 | 用途 | +| --- | --- | +| `astrbot plugin list` | 查看已安装插件。 | +| `astrbot plugin list --all` | 同时显示未安装插件。 | +| `astrbot plugin search ` | 搜索插件。 | +| `astrbot plugin install ` | 安装插件。 | +| `astrbot plugin update [NAME]` | 更新指定插件;不传名称时更新所有可更新插件。 | +| `astrbot plugin remove ` | 删除已安装插件。 | +| `astrbot plugin new ` | 基于模板创建新插件。 | + +安装或更新插件时可以使用 GitHub 代理: + +```bash +astrbot plugin install example-plugin --proxy https://gh-proxy.example.com/ +astrbot plugin update --proxy https://gh-proxy.example.com/ +``` + +创建新插件会交互式询问作者、描述、版本和仓库地址: + +```bash +astrbot plugin new my-plugin +``` + +## 帮助 + +查看全部 CLI 帮助: + +```bash +astrbot help +``` + +查看指定指令帮助: + +```bash +astrbot help service +astrbot service --help +astrbot service logs --help +``` + +查看版本: + +```bash +astrbot --version +``` diff --git a/main.py b/main.py index 781f006377..c620f1af39 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,16 @@ runtime_bootstrap.initialize_runtime_bootstrap() +DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_DASHBOARD_RESET_PASSWORD" + + +def _prime_startup_flags(argv: list[str]) -> None: + if "--reset-password" in argv: + os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1" + + +_prime_startup_flags(sys.argv[1:]) + from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402 from astrbot.core.config.default import VERSION # noqa: E402 from astrbot.core.initial_loader import InitialLoader # noqa: E402 @@ -140,6 +150,11 @@ async def main_async(webui_dir_arg: str | None) -> None: help="Specify the directory path for WebUI static files", default=None, ) + parser.add_argument( + "--reset-password", + action="store_true", + help="Force reset the dashboard initial password on startup.", + ) args = parser.parse_args() check_env() diff --git a/tests/test_cli_command_aliases.py b/tests/test_cli_command_aliases.py new file mode 100644 index 0000000000..998219e552 --- /dev/null +++ b/tests/test_cli_command_aliases.py @@ -0,0 +1,25 @@ +from click.testing import CliRunner + +from astrbot.cli.__main__ import cli + + +def test_top_level_help_uses_product_command_names(): + result = CliRunner().invoke(cli, ["help"]) + + assert result.exit_code == 0 + assert "config" in result.output + assert "plugin" in result.output + assert " conf " not in result.output + assert " plug " not in result.output + + +def test_legacy_config_and_plugin_aliases_still_work(): + runner = CliRunner() + + config_result = runner.invoke(cli, ["help", "conf"]) + plugin_result = runner.invoke(cli, ["help", "plug"]) + + assert config_result.exit_code == 0 + assert "Configuration management commands" in config_result.output + assert plugin_result.exit_code == 0 + assert "Plugin management" in plugin_result.output diff --git a/tests/test_cli_run.py b/tests/test_cli_run.py new file mode 100644 index 0000000000..ff6c26a810 --- /dev/null +++ b/tests/test_cli_run.py @@ -0,0 +1,22 @@ +import os + +from click.testing import CliRunner + +from astrbot.cli.commands import cmd_run + + +def test_run_reset_password_sets_startup_env(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv(cmd_run.DASHBOARD_RESET_PASSWORD_ENV, raising=False) + (tmp_path / ".astrbot").touch() + observed_reset_flags = [] + + async def fake_run_astrbot(_astrbot_root): + observed_reset_flags.append(os.environ.get(cmd_run.DASHBOARD_RESET_PASSWORD_ENV)) + + monkeypatch.setattr(cmd_run, "run_astrbot", fake_run_astrbot) + + result = CliRunner().invoke(cmd_run.run, ["--reset-password"]) + + assert result.exit_code == 0 + assert observed_reset_flags == ["1"] diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py new file mode 100644 index 0000000000..8668447d1b --- /dev/null +++ b/tests/test_cli_service.py @@ -0,0 +1,313 @@ +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from threading import Thread + +from click.testing import CliRunner + +from astrbot.cli.__main__ import cli +from astrbot.cli.commands import cmd_service +from astrbot.cli.commands.cmd_service import ( + ServiceState, + WebUIStatus, + _build_launchd_plist, + _build_systemd_unit, + _check_webui, + _get_app_log_config, + _health_label, + _load_dashboard_port, + _load_or_init_config, + service, +) + + +class _HealthyHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *_args): + return + + +def test_service_command_is_registered(): + result = CliRunner().invoke(cli, ["help", "service"]) + + assert result.exit_code == 0 + assert "install" in result.output + assert "logs" in result.output + assert "restart" in result.output + assert "status" in result.output + assert "start" in result.output + assert "stop" in result.output + assert "uninstall" in result.output + + +def test_service_logs_group_exposes_log_file_controls(): + result = CliRunner().invoke(service, ["logs", "--help"]) + + assert result.exit_code == 0 + assert "enable" in result.output + assert "disable" in result.output + assert "status" in result.output + + +def test_service_install_requires_initialized_root(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(service, ["install", "--executable", "astrbot"]) + + assert result.exit_code == 1 + assert "Use 'astrbot init' before installing the service" in result.output + + +def test_service_install_rejects_windows(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cmd_service.platform, "system", lambda: "Windows") + + result = CliRunner().invoke(service, ["install", "--executable", "astrbot"]) + + assert result.exit_code == 1 + assert "Unsupported platform: Windows" in result.output + + +def test_systemd_unit_uses_astrbot_executable_and_working_directory(): + unit = _build_systemd_unit( + "astrbot", + Path("/home/astrbot/.local/bin/astrbot"), + Path("/home/astrbot/AstrBot Root"), + ) + + assert 'WorkingDirectory="/home/astrbot/AstrBot Root"' in unit + assert "ExecStart=/home/astrbot/.local/bin/astrbot run" in unit + assert "Environment=PYTHONUNBUFFERED=1" in unit + + +def test_launchd_plist_uses_astrbot_executable_and_working_directory(): + plist = _build_launchd_plist( + "astrbot", + Path("/Users/astrbot/.local/bin/astrbot"), + Path("/Users/astrbot/AstrBot"), + Path("/Users/astrbot/Library/Logs/AstrBot"), + ) + + assert plist["Label"] == "app.astrbot.astrbot" + assert plist["ProgramArguments"] == ["/Users/astrbot/.local/bin/astrbot", "run"] + assert plist["WorkingDirectory"] == "/Users/astrbot/AstrBot" + assert plist["EnvironmentVariables"] == {"PYTHONUNBUFFERED": "1"} + + +def test_launch_agent_start_waits_until_loaded_before_kickstart(monkeypatch, tmp_path): + plist_path = tmp_path / "app.astrbot.astrbot.plist" + plist_path.touch() + events = [] + loaded_states = [False, False, True] + + monkeypatch.setattr(cmd_service.shutil, "which", lambda name: "/bin/launchctl") + monkeypatch.setattr(cmd_service, "_launch_agent_path", lambda _name: plist_path) + monkeypatch.setattr(cmd_service.time, "sleep", lambda _seconds: None) + + def fake_run_capture(command): + if command[1] == "print": + events.append("print") + loaded = loaded_states.pop(0) if loaded_states else True + return cmd_service.subprocess.CompletedProcess( + command, + 0 if loaded else 113, + stdout="", + stderr="not loaded", + ) + if command[1] == "kickstart": + events.append("kickstart") + return cmd_service.subprocess.CompletedProcess(command, 0) + raise AssertionError(f"Unexpected capture command: {command}") + + def fake_run_checked(command, _failure_message): + events.append(command[1]) + + monkeypatch.setattr(cmd_service, "_run_capture", fake_run_capture) + monkeypatch.setattr(cmd_service, "_run_checked", fake_run_checked) + + cmd_service._start_launch_agent("astrbot") + + assert "bootstrap" in events + assert "enable" in events + assert "kickstart" in events + assert events.index("bootstrap") < events.index("kickstart") + + +def test_load_dashboard_port_reads_cmd_config(tmp_path): + config_path = tmp_path / "data" / "cmd_config.json" + config_path.parent.mkdir() + config_path.write_text( + json.dumps({"dashboard": {"port": 7788}}), + encoding="utf-8-sig", + ) + + dashboard_port = _load_dashboard_port(tmp_path) + + assert dashboard_port.port == 7788 + assert dashboard_port.detail is None + + +def test_check_webui_reports_accessible_http_response(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _HealthyHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + webui_status = _check_webui(server.server_port, timeout=1.0) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1) + + assert webui_status.accessible is True + assert webui_status.status_code == 200 + + +def test_health_label_requires_service_and_webui(): + active = ServiceState(manager="systemd --user", installed=True, state="active") + inactive = ServiceState(manager="systemd --user", installed=True, state="inactive") + reachable = WebUIStatus(url="http://127.0.0.1:6185/", accessible=True) + unreachable = WebUIStatus(url="http://127.0.0.1:6185/", accessible=False) + + assert _health_label(active, reachable) == "healthy" + assert _health_label(active, unreachable) == "degraded" + assert _health_label(inactive, reachable) == "degraded" + assert _health_label(inactive, unreachable) == "unhealthy" + + +def test_service_status_reports_port_and_webui_health(monkeypatch, tmp_path): + (tmp_path / ".astrbot").touch() + config_path = tmp_path / "data" / "cmd_config.json" + config_path.parent.mkdir() + config_path.write_text( + json.dumps({"dashboard": {"port": 7788}}), + encoding="utf-8-sig", + ) + + monkeypatch.setattr( + cmd_service, + "_get_service_state", + lambda _name: ServiceState( + manager="systemd --user", + installed=True, + state="active", + ), + ) + monkeypatch.setattr( + cmd_service, + "_check_webui", + lambda port, _timeout: WebUIStatus( + url=f"http://127.0.0.1:{port}/", + accessible=True, + status_code=200, + detail="HTTP 200", + ), + ) + + result = CliRunner().invoke(service, ["status", "--workdir", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Health: healthy" in result.output + assert "Dashboard port: 7788" in result.output + assert "WebUI accessible: yes" in result.output + assert "WebUI HTTP status: 200" in result.output + + +def test_service_start_dispatches_to_platform_control(monkeypatch): + calls = [] + monkeypatch.setattr( + cmd_service, + "_control_service", + lambda name, action: calls.append((name, action)), + ) + + result = CliRunner().invoke(service, ["start", "--name", "astrbot-test"]) + + assert result.exit_code == 0 + assert calls == [("astrbot-test", "start")] + assert "Started service: astrbot-test" in result.output + + +def test_service_uninstall_requires_confirmation(monkeypatch): + calls = [] + monkeypatch.setattr( + cmd_service, + "_uninstall_service", + lambda name: calls.append(name) or name, + ) + + result = CliRunner().invoke(service, ["uninstall"], input="n\n") + + assert result.exit_code == 1 + assert calls == [] + + +def test_service_logs_source_app_reads_application_log(monkeypatch, tmp_path): + (tmp_path / ".astrbot").touch() + log_path = tmp_path / "data" / "logs" / "astrbot.log" + log_path.parent.mkdir(parents=True) + log_path.write_text("first\nsecond\nthird\n", encoding="utf-8") + + result = CliRunner().invoke( + service, + ["logs", "--source", "app", "--workdir", str(tmp_path), "--lines", "2"], + ) + + assert result.exit_code == 0 + assert "first" not in result.output + assert "second" in result.output + assert "third" in result.output + + +def test_service_logs_hides_stderr_by_default(monkeypatch, tmp_path): + out_log = tmp_path / "astrbot.out.log" + err_log = tmp_path / "astrbot.err.log" + out_log.write_text("normal output\n", encoding="utf-8") + err_log.write_text("stderr output\n", encoding="utf-8") + + monkeypatch.setattr(cmd_service.platform, "system", lambda: "Darwin") + monkeypatch.setattr( + cmd_service, + "_service_log_paths", + lambda _name: (out_log, err_log), + ) + + default_result = CliRunner().invoke(service, ["logs", "--lines", "10"]) + stderr_result = CliRunner().invoke( + service, + ["logs", "--lines", "10", "--include-stderr"], + ) + + assert default_result.exit_code == 0 + assert "normal output" in default_result.output + assert "stderr output" not in default_result.output + assert stderr_result.exit_code == 0 + assert "normal output" in stderr_result.output + assert "stderr output" in stderr_result.output + + +def test_service_app_log_enable_updates_config(tmp_path): + (tmp_path / ".astrbot").touch() + + result = CliRunner().invoke( + service, + [ + "logs", + "enable", + "--workdir", + str(tmp_path), + "--path", + "logs/custom.log", + ], + ) + + assert result.exit_code == 0 + config = _load_or_init_config(tmp_path) + log_config = _get_app_log_config(tmp_path, config) + assert log_config.enabled is True + assert log_config.configured_path == "logs/custom.log" + assert log_config.path == tmp_path / "data" / "logs" / "custom.log" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 7afe82ebed..f168c0a4f7 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -10,6 +10,8 @@ from astrbot.core.config.i18n_utils import ConfigMetadataI18n from astrbot.core.utils.auth_password import ( DEFAULT_DASHBOARD_PASSWORD, + hash_dashboard_password, + hash_legacy_dashboard_password, validate_dashboard_password, verify_dashboard_password, ) @@ -276,15 +278,20 @@ def test_initial_dashboard_password_env_must_be_valid( default_config=default_config, ) - def test_legacy_password_change_required_rotates_and_keeps_config_flag( + def test_password_change_required_keeps_existing_password( self, temp_config_path ): - """Test that the setup flag stays in dashboard config.""" + """Test that the setup flag no longer rotates the initial password.""" + existing_password = "ExistingPass123" + existing_pbkdf2_password = hash_dashboard_password(existing_password) + existing_legacy_password = hash_legacy_dashboard_password(existing_password) default_config = { "dashboard": { "username": "astrbot", "password": "", "pbkdf2_password": "", + "password_storage_upgraded": False, + "password_change_required": False, }, } with open(temp_config_path, "w", encoding="utf-8") as f: @@ -292,8 +299,9 @@ def test_legacy_password_change_required_rotates_and_keeps_config_flag( { "dashboard": { "username": "astrbot", - "password": "", - "pbkdf2_password": "pbkdf2_sha256$600000$00$00", + "password": existing_legacy_password, + "pbkdf2_password": existing_pbkdf2_password, + "password_storage_upgraded": True, "password_change_required": True, } }, @@ -306,7 +314,7 @@ def test_legacy_password_change_required_rotates_and_keeps_config_flag( ) generated_password = getattr(config, "_generated_dashboard_password", None) - assert isinstance(generated_password, str) + assert generated_password is None assert config["dashboard"]["password_change_required"] is True assert config["dashboard"]["password_storage_upgraded"] is True assert ( @@ -314,12 +322,60 @@ def test_legacy_password_change_required_rotates_and_keeps_config_flag( is True ) assert verify_dashboard_password( - config["dashboard"]["pbkdf2_password"], generated_password + config["dashboard"]["pbkdf2_password"], existing_password ) assert verify_dashboard_password( - config["dashboard"]["password"], generated_password + config["dashboard"]["password"], existing_password ) + def test_reset_password_env_rotates_existing_password( + self, temp_config_path, monkeypatch + ): + """Test that explicit reset rotates dashboard password on startup.""" + existing_password = "ExistingPass123" + reset_password = "ResetPass123" + monkeypatch.setenv("ASTRBOT_DASHBOARD_RESET_PASSWORD", "1") + monkeypatch.setenv("ASTRBOT_DASHBOARD_INITIAL_PASSWORD", reset_password) + default_config = { + "dashboard": { + "username": "astrbot", + "password": "", + "pbkdf2_password": "", + "password_storage_upgraded": False, + "password_change_required": False, + }, + } + with open(temp_config_path, "w", encoding="utf-8") as f: + json.dump( + { + "dashboard": { + "username": "astrbot", + "password": hash_legacy_dashboard_password(existing_password), + "pbkdf2_password": hash_dashboard_password(existing_password), + "password_storage_upgraded": True, + "password_change_required": False, + } + }, + f, + ) + + config = AstrBotConfig( + config_path=temp_config_path, + default_config=default_config, + ) + + assert getattr(config, "_generated_dashboard_password", None) == reset_password + assert verify_dashboard_password( + config["dashboard"]["pbkdf2_password"], reset_password + ) + assert verify_dashboard_password(config["dashboard"]["password"], reset_password) + assert not verify_dashboard_password( + config["dashboard"]["pbkdf2_password"], existing_password + ) + assert config["dashboard"]["password_change_required"] is True + assert config["dashboard"]["password_storage_upgraded"] is True + assert os.environ["ASTRBOT_DASHBOARD_RESET_PASSWORD"] == "0" + def test_legacy_astrbot_user_without_change_flag_keeps_legacy_password( self, temp_config_path ):