From 97f0fd3de3307cea32e2e28ef83e4cd894423fe3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 21 May 2026 23:44:28 +0800 Subject: [PATCH 1/8] feat: add CLI commands documentation and service management features - Updated the VitePress configuration to include links to CLI commands in both English and Chinese. - Enhanced the AstrBot deployment documentation with instructions for installing it as a system service. - Created comprehensive CLI commands documentation covering initialization, service management, configuration, and plugin management. - Added tests for CLI command aliases and service functionalities to ensure proper command registration and behavior. - Implemented service log management features, including enabling application logging and controlling log visibility. --- astrbot/cli/__main__.py | 25 +- astrbot/cli/commands/__init__.py | 10 +- astrbot/cli/commands/cmd_conf.py | 2 +- astrbot/cli/commands/cmd_plug.py | 2 +- astrbot/cli/commands/cmd_service.py | 1433 +++++++++++++++++++++++++++ docs/.vitepress/config.mjs | 2 + docs/en/deploy/astrbot/package.md | 68 +- docs/en/use/cli.md | 307 ++++++ docs/zh/deploy/astrbot/package.md | 65 ++ docs/zh/use/cli.md | 307 ++++++ tests/test_cli_command_aliases.py | 25 + tests/test_cli_service.py | 319 ++++++ 12 files changed, 2555 insertions(+), 10 deletions(-) create mode 100644 astrbot/cli/commands/cmd_service.py create mode 100644 docs/en/use/cli.md create mode 100644 docs/zh/use/cli.md create mode 100644 tests/test_cli_command_aliases.py create mode 100644 tests/test_cli_service.py 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_service.py b/astrbot/cli/commands/cmd_service.py new file mode 100644 index 0000000000..28a13298d8 --- /dev/null +++ b/astrbot/cli/commands/cmd_service.py @@ -0,0 +1,1433 @@ +import copy +import getpass +import json +import os +import platform +import plistlib +import shutil +import subprocess +import sys +import tempfile +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 +from xml.etree import ElementTree + +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" +WINDOWS_TASK_XML_NS = "http://schemas.microsoft.com/windows/2004/02/mit/task" + + +@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() + elif system == "Windows": + return _windows_service_log_paths(service_name) + 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 _task_element( + parent: ElementTree.Element, + name: str, + text: str | None = None, + attrib: dict[str, str] | None = None, +) -> ElementTree.Element: + child = ElementTree.SubElement( + parent, f"{{{WINDOWS_TASK_XML_NS}}}{name}", attrib or {} + ) + if text is not None: + child.text = text + return child + + +def _windows_log_dir() -> Path: + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + return Path(local_app_data) / "AstrBot" / "Logs" + return Path.home() / "AppData" / "Local" / "AstrBot" / "Logs" + + +def _windows_service_log_paths(service_name: str) -> tuple[Path, Path]: + log_dir = _windows_log_dir() + return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" + + +def _windows_cmd_executable() -> str: + return os.environ.get("COMSPEC") or "cmd.exe" + + +def _quote_windows_cmd_arg(value: Path | str) -> str: + escaped = str(value).replace('"', '""') + return f'"{escaped}"' + + +def _build_windows_cmd_arguments(service_name: str, executable: Path) -> str: + out_log, err_log = _windows_service_log_paths(service_name) + return ( + "/d /c " + f'"{_quote_windows_cmd_arg(executable)} run ' + f">> {_quote_windows_cmd_arg(out_log)} " + f"2>> {_quote_windows_cmd_arg(err_log)}" + '"' + ) + + +def _build_windows_task_xml( + service_name: str, + executable: Path, + workdir: Path, +) -> bytes: + ElementTree.register_namespace("", WINDOWS_TASK_XML_NS) + task = ElementTree.Element( + f"{{{WINDOWS_TASK_XML_NS}}}Task", + {"version": "1.4"}, + ) + + registration_info = _task_element(task, "RegistrationInfo") + _task_element(registration_info, "Description", "AstrBot Service") + + triggers = _task_element(task, "Triggers") + logon_trigger = _task_element(triggers, "LogonTrigger") + _task_element(logon_trigger, "Enabled", "true") + + principals = _task_element(task, "Principals") + principal = _task_element(principals, "Principal", attrib={"id": "Author"}) + _task_element(principal, "LogonType", "InteractiveToken") + _task_element(principal, "RunLevel", "LeastPrivilege") + + settings = _task_element(task, "Settings") + _task_element(settings, "MultipleInstancesPolicy", "IgnoreNew") + _task_element(settings, "DisallowStartIfOnBatteries", "false") + _task_element(settings, "StopIfGoingOnBatteries", "false") + _task_element(settings, "AllowHardTerminate", "true") + _task_element(settings, "StartWhenAvailable", "true") + _task_element(settings, "RunOnlyIfNetworkAvailable", "false") + _task_element(settings, "AllowStartOnDemand", "true") + _task_element(settings, "Enabled", "true") + _task_element(settings, "Hidden", "false") + _task_element(settings, "RunOnlyIfIdle", "false") + _task_element(settings, "WakeToRun", "false") + _task_element(settings, "ExecutionTimeLimit", "PT0S") + _task_element(settings, "Priority", "7") + restart = _task_element(settings, "RestartOnFailure") + _task_element(restart, "Interval", "PT1M") + _task_element(restart, "Count", "3") + + actions = _task_element(task, "Actions", attrib={"Context": "Author"}) + exec_action = _task_element(actions, "Exec") + _task_element(exec_action, "Command", _windows_cmd_executable()) + _task_element( + exec_action, + "Arguments", + _build_windows_cmd_arguments(service_name, executable), + ) + _task_element(exec_action, "WorkingDirectory", str(workdir)) + + ElementTree.indent(task, space=" ") + return ElementTree.tostring(task, encoding="utf-16", xml_declaration=True) + + +def _windows_task_exists(service_name: str) -> bool: + result = subprocess.run( + ["schtasks", "/Query", "/TN", service_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + + +def _install_windows_task( + service_name: str, + executable: Path, + workdir: Path, + *, + force: bool, + now: bool, +) -> None: + if platform.system() != "Windows": + raise click.ClickException( + "Windows scheduled task installation is only available on Windows" + ) + if shutil.which("schtasks") is None: + raise click.ClickException("schtasks was not found") + if _windows_task_exists(service_name) and not force: + raise click.ClickException( + f"Scheduled task {service_name} already exists. Use --force to overwrite" + ) + + _windows_log_dir().mkdir(parents=True, exist_ok=True) + task_xml = _build_windows_task_xml(service_name, executable, workdir) + temp_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f: + temp_path = Path(f.name) + f.write(task_xml) + + command = ["schtasks", "/Create", "/TN", service_name, "/XML", str(temp_path)] + if force: + command.append("/F") + _run_checked(command, "Failed to create the Windows scheduled task") + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + if now: + _run_checked( + ["schtasks", "/Run", "/TN", service_name], + "Failed to start the Windows scheduled task", + ) + + +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 _parse_schtasks_field(output: str, field_name: str) -> str | None: + prefix = f"{field_name}:" + for line in output.splitlines(): + if line.startswith(prefix): + return line.removeprefix(prefix).strip() + return None + + +def _get_windows_task_state(service_name: str) -> ServiceState: + if shutil.which("schtasks") is None: + return ServiceState( + manager="Task Scheduler", + installed=False, + state="unknown", + detail="schtasks was not found", + ) + + result = _run_capture( + ["schtasks", "/Query", "/TN", service_name, "/FO", "LIST", "/V"] + ) + if result is None: + return ServiceState( + manager="Task Scheduler", + installed=False, + state="unknown", + detail="schtasks was not found", + ) + if result.returncode != 0: + return ServiceState( + manager="Task Scheduler", + installed=False, + state="not-installed", + detail=_first_output_line(result), + ) + + status = _parse_schtasks_field(result.stdout or "", "Status") or "unknown" + return ServiceState( + manager="Task Scheduler", + installed=True, + state=status.lower(), + detail=_parse_schtasks_field(result.stdout or "", "Task To Run"), + ) + + +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) + if system == "Windows": + return _get_windows_task_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_windows_task(service_name: str, action: str) -> None: + if shutil.which("schtasks") is None: + raise click.ClickException("schtasks was not found") + if not _windows_task_exists(service_name): + raise click.ClickException( + f"Scheduled task {service_name} does not exist. Run 'service install' first" + ) + + match action: + case "start": + command = ["schtasks", "/Run", "/TN", service_name] + case "stop": + command = ["schtasks", "/End", "/TN", service_name] + case _: + raise click.ClickException(f"Unsupported Windows task action: {action}") + + _run_checked(command, f"Failed to {action} the Windows scheduled task") + + +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 + + if system == "Windows": + match action: + case "start" | "stop": + _control_windows_task(service_name, action) + case "restart": + _control_windows_task(service_name, "stop") + _control_windows_task(service_name, "start") + case _: + raise click.ClickException(f"Unsupported Windows task 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_windows_task(service_name: str) -> str: + if shutil.which("schtasks") is None: + raise click.ClickException("schtasks was not found") + if not _windows_task_exists(service_name): + raise click.ClickException(f"Scheduled task {service_name} does not exist") + + _run_checked( + ["schtasks", "/Delete", "/TN", service_name, "/F"], + "Failed to delete the Windows scheduled task", + ) + return service_name + + +def _uninstall_service(service_name: str) -> Path | str: + system = platform.system() + if system == "Linux": + return _uninstall_systemd_service(service_name) + if system == "Darwin": + return _uninstall_launch_agent(service_name) + if system == "Windows": + return _uninstall_windows_task(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 in {"Darwin", "Windows"}: + 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) + astrbot_root = _resolve_workdir(workdir) + astrbot_executable = _resolve_astrbot_executable(executable) + system = platform.system() + + 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 + + if system == "Windows": + _install_windows_task( + service_name, + astrbot_executable, + astrbot_root, + force=force, + now=now, + ) + click.echo(f"Installed Windows scheduled task: {service_name}") + click.echo(f"Manage it with: schtasks /Query /TN {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 and Windows.", +) +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/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..ceda780a2f 100644 --- a/docs/en/deploy/astrbot/package.md +++ b/docs/en/deploy/astrbot/package.md @@ -20,5 +20,71 @@ 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` +- Windows: Task Scheduler + +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 and Windows, 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..7a6a016c4e --- /dev/null +++ b/docs/en/use/cli.md @@ -0,0 +1,307 @@ +# 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. | + +Examples: + +```bash +astrbot run --port 6185 +astrbot run --reload +``` + +## 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 | +| Windows | Task Scheduler | + +### 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 and Windows. | + +On macOS and Windows, `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..6ad7c4df74 100644 --- a/docs/zh/deploy/astrbot/package.md +++ b/docs/zh/deploy/astrbot/package.md @@ -22,3 +22,68 @@ 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` +- Windows:任务计划程序 + +如果需要指定 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 和 Windows 下默认只显示标准输出日志;如需同时查看 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..b71c4bee92 --- /dev/null +++ b/docs/zh/use/cli.md @@ -0,0 +1,307 @@ +# 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` | 启用插件自动重载,适合插件开发调试。 | + +示例: + +```bash +astrbot run --port 6185 +astrbot run --reload +``` + +## 后台服务 + +`astrbot service` 可以把 AstrBot 安装为用户级后台服务,适合长期运行。 + +不同系统会使用对应的服务管理机制: + +| 系统 | 服务管理器 | +| --- | --- | +| Linux | `systemd --user` | +| macOS | LaunchAgent | +| Windows | 任务计划程序 | + +### 安装服务 + +```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 和 Windows 上同时显示 stderr 日志。 | + +macOS 和 Windows 下,`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/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_service.py b/tests/test_cli_service.py new file mode 100644 index 0000000000..166becd26b --- /dev/null +++ b/tests/test_cli_service.py @@ -0,0 +1,319 @@ +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, + _build_windows_task_xml, + _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_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_windows_task_xml_uses_astrbot_executable_and_working_directory(): + task_xml = _build_windows_task_xml( + "astrbot", + Path("C:\\Users\\astrbot\\.local\\bin\\astrbot.exe"), + Path("C:\\Users\\astrbot\\AstrBot"), + ).decode("utf-16") + + assert "cmd.exe" in task_xml + assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in task_xml + assert "run" in task_xml + assert "astrbot.out.log" in task_xml + assert "astrbot.err.log" in task_xml + assert "C:\\Users\\astrbot\\AstrBot" in task_xml + + +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" From 250baccf5b7e3477ff9c23f12a716c71ebea4a1c Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 15:37:38 +0800 Subject: [PATCH 2/8] feat(service): update Windows service command to use PowerShell and enhance task XML configuration --- astrbot/cli/commands/cmd_service.py | 48 +++++++++++++++++++++++------ tests/test_cli_service.py | 28 ++++++++++++----- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py index 28a13298d8..91e28f2dde 100644 --- a/astrbot/cli/commands/cmd_service.py +++ b/astrbot/cli/commands/cmd_service.py @@ -1,3 +1,4 @@ +import base64 import copy import getpass import json @@ -334,8 +335,8 @@ def _windows_service_log_paths(service_name: str) -> tuple[Path, Path]: return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" -def _windows_cmd_executable() -> str: - return os.environ.get("COMSPEC") or "cmd.exe" +def _windows_powershell_executable() -> str: + return "powershell.exe" def _quote_windows_cmd_arg(value: Path | str) -> str: @@ -343,14 +344,43 @@ def _quote_windows_cmd_arg(value: Path | str) -> str: return f'"{escaped}"' -def _build_windows_cmd_arguments(service_name: str, executable: Path) -> str: +def _quote_powershell_literal(value: Path | str) -> str: + escaped = str(value).replace("'", "''") + return f"'{escaped}'" + + +def _build_windows_cmd_line(service_name: str, executable: Path) -> str: out_log, err_log = _windows_service_log_paths(service_name) return ( - "/d /c " - f'"{_quote_windows_cmd_arg(executable)} run ' + f"{_quote_windows_cmd_arg(executable)} run " f">> {_quote_windows_cmd_arg(out_log)} " f"2>> {_quote_windows_cmd_arg(err_log)}" - '"' + ) + + +def _build_windows_powershell_arguments( + service_name: str, + executable: Path, + workdir: Path, +) -> str: + script = ( + "$ErrorActionPreference = 'Stop'\n" + "$env:PYTHONUNBUFFERED = '1'\n" + "$cmdExe = if ($env:COMSPEC) { $env:COMSPEC } else { 'cmd.exe' }\n" + f"$astrbotCommand = {_quote_powershell_literal(_build_windows_cmd_line(service_name, executable))}\n" + "$process = Start-Process " + "-FilePath $cmdExe " + "-ArgumentList @('/d', '/c', $astrbotCommand) " + f"-WorkingDirectory {_quote_powershell_literal(workdir)} " + "-WindowStyle Hidden " + "-PassThru " + "-Wait\n" + "exit $process.ExitCode\n" + ) + encoded_script = base64.b64encode(script.encode("utf-16le")).decode("ascii") + return ( + "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass " + f"-WindowStyle Hidden -EncodedCommand {encoded_script}" ) @@ -386,7 +416,7 @@ def _build_windows_task_xml( _task_element(settings, "RunOnlyIfNetworkAvailable", "false") _task_element(settings, "AllowStartOnDemand", "true") _task_element(settings, "Enabled", "true") - _task_element(settings, "Hidden", "false") + _task_element(settings, "Hidden", "true") _task_element(settings, "RunOnlyIfIdle", "false") _task_element(settings, "WakeToRun", "false") _task_element(settings, "ExecutionTimeLimit", "PT0S") @@ -397,11 +427,11 @@ def _build_windows_task_xml( actions = _task_element(task, "Actions", attrib={"Context": "Author"}) exec_action = _task_element(actions, "Exec") - _task_element(exec_action, "Command", _windows_cmd_executable()) + _task_element(exec_action, "Command", _windows_powershell_executable()) _task_element( exec_action, "Arguments", - _build_windows_cmd_arguments(service_name, executable), + _build_windows_powershell_arguments(service_name, executable, workdir), ) _task_element(exec_action, "WorkingDirectory", str(workdir)) diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py index 166becd26b..a76d040ed4 100644 --- a/tests/test_cli_service.py +++ b/tests/test_cli_service.py @@ -1,3 +1,4 @@ +import base64 import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -22,6 +23,12 @@ ) +def _decode_windows_encoded_command(task_xml: str) -> str: + marker = "-EncodedCommand " + encoded_command = task_xml.split(marker, 1)[1].split("<", 1)[0] + return base64.b64decode(encoded_command).decode("utf-16le") + + class _HealthyHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) @@ -134,13 +141,20 @@ def test_windows_task_xml_uses_astrbot_executable_and_working_directory(): Path("C:\\Users\\astrbot\\.local\\bin\\astrbot.exe"), Path("C:\\Users\\astrbot\\AstrBot"), ).decode("utf-16") - - assert "cmd.exe" in task_xml - assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in task_xml - assert "run" in task_xml - assert "astrbot.out.log" in task_xml - assert "astrbot.err.log" in task_xml - assert "C:\\Users\\astrbot\\AstrBot" in task_xml + powershell_script = _decode_windows_encoded_command(task_xml) + + assert "powershell.exe" in task_xml + assert "true" in task_xml + assert "-WindowStyle Hidden" in task_xml + assert "Start-Process" in powershell_script + assert "-WindowStyle Hidden" in powershell_script + assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in powershell_script + assert "run" in powershell_script + assert "astrbot.out.log" in powershell_script + assert "astrbot.err.log" in powershell_script + assert ( + "C:\\Users\\astrbot\\AstrBot" in task_xml + ) def test_load_dashboard_port_reads_cmd_config(tmp_path): From b1e1f5e6e4ef55758dbfd180e4e33a8698a47803 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 17:58:20 +0800 Subject: [PATCH 3/8] chore: remove windows --- astrbot/cli/commands/cmd_service.py | 305 ++-------------------------- docs/en/deploy/astrbot/package.md | 6 +- docs/en/use/cli.md | 8 +- docs/zh/deploy/astrbot/package.md | 6 +- docs/zh/use/cli.md | 8 +- tests/test_cli_service.py | 35 +--- 6 files changed, 37 insertions(+), 331 deletions(-) diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py index 91e28f2dde..4c9da5acd7 100644 --- a/astrbot/cli/commands/cmd_service.py +++ b/astrbot/cli/commands/cmd_service.py @@ -1,4 +1,3 @@ -import base64 import copy import getpass import json @@ -8,7 +7,6 @@ import shutil import subprocess import sys -import tempfile import time from collections import deque from dataclasses import dataclass @@ -16,7 +14,6 @@ from textwrap import dedent from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen -from xml.etree import ElementTree import click @@ -27,7 +24,10 @@ DEFAULT_STATUS_TIMEOUT_SECONDS = 2.0 DEFAULT_LOG_LINES = 200 MACOS_LABEL_PREFIX = "app.astrbot" -WINDOWS_TASK_XML_NS = "http://schemas.microsoft.com/windows/2004/02/mit/task" +WINDOWS_SERVICE_UNSUPPORTED_MESSAGE = ( + "AstrBot service management is not supported on Windows yet. " + "Use 'astrbot run' to start AstrBot in the foreground." +) @dataclass(frozen=True) @@ -64,6 +64,12 @@ class AppLogConfig: @click.group(name="service") def service() -> None: """Install and manage AstrBot as a background service.""" + _ensure_service_platform_supported() + + +def _ensure_service_platform_supported() -> None: + if platform.system() == "Windows": + raise click.ClickException(WINDOWS_SERVICE_UNSUPPORTED_MESSAGE) def _validate_service_name(name: str) -> str: @@ -244,8 +250,6 @@ def _service_log_paths(service_name: str) -> tuple[Path, Path]: system = platform.system() if system == "Darwin": log_dir = _macos_log_dir() - elif system == "Windows": - return _windows_service_log_paths(service_name) else: log_dir = get_astrbot_root() / "data" / "logs" return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" @@ -309,188 +313,6 @@ def _install_launch_agent( return plist_path -def _task_element( - parent: ElementTree.Element, - name: str, - text: str | None = None, - attrib: dict[str, str] | None = None, -) -> ElementTree.Element: - child = ElementTree.SubElement( - parent, f"{{{WINDOWS_TASK_XML_NS}}}{name}", attrib or {} - ) - if text is not None: - child.text = text - return child - - -def _windows_log_dir() -> Path: - local_app_data = os.environ.get("LOCALAPPDATA") - if local_app_data: - return Path(local_app_data) / "AstrBot" / "Logs" - return Path.home() / "AppData" / "Local" / "AstrBot" / "Logs" - - -def _windows_service_log_paths(service_name: str) -> tuple[Path, Path]: - log_dir = _windows_log_dir() - return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" - - -def _windows_powershell_executable() -> str: - return "powershell.exe" - - -def _quote_windows_cmd_arg(value: Path | str) -> str: - escaped = str(value).replace('"', '""') - return f'"{escaped}"' - - -def _quote_powershell_literal(value: Path | str) -> str: - escaped = str(value).replace("'", "''") - return f"'{escaped}'" - - -def _build_windows_cmd_line(service_name: str, executable: Path) -> str: - out_log, err_log = _windows_service_log_paths(service_name) - return ( - f"{_quote_windows_cmd_arg(executable)} run " - f">> {_quote_windows_cmd_arg(out_log)} " - f"2>> {_quote_windows_cmd_arg(err_log)}" - ) - - -def _build_windows_powershell_arguments( - service_name: str, - executable: Path, - workdir: Path, -) -> str: - script = ( - "$ErrorActionPreference = 'Stop'\n" - "$env:PYTHONUNBUFFERED = '1'\n" - "$cmdExe = if ($env:COMSPEC) { $env:COMSPEC } else { 'cmd.exe' }\n" - f"$astrbotCommand = {_quote_powershell_literal(_build_windows_cmd_line(service_name, executable))}\n" - "$process = Start-Process " - "-FilePath $cmdExe " - "-ArgumentList @('/d', '/c', $astrbotCommand) " - f"-WorkingDirectory {_quote_powershell_literal(workdir)} " - "-WindowStyle Hidden " - "-PassThru " - "-Wait\n" - "exit $process.ExitCode\n" - ) - encoded_script = base64.b64encode(script.encode("utf-16le")).decode("ascii") - return ( - "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass " - f"-WindowStyle Hidden -EncodedCommand {encoded_script}" - ) - - -def _build_windows_task_xml( - service_name: str, - executable: Path, - workdir: Path, -) -> bytes: - ElementTree.register_namespace("", WINDOWS_TASK_XML_NS) - task = ElementTree.Element( - f"{{{WINDOWS_TASK_XML_NS}}}Task", - {"version": "1.4"}, - ) - - registration_info = _task_element(task, "RegistrationInfo") - _task_element(registration_info, "Description", "AstrBot Service") - - triggers = _task_element(task, "Triggers") - logon_trigger = _task_element(triggers, "LogonTrigger") - _task_element(logon_trigger, "Enabled", "true") - - principals = _task_element(task, "Principals") - principal = _task_element(principals, "Principal", attrib={"id": "Author"}) - _task_element(principal, "LogonType", "InteractiveToken") - _task_element(principal, "RunLevel", "LeastPrivilege") - - settings = _task_element(task, "Settings") - _task_element(settings, "MultipleInstancesPolicy", "IgnoreNew") - _task_element(settings, "DisallowStartIfOnBatteries", "false") - _task_element(settings, "StopIfGoingOnBatteries", "false") - _task_element(settings, "AllowHardTerminate", "true") - _task_element(settings, "StartWhenAvailable", "true") - _task_element(settings, "RunOnlyIfNetworkAvailable", "false") - _task_element(settings, "AllowStartOnDemand", "true") - _task_element(settings, "Enabled", "true") - _task_element(settings, "Hidden", "true") - _task_element(settings, "RunOnlyIfIdle", "false") - _task_element(settings, "WakeToRun", "false") - _task_element(settings, "ExecutionTimeLimit", "PT0S") - _task_element(settings, "Priority", "7") - restart = _task_element(settings, "RestartOnFailure") - _task_element(restart, "Interval", "PT1M") - _task_element(restart, "Count", "3") - - actions = _task_element(task, "Actions", attrib={"Context": "Author"}) - exec_action = _task_element(actions, "Exec") - _task_element(exec_action, "Command", _windows_powershell_executable()) - _task_element( - exec_action, - "Arguments", - _build_windows_powershell_arguments(service_name, executable, workdir), - ) - _task_element(exec_action, "WorkingDirectory", str(workdir)) - - ElementTree.indent(task, space=" ") - return ElementTree.tostring(task, encoding="utf-16", xml_declaration=True) - - -def _windows_task_exists(service_name: str) -> bool: - result = subprocess.run( - ["schtasks", "/Query", "/TN", service_name], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return result.returncode == 0 - - -def _install_windows_task( - service_name: str, - executable: Path, - workdir: Path, - *, - force: bool, - now: bool, -) -> None: - if platform.system() != "Windows": - raise click.ClickException( - "Windows scheduled task installation is only available on Windows" - ) - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if _windows_task_exists(service_name) and not force: - raise click.ClickException( - f"Scheduled task {service_name} already exists. Use --force to overwrite" - ) - - _windows_log_dir().mkdir(parents=True, exist_ok=True) - task_xml = _build_windows_task_xml(service_name, executable, workdir) - temp_path = None - try: - with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f: - temp_path = Path(f.name) - f.write(task_xml) - - command = ["schtasks", "/Create", "/TN", service_name, "/XML", str(temp_path)] - if force: - command.append("/F") - _run_checked(command, "Failed to create the Windows scheduled task") - finally: - if temp_path is not None: - temp_path.unlink(missing_ok=True) - - if now: - _run_checked( - ["schtasks", "/Run", "/TN", service_name], - "Failed to start the Windows scheduled task", - ) - - def _first_output_line(result: subprocess.CompletedProcess[str]) -> str | None: text = (result.stdout or result.stderr).strip() if not text: @@ -595,58 +417,12 @@ def _get_launchd_state(service_name: str) -> ServiceState: ) -def _parse_schtasks_field(output: str, field_name: str) -> str | None: - prefix = f"{field_name}:" - for line in output.splitlines(): - if line.startswith(prefix): - return line.removeprefix(prefix).strip() - return None - - -def _get_windows_task_state(service_name: str) -> ServiceState: - if shutil.which("schtasks") is None: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="unknown", - detail="schtasks was not found", - ) - - result = _run_capture( - ["schtasks", "/Query", "/TN", service_name, "/FO", "LIST", "/V"] - ) - if result is None: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="unknown", - detail="schtasks was not found", - ) - if result.returncode != 0: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="not-installed", - detail=_first_output_line(result), - ) - - status = _parse_schtasks_field(result.stdout or "", "Status") or "unknown" - return ServiceState( - manager="Task Scheduler", - installed=True, - state=status.lower(), - detail=_parse_schtasks_field(result.stdout or "", "Task To Run"), - ) - - 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) - if system == "Windows": - return _get_windows_task_state(service_name) return ServiceState( manager="unknown", installed=False, @@ -836,25 +612,6 @@ def _stop_launch_agent(service_name: str, *, allow_missing: bool = False) -> Non _wait_for_launch_agent_state(service_name, loaded=False) -def _control_windows_task(service_name: str, action: str) -> None: - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if not _windows_task_exists(service_name): - raise click.ClickException( - f"Scheduled task {service_name} does not exist. Run 'service install' first" - ) - - match action: - case "start": - command = ["schtasks", "/Run", "/TN", service_name] - case "stop": - command = ["schtasks", "/End", "/TN", service_name] - case _: - raise click.ClickException(f"Unsupported Windows task action: {action}") - - _run_checked(command, f"Failed to {action} the Windows scheduled task") - - def _control_service(service_name: str, action: str) -> None: system = platform.system() if system == "Linux": @@ -874,17 +631,6 @@ def _control_service(service_name: str, action: str) -> None: raise click.ClickException(f"Unsupported launchd action: {action}") return - if system == "Windows": - match action: - case "start" | "stop": - _control_windows_task(service_name, action) - case "restart": - _control_windows_task(service_name, "stop") - _control_windows_task(service_name, "start") - case _: - raise click.ClickException(f"Unsupported Windows task action: {action}") - return - raise click.ClickException(f"Unsupported platform: {system}") @@ -925,27 +671,12 @@ def _uninstall_launch_agent(service_name: str) -> Path: return plist_path -def _uninstall_windows_task(service_name: str) -> str: - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if not _windows_task_exists(service_name): - raise click.ClickException(f"Scheduled task {service_name} does not exist") - - _run_checked( - ["schtasks", "/Delete", "/TN", service_name, "/F"], - "Failed to delete the Windows scheduled task", - ) - return service_name - - def _uninstall_service(service_name: str) -> Path | str: system = platform.system() if system == "Linux": return _uninstall_systemd_service(service_name) if system == "Darwin": return _uninstall_launch_agent(service_name) - if system == "Windows": - return _uninstall_windows_task(service_name) raise click.ClickException(f"Unsupported platform: {system}") @@ -1131,7 +862,7 @@ def _show_service_logs( _show_journal_logs(service_name, lines, follow) return - if system in {"Darwin", "Windows"}: + if system == "Darwin": out_log, err_log = _service_log_paths(service_name) paths = [out_log] if include_stderr: @@ -1204,18 +935,6 @@ def install( click.echo(f"LaunchAgent label: {_macos_label(service_name)}") return - if system == "Windows": - _install_windows_task( - service_name, - astrbot_executable, - astrbot_root, - force=force, - now=now, - ) - click.echo(f"Installed Windows scheduled task: {service_name}") - click.echo(f"Manage it with: schtasks /Query /TN {service_name}") - return - raise click.ClickException(f"Unsupported platform: {system}") @@ -1320,7 +1039,7 @@ def uninstall(name: str, force: bool) -> None: @click.option( "--include-stderr", is_flag=True, - help="Also show stderr logs on macOS and Windows.", + help="Also show stderr logs on macOS.", ) def logs( ctx: click.Context, diff --git a/docs/en/deploy/astrbot/package.md b/docs/en/deploy/astrbot/package.md index ceda780a2f..1aad02fe65 100644 --- a/docs/en/deploy/astrbot/package.md +++ b/docs/en/deploy/astrbot/package.md @@ -36,7 +36,9 @@ The command uses the `astrbot` executable found on `PATH` (usually generated by - Linux: `systemd --user` - macOS: `LaunchAgent` -- Windows: Task Scheduler + +> [!NOTE] +> `astrbot service` is not supported on Windows yet. Use `astrbot run` to start AstrBot in the foreground. To specify the AstrBot working directory or executable path explicitly: @@ -68,7 +70,7 @@ astrbot service logs astrbot service logs -f ``` -On macOS and Windows, this shows stdout logs by default. To include stderr: +On macOS, this shows stdout logs by default. To include stderr: ```bash astrbot service logs --include-stderr diff --git a/docs/en/use/cli.md b/docs/en/use/cli.md index 7a6a016c4e..04ec2599be 100644 --- a/docs/en/use/cli.md +++ b/docs/en/use/cli.md @@ -88,7 +88,9 @@ Each platform uses its native service manager: | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | -| Windows | Task Scheduler | + +> [!NOTE] +> `astrbot service` is not supported on Windows yet. Use `astrbot run` to start AstrBot in the foreground. ### Install @@ -188,9 +190,9 @@ Common options: | `--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 and Windows. | +| `--include-stderr` | Also show stderr logs on macOS. | -On macOS and Windows, `astrbot service logs` shows stdout logs by default, which are the `.out.log` files. Add `--include-stderr` when you also need error output. +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 diff --git a/docs/zh/deploy/astrbot/package.md b/docs/zh/deploy/astrbot/package.md index 6ad7c4df74..433b470321 100644 --- a/docs/zh/deploy/astrbot/package.md +++ b/docs/zh/deploy/astrbot/package.md @@ -35,7 +35,9 @@ astrbot service install --now - Linux:`systemd --user` - macOS:`LaunchAgent` -- Windows:任务计划程序 + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请先使用 `astrbot run` 前台启动。 如果需要指定 AstrBot 工作目录或可执行文件路径,可以使用: @@ -67,7 +69,7 @@ astrbot service logs astrbot service logs -f ``` -macOS 和 Windows 下默认只显示标准输出日志;如需同时查看 stderr: +macOS 下默认只显示标准输出日志;如需同时查看 stderr: ```bash astrbot service logs --include-stderr diff --git a/docs/zh/use/cli.md b/docs/zh/use/cli.md index b71c4bee92..19b715ec62 100644 --- a/docs/zh/use/cli.md +++ b/docs/zh/use/cli.md @@ -88,7 +88,9 @@ astrbot run --reload | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | -| Windows | 任务计划程序 | + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请先使用 `astrbot run` 前台启动。 ### 安装服务 @@ -188,9 +190,9 @@ astrbot service logs -f | `--name ` | 指定服务名。 | | `-n, --lines ` | 显示最近 N 行,默认 200。 | | `-f, --follow` | 持续跟随日志输出。 | -| `--include-stderr` | 在 macOS 和 Windows 上同时显示 stderr 日志。 | +| `--include-stderr` | 在 macOS 上同时显示 stderr 日志。 | -macOS 和 Windows 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 +macOS 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 ### 启用应用日志文件 diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py index a76d040ed4..d4a53e534a 100644 --- a/tests/test_cli_service.py +++ b/tests/test_cli_service.py @@ -1,4 +1,3 @@ -import base64 import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -13,7 +12,6 @@ WebUIStatus, _build_launchd_plist, _build_systemd_unit, - _build_windows_task_xml, _check_webui, _get_app_log_config, _health_label, @@ -23,12 +21,6 @@ ) -def _decode_windows_encoded_command(task_xml: str) -> str: - marker = "-EncodedCommand " - encoded_command = task_xml.split(marker, 1)[1].split("<", 1)[0] - return base64.b64decode(encoded_command).decode("utf-16le") - - class _HealthyHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) @@ -135,26 +127,13 @@ def fake_run_checked(command, _failure_message): assert events.index("bootstrap") < events.index("kickstart") -def test_windows_task_xml_uses_astrbot_executable_and_working_directory(): - task_xml = _build_windows_task_xml( - "astrbot", - Path("C:\\Users\\astrbot\\.local\\bin\\astrbot.exe"), - Path("C:\\Users\\astrbot\\AstrBot"), - ).decode("utf-16") - powershell_script = _decode_windows_encoded_command(task_xml) - - assert "powershell.exe" in task_xml - assert "true" in task_xml - assert "-WindowStyle Hidden" in task_xml - assert "Start-Process" in powershell_script - assert "-WindowStyle Hidden" in powershell_script - assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in powershell_script - assert "run" in powershell_script - assert "astrbot.out.log" in powershell_script - assert "astrbot.err.log" in powershell_script - assert ( - "C:\\Users\\astrbot\\AstrBot" in task_xml - ) +def test_service_command_rejects_windows(monkeypatch): + monkeypatch.setattr(cmd_service.platform, "system", lambda: "Windows") + + result = CliRunner().invoke(service, ["start"]) + + assert result.exit_code == 1 + assert "not supported on Windows yet" in result.output def test_load_dashboard_port_reads_cmd_config(tmp_path): From aa7bd5e5adeafc77897f9317cc52296661cf9411 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 19:34:22 +0800 Subject: [PATCH 4/8] feat: add support for resetting dashboard initial password on startup Co-authored-by: Copilot --- astrbot/cli/commands/cmd_run.py | 12 ++++- astrbot/core/config/astrbot_config.py | 30 ++++++++---- docs/en/use/cli.md | 2 + docs/zh/use/cli.md | 2 + main.py | 15 ++++++ tests/test_cli_run.py | 22 +++++++++ tests/unit/test_config.py | 70 ++++++++++++++++++++++++--- 7 files changed, 135 insertions(+), 18 deletions(-) create mode 100644 tests/test_cli_run.py 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/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/en/use/cli.md b/docs/en/use/cli.md index 04ec2599be..a0dbb1bdb5 100644 --- a/docs/en/use/cli.md +++ b/docs/en/use/cli.md @@ -70,12 +70,14 @@ Common options: | --- | --- | | `-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 diff --git a/docs/zh/use/cli.md b/docs/zh/use/cli.md index 19b715ec62..71a19f7464 100644 --- a/docs/zh/use/cli.md +++ b/docs/zh/use/cli.md @@ -70,12 +70,14 @@ astrbot run | --- | --- | | `-p, --port ` | 指定 WebUI 端口。 | | `-r, --reload` | 启用插件自动重载,适合插件开发调试。 | +| `--reset-password` | 启动时重置 WebUI 初始密码,并在启动日志中打印新的初始密码。 | 示例: ```bash astrbot run --port 6185 astrbot run --reload +astrbot run --reset-password ``` ## 后台服务 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_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/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 ): From c2bbec768320b27434f91565ab8fe1ea00e1761a Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 19:44:14 +0800 Subject: [PATCH 5/8] Revert "chore: remove windows" This reverts commit b1e1f5e6e4ef55758dbfd180e4e33a8698a47803. --- astrbot/cli/commands/cmd_service.py | 305 ++++++++++++++++++++++++++-- docs/en/deploy/astrbot/package.md | 6 +- docs/en/use/cli.md | 8 +- docs/zh/deploy/astrbot/package.md | 6 +- docs/zh/use/cli.md | 8 +- tests/test_cli_service.py | 35 +++- 6 files changed, 331 insertions(+), 37 deletions(-) diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py index 4c9da5acd7..91e28f2dde 100644 --- a/astrbot/cli/commands/cmd_service.py +++ b/astrbot/cli/commands/cmd_service.py @@ -1,3 +1,4 @@ +import base64 import copy import getpass import json @@ -7,6 +8,7 @@ import shutil import subprocess import sys +import tempfile import time from collections import deque from dataclasses import dataclass @@ -14,6 +16,7 @@ from textwrap import dedent from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +from xml.etree import ElementTree import click @@ -24,10 +27,7 @@ DEFAULT_STATUS_TIMEOUT_SECONDS = 2.0 DEFAULT_LOG_LINES = 200 MACOS_LABEL_PREFIX = "app.astrbot" -WINDOWS_SERVICE_UNSUPPORTED_MESSAGE = ( - "AstrBot service management is not supported on Windows yet. " - "Use 'astrbot run' to start AstrBot in the foreground." -) +WINDOWS_TASK_XML_NS = "http://schemas.microsoft.com/windows/2004/02/mit/task" @dataclass(frozen=True) @@ -64,12 +64,6 @@ class AppLogConfig: @click.group(name="service") def service() -> None: """Install and manage AstrBot as a background service.""" - _ensure_service_platform_supported() - - -def _ensure_service_platform_supported() -> None: - if platform.system() == "Windows": - raise click.ClickException(WINDOWS_SERVICE_UNSUPPORTED_MESSAGE) def _validate_service_name(name: str) -> str: @@ -250,6 +244,8 @@ def _service_log_paths(service_name: str) -> tuple[Path, Path]: system = platform.system() if system == "Darwin": log_dir = _macos_log_dir() + elif system == "Windows": + return _windows_service_log_paths(service_name) else: log_dir = get_astrbot_root() / "data" / "logs" return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" @@ -313,6 +309,188 @@ def _install_launch_agent( return plist_path +def _task_element( + parent: ElementTree.Element, + name: str, + text: str | None = None, + attrib: dict[str, str] | None = None, +) -> ElementTree.Element: + child = ElementTree.SubElement( + parent, f"{{{WINDOWS_TASK_XML_NS}}}{name}", attrib or {} + ) + if text is not None: + child.text = text + return child + + +def _windows_log_dir() -> Path: + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + return Path(local_app_data) / "AstrBot" / "Logs" + return Path.home() / "AppData" / "Local" / "AstrBot" / "Logs" + + +def _windows_service_log_paths(service_name: str) -> tuple[Path, Path]: + log_dir = _windows_log_dir() + return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" + + +def _windows_powershell_executable() -> str: + return "powershell.exe" + + +def _quote_windows_cmd_arg(value: Path | str) -> str: + escaped = str(value).replace('"', '""') + return f'"{escaped}"' + + +def _quote_powershell_literal(value: Path | str) -> str: + escaped = str(value).replace("'", "''") + return f"'{escaped}'" + + +def _build_windows_cmd_line(service_name: str, executable: Path) -> str: + out_log, err_log = _windows_service_log_paths(service_name) + return ( + f"{_quote_windows_cmd_arg(executable)} run " + f">> {_quote_windows_cmd_arg(out_log)} " + f"2>> {_quote_windows_cmd_arg(err_log)}" + ) + + +def _build_windows_powershell_arguments( + service_name: str, + executable: Path, + workdir: Path, +) -> str: + script = ( + "$ErrorActionPreference = 'Stop'\n" + "$env:PYTHONUNBUFFERED = '1'\n" + "$cmdExe = if ($env:COMSPEC) { $env:COMSPEC } else { 'cmd.exe' }\n" + f"$astrbotCommand = {_quote_powershell_literal(_build_windows_cmd_line(service_name, executable))}\n" + "$process = Start-Process " + "-FilePath $cmdExe " + "-ArgumentList @('/d', '/c', $astrbotCommand) " + f"-WorkingDirectory {_quote_powershell_literal(workdir)} " + "-WindowStyle Hidden " + "-PassThru " + "-Wait\n" + "exit $process.ExitCode\n" + ) + encoded_script = base64.b64encode(script.encode("utf-16le")).decode("ascii") + return ( + "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass " + f"-WindowStyle Hidden -EncodedCommand {encoded_script}" + ) + + +def _build_windows_task_xml( + service_name: str, + executable: Path, + workdir: Path, +) -> bytes: + ElementTree.register_namespace("", WINDOWS_TASK_XML_NS) + task = ElementTree.Element( + f"{{{WINDOWS_TASK_XML_NS}}}Task", + {"version": "1.4"}, + ) + + registration_info = _task_element(task, "RegistrationInfo") + _task_element(registration_info, "Description", "AstrBot Service") + + triggers = _task_element(task, "Triggers") + logon_trigger = _task_element(triggers, "LogonTrigger") + _task_element(logon_trigger, "Enabled", "true") + + principals = _task_element(task, "Principals") + principal = _task_element(principals, "Principal", attrib={"id": "Author"}) + _task_element(principal, "LogonType", "InteractiveToken") + _task_element(principal, "RunLevel", "LeastPrivilege") + + settings = _task_element(task, "Settings") + _task_element(settings, "MultipleInstancesPolicy", "IgnoreNew") + _task_element(settings, "DisallowStartIfOnBatteries", "false") + _task_element(settings, "StopIfGoingOnBatteries", "false") + _task_element(settings, "AllowHardTerminate", "true") + _task_element(settings, "StartWhenAvailable", "true") + _task_element(settings, "RunOnlyIfNetworkAvailable", "false") + _task_element(settings, "AllowStartOnDemand", "true") + _task_element(settings, "Enabled", "true") + _task_element(settings, "Hidden", "true") + _task_element(settings, "RunOnlyIfIdle", "false") + _task_element(settings, "WakeToRun", "false") + _task_element(settings, "ExecutionTimeLimit", "PT0S") + _task_element(settings, "Priority", "7") + restart = _task_element(settings, "RestartOnFailure") + _task_element(restart, "Interval", "PT1M") + _task_element(restart, "Count", "3") + + actions = _task_element(task, "Actions", attrib={"Context": "Author"}) + exec_action = _task_element(actions, "Exec") + _task_element(exec_action, "Command", _windows_powershell_executable()) + _task_element( + exec_action, + "Arguments", + _build_windows_powershell_arguments(service_name, executable, workdir), + ) + _task_element(exec_action, "WorkingDirectory", str(workdir)) + + ElementTree.indent(task, space=" ") + return ElementTree.tostring(task, encoding="utf-16", xml_declaration=True) + + +def _windows_task_exists(service_name: str) -> bool: + result = subprocess.run( + ["schtasks", "/Query", "/TN", service_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + + +def _install_windows_task( + service_name: str, + executable: Path, + workdir: Path, + *, + force: bool, + now: bool, +) -> None: + if platform.system() != "Windows": + raise click.ClickException( + "Windows scheduled task installation is only available on Windows" + ) + if shutil.which("schtasks") is None: + raise click.ClickException("schtasks was not found") + if _windows_task_exists(service_name) and not force: + raise click.ClickException( + f"Scheduled task {service_name} already exists. Use --force to overwrite" + ) + + _windows_log_dir().mkdir(parents=True, exist_ok=True) + task_xml = _build_windows_task_xml(service_name, executable, workdir) + temp_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f: + temp_path = Path(f.name) + f.write(task_xml) + + command = ["schtasks", "/Create", "/TN", service_name, "/XML", str(temp_path)] + if force: + command.append("/F") + _run_checked(command, "Failed to create the Windows scheduled task") + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + if now: + _run_checked( + ["schtasks", "/Run", "/TN", service_name], + "Failed to start the Windows scheduled task", + ) + + def _first_output_line(result: subprocess.CompletedProcess[str]) -> str | None: text = (result.stdout or result.stderr).strip() if not text: @@ -417,12 +595,58 @@ def _get_launchd_state(service_name: str) -> ServiceState: ) +def _parse_schtasks_field(output: str, field_name: str) -> str | None: + prefix = f"{field_name}:" + for line in output.splitlines(): + if line.startswith(prefix): + return line.removeprefix(prefix).strip() + return None + + +def _get_windows_task_state(service_name: str) -> ServiceState: + if shutil.which("schtasks") is None: + return ServiceState( + manager="Task Scheduler", + installed=False, + state="unknown", + detail="schtasks was not found", + ) + + result = _run_capture( + ["schtasks", "/Query", "/TN", service_name, "/FO", "LIST", "/V"] + ) + if result is None: + return ServiceState( + manager="Task Scheduler", + installed=False, + state="unknown", + detail="schtasks was not found", + ) + if result.returncode != 0: + return ServiceState( + manager="Task Scheduler", + installed=False, + state="not-installed", + detail=_first_output_line(result), + ) + + status = _parse_schtasks_field(result.stdout or "", "Status") or "unknown" + return ServiceState( + manager="Task Scheduler", + installed=True, + state=status.lower(), + detail=_parse_schtasks_field(result.stdout or "", "Task To Run"), + ) + + 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) + if system == "Windows": + return _get_windows_task_state(service_name) return ServiceState( manager="unknown", installed=False, @@ -612,6 +836,25 @@ def _stop_launch_agent(service_name: str, *, allow_missing: bool = False) -> Non _wait_for_launch_agent_state(service_name, loaded=False) +def _control_windows_task(service_name: str, action: str) -> None: + if shutil.which("schtasks") is None: + raise click.ClickException("schtasks was not found") + if not _windows_task_exists(service_name): + raise click.ClickException( + f"Scheduled task {service_name} does not exist. Run 'service install' first" + ) + + match action: + case "start": + command = ["schtasks", "/Run", "/TN", service_name] + case "stop": + command = ["schtasks", "/End", "/TN", service_name] + case _: + raise click.ClickException(f"Unsupported Windows task action: {action}") + + _run_checked(command, f"Failed to {action} the Windows scheduled task") + + def _control_service(service_name: str, action: str) -> None: system = platform.system() if system == "Linux": @@ -631,6 +874,17 @@ def _control_service(service_name: str, action: str) -> None: raise click.ClickException(f"Unsupported launchd action: {action}") return + if system == "Windows": + match action: + case "start" | "stop": + _control_windows_task(service_name, action) + case "restart": + _control_windows_task(service_name, "stop") + _control_windows_task(service_name, "start") + case _: + raise click.ClickException(f"Unsupported Windows task action: {action}") + return + raise click.ClickException(f"Unsupported platform: {system}") @@ -671,12 +925,27 @@ def _uninstall_launch_agent(service_name: str) -> Path: return plist_path +def _uninstall_windows_task(service_name: str) -> str: + if shutil.which("schtasks") is None: + raise click.ClickException("schtasks was not found") + if not _windows_task_exists(service_name): + raise click.ClickException(f"Scheduled task {service_name} does not exist") + + _run_checked( + ["schtasks", "/Delete", "/TN", service_name, "/F"], + "Failed to delete the Windows scheduled task", + ) + return service_name + + def _uninstall_service(service_name: str) -> Path | str: system = platform.system() if system == "Linux": return _uninstall_systemd_service(service_name) if system == "Darwin": return _uninstall_launch_agent(service_name) + if system == "Windows": + return _uninstall_windows_task(service_name) raise click.ClickException(f"Unsupported platform: {system}") @@ -862,7 +1131,7 @@ def _show_service_logs( _show_journal_logs(service_name, lines, follow) return - if system == "Darwin": + if system in {"Darwin", "Windows"}: out_log, err_log = _service_log_paths(service_name) paths = [out_log] if include_stderr: @@ -935,6 +1204,18 @@ def install( click.echo(f"LaunchAgent label: {_macos_label(service_name)}") return + if system == "Windows": + _install_windows_task( + service_name, + astrbot_executable, + astrbot_root, + force=force, + now=now, + ) + click.echo(f"Installed Windows scheduled task: {service_name}") + click.echo(f"Manage it with: schtasks /Query /TN {service_name}") + return + raise click.ClickException(f"Unsupported platform: {system}") @@ -1039,7 +1320,7 @@ def uninstall(name: str, force: bool) -> None: @click.option( "--include-stderr", is_flag=True, - help="Also show stderr logs on macOS.", + help="Also show stderr logs on macOS and Windows.", ) def logs( ctx: click.Context, diff --git a/docs/en/deploy/astrbot/package.md b/docs/en/deploy/astrbot/package.md index 1aad02fe65..ceda780a2f 100644 --- a/docs/en/deploy/astrbot/package.md +++ b/docs/en/deploy/astrbot/package.md @@ -36,9 +36,7 @@ The command uses the `astrbot` executable found on `PATH` (usually generated by - Linux: `systemd --user` - macOS: `LaunchAgent` - -> [!NOTE] -> `astrbot service` is not supported on Windows yet. Use `astrbot run` to start AstrBot in the foreground. +- Windows: Task Scheduler To specify the AstrBot working directory or executable path explicitly: @@ -70,7 +68,7 @@ astrbot service logs astrbot service logs -f ``` -On macOS, this shows stdout logs by default. To include stderr: +On macOS and Windows, this shows stdout logs by default. To include stderr: ```bash astrbot service logs --include-stderr diff --git a/docs/en/use/cli.md b/docs/en/use/cli.md index a0dbb1bdb5..a06a341c57 100644 --- a/docs/en/use/cli.md +++ b/docs/en/use/cli.md @@ -90,9 +90,7 @@ Each platform uses its native service manager: | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | - -> [!NOTE] -> `astrbot service` is not supported on Windows yet. Use `astrbot run` to start AstrBot in the foreground. +| Windows | Task Scheduler | ### Install @@ -192,9 +190,9 @@ Common options: | `--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. | +| `--include-stderr` | Also show stderr logs on macOS and Windows. | -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. +On macOS and Windows, `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 diff --git a/docs/zh/deploy/astrbot/package.md b/docs/zh/deploy/astrbot/package.md index 433b470321..6ad7c4df74 100644 --- a/docs/zh/deploy/astrbot/package.md +++ b/docs/zh/deploy/astrbot/package.md @@ -35,9 +35,7 @@ astrbot service install --now - Linux:`systemd --user` - macOS:`LaunchAgent` - -> [!NOTE] -> Windows 暂不支持 `astrbot service`。请先使用 `astrbot run` 前台启动。 +- Windows:任务计划程序 如果需要指定 AstrBot 工作目录或可执行文件路径,可以使用: @@ -69,7 +67,7 @@ astrbot service logs astrbot service logs -f ``` -macOS 下默认只显示标准输出日志;如需同时查看 stderr: +macOS 和 Windows 下默认只显示标准输出日志;如需同时查看 stderr: ```bash astrbot service logs --include-stderr diff --git a/docs/zh/use/cli.md b/docs/zh/use/cli.md index 71a19f7464..96174bd09d 100644 --- a/docs/zh/use/cli.md +++ b/docs/zh/use/cli.md @@ -90,9 +90,7 @@ astrbot run --reset-password | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | - -> [!NOTE] -> Windows 暂不支持 `astrbot service`。请先使用 `astrbot run` 前台启动。 +| Windows | 任务计划程序 | ### 安装服务 @@ -192,9 +190,9 @@ astrbot service logs -f | `--name ` | 指定服务名。 | | `-n, --lines ` | 显示最近 N 行,默认 200。 | | `-f, --follow` | 持续跟随日志输出。 | -| `--include-stderr` | 在 macOS 上同时显示 stderr 日志。 | +| `--include-stderr` | 在 macOS 和 Windows 上同时显示 stderr 日志。 | -macOS 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 +macOS 和 Windows 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 ### 启用应用日志文件 diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py index d4a53e534a..a76d040ed4 100644 --- a/tests/test_cli_service.py +++ b/tests/test_cli_service.py @@ -1,3 +1,4 @@ +import base64 import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -12,6 +13,7 @@ WebUIStatus, _build_launchd_plist, _build_systemd_unit, + _build_windows_task_xml, _check_webui, _get_app_log_config, _health_label, @@ -21,6 +23,12 @@ ) +def _decode_windows_encoded_command(task_xml: str) -> str: + marker = "-EncodedCommand " + encoded_command = task_xml.split(marker, 1)[1].split("<", 1)[0] + return base64.b64decode(encoded_command).decode("utf-16le") + + class _HealthyHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) @@ -127,13 +135,26 @@ def fake_run_checked(command, _failure_message): assert events.index("bootstrap") < events.index("kickstart") -def test_service_command_rejects_windows(monkeypatch): - monkeypatch.setattr(cmd_service.platform, "system", lambda: "Windows") - - result = CliRunner().invoke(service, ["start"]) - - assert result.exit_code == 1 - assert "not supported on Windows yet" in result.output +def test_windows_task_xml_uses_astrbot_executable_and_working_directory(): + task_xml = _build_windows_task_xml( + "astrbot", + Path("C:\\Users\\astrbot\\.local\\bin\\astrbot.exe"), + Path("C:\\Users\\astrbot\\AstrBot"), + ).decode("utf-16") + powershell_script = _decode_windows_encoded_command(task_xml) + + assert "powershell.exe" in task_xml + assert "true" in task_xml + assert "-WindowStyle Hidden" in task_xml + assert "Start-Process" in powershell_script + assert "-WindowStyle Hidden" in powershell_script + assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in powershell_script + assert "run" in powershell_script + assert "astrbot.out.log" in powershell_script + assert "astrbot.err.log" in powershell_script + assert ( + "C:\\Users\\astrbot\\AstrBot" in task_xml + ) def test_load_dashboard_port_reads_cmd_config(tmp_path): From e2c55fa740367e5ade0c0e0ea6572112fab3285a Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 19:49:48 +0800 Subject: [PATCH 6/8] feat(service): add UTF-8 environment variables to PowerShell script for Windows service --- astrbot/cli/commands/cmd_service.py | 2 ++ tests/test_cli_service.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py index 91e28f2dde..0fe6f0a3a8 100644 --- a/astrbot/cli/commands/cmd_service.py +++ b/astrbot/cli/commands/cmd_service.py @@ -366,6 +366,8 @@ def _build_windows_powershell_arguments( script = ( "$ErrorActionPreference = 'Stop'\n" "$env:PYTHONUNBUFFERED = '1'\n" + "$env:PYTHONUTF8 = '1'\n" + "$env:PYTHONIOENCODING = 'utf-8'\n" "$cmdExe = if ($env:COMSPEC) { $env:COMSPEC } else { 'cmd.exe' }\n" f"$astrbotCommand = {_quote_powershell_literal(_build_windows_cmd_line(service_name, executable))}\n" "$process = Start-Process " diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py index a76d040ed4..5c192362ab 100644 --- a/tests/test_cli_service.py +++ b/tests/test_cli_service.py @@ -146,6 +146,9 @@ def test_windows_task_xml_uses_astrbot_executable_and_working_directory(): assert "powershell.exe" in task_xml assert "true" in task_xml assert "-WindowStyle Hidden" in task_xml + assert "$env:PYTHONUNBUFFERED = '1'" in powershell_script + assert "$env:PYTHONUTF8 = '1'" in powershell_script + assert "$env:PYTHONIOENCODING = 'utf-8'" in powershell_script assert "Start-Process" in powershell_script assert "-WindowStyle Hidden" in powershell_script assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in powershell_script From 85e2560bf3c6ec70264e27ac501511173c7a4aa4 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 20:13:20 +0800 Subject: [PATCH 7/8] feat(service): update PowerShell script for Windows service to enhance logging and execution --- astrbot/cli/commands/cmd_service.py | 17 +++++++---------- tests/test_cli_service.py | 7 +++++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py index 0fe6f0a3a8..c0f444a46b 100644 --- a/astrbot/cli/commands/cmd_service.py +++ b/astrbot/cli/commands/cmd_service.py @@ -363,21 +363,18 @@ def _build_windows_powershell_arguments( executable: Path, workdir: Path, ) -> str: + out_log, err_log = _windows_service_log_paths(service_name) script = ( "$ErrorActionPreference = 'Stop'\n" "$env:PYTHONUNBUFFERED = '1'\n" "$env:PYTHONUTF8 = '1'\n" "$env:PYTHONIOENCODING = 'utf-8'\n" - "$cmdExe = if ($env:COMSPEC) { $env:COMSPEC } else { 'cmd.exe' }\n" - f"$astrbotCommand = {_quote_powershell_literal(_build_windows_cmd_line(service_name, executable))}\n" - "$process = Start-Process " - "-FilePath $cmdExe " - "-ArgumentList @('/d', '/c', $astrbotCommand) " - f"-WorkingDirectory {_quote_powershell_literal(workdir)} " - "-WindowStyle Hidden " - "-PassThru " - "-Wait\n" - "exit $process.ExitCode\n" + f"Set-Location -LiteralPath {_quote_powershell_literal(workdir)}\n" + f"$stdoutLog = {_quote_powershell_literal(out_log)}\n" + f"$stderrLog = {_quote_powershell_literal(err_log)}\n" + "New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stdoutLog) | Out-Null\n" + f"& {_quote_powershell_literal(executable)} run 1>> $stdoutLog 2>> $stderrLog\n" + "exit $LASTEXITCODE\n" ) encoded_script = base64.b64encode(script.encode("utf-16le")).decode("ascii") return ( diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py index 5c192362ab..97c4581228 100644 --- a/tests/test_cli_service.py +++ b/tests/test_cli_service.py @@ -149,8 +149,11 @@ def test_windows_task_xml_uses_astrbot_executable_and_working_directory(): assert "$env:PYTHONUNBUFFERED = '1'" in powershell_script assert "$env:PYTHONUTF8 = '1'" in powershell_script assert "$env:PYTHONIOENCODING = 'utf-8'" in powershell_script - assert "Start-Process" in powershell_script - assert "-WindowStyle Hidden" in powershell_script + assert "Set-Location -LiteralPath 'C:\\Users\\astrbot\\AstrBot'" in powershell_script + assert "New-Item -ItemType Directory -Force" in powershell_script + assert "& 'C:\\Users\\astrbot\\.local\\bin\\astrbot.exe' run" in powershell_script + assert "1>> $stdoutLog 2>> $stderrLog" in powershell_script + assert "Start-Process" not in powershell_script assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in powershell_script assert "run" in powershell_script assert "astrbot.out.log" in powershell_script From c95689402592f974239d0bbfe58f0f5e426fa2e8 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 22 May 2026 20:19:46 +0800 Subject: [PATCH 8/8] feat(service): remove Windows service support and update documentation --- astrbot/cli/commands/cmd_service.py | 301 +--------------------------- docs/en/deploy/astrbot/package.md | 6 +- docs/en/use/cli.md | 8 +- docs/zh/deploy/astrbot/package.md | 6 +- docs/zh/use/cli.md | 8 +- tests/test_cli_service.py | 46 +---- 6 files changed, 35 insertions(+), 340 deletions(-) diff --git a/astrbot/cli/commands/cmd_service.py b/astrbot/cli/commands/cmd_service.py index c0f444a46b..077a36fb74 100644 --- a/astrbot/cli/commands/cmd_service.py +++ b/astrbot/cli/commands/cmd_service.py @@ -1,4 +1,3 @@ -import base64 import copy import getpass import json @@ -8,7 +7,6 @@ import shutil import subprocess import sys -import tempfile import time from collections import deque from dataclasses import dataclass @@ -16,7 +14,6 @@ from textwrap import dedent from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen -from xml.etree import ElementTree import click @@ -27,7 +24,6 @@ DEFAULT_STATUS_TIMEOUT_SECONDS = 2.0 DEFAULT_LOG_LINES = 200 MACOS_LABEL_PREFIX = "app.astrbot" -WINDOWS_TASK_XML_NS = "http://schemas.microsoft.com/windows/2004/02/mit/task" @dataclass(frozen=True) @@ -244,8 +240,6 @@ def _service_log_paths(service_name: str) -> tuple[Path, Path]: system = platform.system() if system == "Darwin": log_dir = _macos_log_dir() - elif system == "Windows": - return _windows_service_log_paths(service_name) else: log_dir = get_astrbot_root() / "data" / "logs" return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" @@ -309,187 +303,6 @@ def _install_launch_agent( return plist_path -def _task_element( - parent: ElementTree.Element, - name: str, - text: str | None = None, - attrib: dict[str, str] | None = None, -) -> ElementTree.Element: - child = ElementTree.SubElement( - parent, f"{{{WINDOWS_TASK_XML_NS}}}{name}", attrib or {} - ) - if text is not None: - child.text = text - return child - - -def _windows_log_dir() -> Path: - local_app_data = os.environ.get("LOCALAPPDATA") - if local_app_data: - return Path(local_app_data) / "AstrBot" / "Logs" - return Path.home() / "AppData" / "Local" / "AstrBot" / "Logs" - - -def _windows_service_log_paths(service_name: str) -> tuple[Path, Path]: - log_dir = _windows_log_dir() - return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log" - - -def _windows_powershell_executable() -> str: - return "powershell.exe" - - -def _quote_windows_cmd_arg(value: Path | str) -> str: - escaped = str(value).replace('"', '""') - return f'"{escaped}"' - - -def _quote_powershell_literal(value: Path | str) -> str: - escaped = str(value).replace("'", "''") - return f"'{escaped}'" - - -def _build_windows_cmd_line(service_name: str, executable: Path) -> str: - out_log, err_log = _windows_service_log_paths(service_name) - return ( - f"{_quote_windows_cmd_arg(executable)} run " - f">> {_quote_windows_cmd_arg(out_log)} " - f"2>> {_quote_windows_cmd_arg(err_log)}" - ) - - -def _build_windows_powershell_arguments( - service_name: str, - executable: Path, - workdir: Path, -) -> str: - out_log, err_log = _windows_service_log_paths(service_name) - script = ( - "$ErrorActionPreference = 'Stop'\n" - "$env:PYTHONUNBUFFERED = '1'\n" - "$env:PYTHONUTF8 = '1'\n" - "$env:PYTHONIOENCODING = 'utf-8'\n" - f"Set-Location -LiteralPath {_quote_powershell_literal(workdir)}\n" - f"$stdoutLog = {_quote_powershell_literal(out_log)}\n" - f"$stderrLog = {_quote_powershell_literal(err_log)}\n" - "New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stdoutLog) | Out-Null\n" - f"& {_quote_powershell_literal(executable)} run 1>> $stdoutLog 2>> $stderrLog\n" - "exit $LASTEXITCODE\n" - ) - encoded_script = base64.b64encode(script.encode("utf-16le")).decode("ascii") - return ( - "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass " - f"-WindowStyle Hidden -EncodedCommand {encoded_script}" - ) - - -def _build_windows_task_xml( - service_name: str, - executable: Path, - workdir: Path, -) -> bytes: - ElementTree.register_namespace("", WINDOWS_TASK_XML_NS) - task = ElementTree.Element( - f"{{{WINDOWS_TASK_XML_NS}}}Task", - {"version": "1.4"}, - ) - - registration_info = _task_element(task, "RegistrationInfo") - _task_element(registration_info, "Description", "AstrBot Service") - - triggers = _task_element(task, "Triggers") - logon_trigger = _task_element(triggers, "LogonTrigger") - _task_element(logon_trigger, "Enabled", "true") - - principals = _task_element(task, "Principals") - principal = _task_element(principals, "Principal", attrib={"id": "Author"}) - _task_element(principal, "LogonType", "InteractiveToken") - _task_element(principal, "RunLevel", "LeastPrivilege") - - settings = _task_element(task, "Settings") - _task_element(settings, "MultipleInstancesPolicy", "IgnoreNew") - _task_element(settings, "DisallowStartIfOnBatteries", "false") - _task_element(settings, "StopIfGoingOnBatteries", "false") - _task_element(settings, "AllowHardTerminate", "true") - _task_element(settings, "StartWhenAvailable", "true") - _task_element(settings, "RunOnlyIfNetworkAvailable", "false") - _task_element(settings, "AllowStartOnDemand", "true") - _task_element(settings, "Enabled", "true") - _task_element(settings, "Hidden", "true") - _task_element(settings, "RunOnlyIfIdle", "false") - _task_element(settings, "WakeToRun", "false") - _task_element(settings, "ExecutionTimeLimit", "PT0S") - _task_element(settings, "Priority", "7") - restart = _task_element(settings, "RestartOnFailure") - _task_element(restart, "Interval", "PT1M") - _task_element(restart, "Count", "3") - - actions = _task_element(task, "Actions", attrib={"Context": "Author"}) - exec_action = _task_element(actions, "Exec") - _task_element(exec_action, "Command", _windows_powershell_executable()) - _task_element( - exec_action, - "Arguments", - _build_windows_powershell_arguments(service_name, executable, workdir), - ) - _task_element(exec_action, "WorkingDirectory", str(workdir)) - - ElementTree.indent(task, space=" ") - return ElementTree.tostring(task, encoding="utf-16", xml_declaration=True) - - -def _windows_task_exists(service_name: str) -> bool: - result = subprocess.run( - ["schtasks", "/Query", "/TN", service_name], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return result.returncode == 0 - - -def _install_windows_task( - service_name: str, - executable: Path, - workdir: Path, - *, - force: bool, - now: bool, -) -> None: - if platform.system() != "Windows": - raise click.ClickException( - "Windows scheduled task installation is only available on Windows" - ) - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if _windows_task_exists(service_name) and not force: - raise click.ClickException( - f"Scheduled task {service_name} already exists. Use --force to overwrite" - ) - - _windows_log_dir().mkdir(parents=True, exist_ok=True) - task_xml = _build_windows_task_xml(service_name, executable, workdir) - temp_path = None - try: - with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f: - temp_path = Path(f.name) - f.write(task_xml) - - command = ["schtasks", "/Create", "/TN", service_name, "/XML", str(temp_path)] - if force: - command.append("/F") - _run_checked(command, "Failed to create the Windows scheduled task") - finally: - if temp_path is not None: - temp_path.unlink(missing_ok=True) - - if now: - _run_checked( - ["schtasks", "/Run", "/TN", service_name], - "Failed to start the Windows scheduled task", - ) - - def _first_output_line(result: subprocess.CompletedProcess[str]) -> str | None: text = (result.stdout or result.stderr).strip() if not text: @@ -594,58 +407,12 @@ def _get_launchd_state(service_name: str) -> ServiceState: ) -def _parse_schtasks_field(output: str, field_name: str) -> str | None: - prefix = f"{field_name}:" - for line in output.splitlines(): - if line.startswith(prefix): - return line.removeprefix(prefix).strip() - return None - - -def _get_windows_task_state(service_name: str) -> ServiceState: - if shutil.which("schtasks") is None: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="unknown", - detail="schtasks was not found", - ) - - result = _run_capture( - ["schtasks", "/Query", "/TN", service_name, "/FO", "LIST", "/V"] - ) - if result is None: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="unknown", - detail="schtasks was not found", - ) - if result.returncode != 0: - return ServiceState( - manager="Task Scheduler", - installed=False, - state="not-installed", - detail=_first_output_line(result), - ) - - status = _parse_schtasks_field(result.stdout or "", "Status") or "unknown" - return ServiceState( - manager="Task Scheduler", - installed=True, - state=status.lower(), - detail=_parse_schtasks_field(result.stdout or "", "Task To Run"), - ) - - 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) - if system == "Windows": - return _get_windows_task_state(service_name) return ServiceState( manager="unknown", installed=False, @@ -835,25 +602,6 @@ def _stop_launch_agent(service_name: str, *, allow_missing: bool = False) -> Non _wait_for_launch_agent_state(service_name, loaded=False) -def _control_windows_task(service_name: str, action: str) -> None: - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if not _windows_task_exists(service_name): - raise click.ClickException( - f"Scheduled task {service_name} does not exist. Run 'service install' first" - ) - - match action: - case "start": - command = ["schtasks", "/Run", "/TN", service_name] - case "stop": - command = ["schtasks", "/End", "/TN", service_name] - case _: - raise click.ClickException(f"Unsupported Windows task action: {action}") - - _run_checked(command, f"Failed to {action} the Windows scheduled task") - - def _control_service(service_name: str, action: str) -> None: system = platform.system() if system == "Linux": @@ -873,17 +621,6 @@ def _control_service(service_name: str, action: str) -> None: raise click.ClickException(f"Unsupported launchd action: {action}") return - if system == "Windows": - match action: - case "start" | "stop": - _control_windows_task(service_name, action) - case "restart": - _control_windows_task(service_name, "stop") - _control_windows_task(service_name, "start") - case _: - raise click.ClickException(f"Unsupported Windows task action: {action}") - return - raise click.ClickException(f"Unsupported platform: {system}") @@ -924,27 +661,12 @@ def _uninstall_launch_agent(service_name: str) -> Path: return plist_path -def _uninstall_windows_task(service_name: str) -> str: - if shutil.which("schtasks") is None: - raise click.ClickException("schtasks was not found") - if not _windows_task_exists(service_name): - raise click.ClickException(f"Scheduled task {service_name} does not exist") - - _run_checked( - ["schtasks", "/Delete", "/TN", service_name, "/F"], - "Failed to delete the Windows scheduled task", - ) - return service_name - - -def _uninstall_service(service_name: str) -> Path | str: +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) - if system == "Windows": - return _uninstall_windows_task(service_name) raise click.ClickException(f"Unsupported platform: {system}") @@ -1130,7 +852,7 @@ def _show_service_logs( _show_journal_logs(service_name, lines, follow) return - if system in {"Darwin", "Windows"}: + if system == "Darwin": out_log, err_log = _service_log_paths(service_name) paths = [out_log] if include_stderr: @@ -1171,9 +893,12 @@ def install( ) -> 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) - system = platform.system() if system == "Linux": service_path = _install_systemd_user_service( @@ -1203,18 +928,6 @@ def install( click.echo(f"LaunchAgent label: {_macos_label(service_name)}") return - if system == "Windows": - _install_windows_task( - service_name, - astrbot_executable, - astrbot_root, - force=force, - now=now, - ) - click.echo(f"Installed Windows scheduled task: {service_name}") - click.echo(f"Manage it with: schtasks /Query /TN {service_name}") - return - raise click.ClickException(f"Unsupported platform: {system}") @@ -1319,7 +1032,7 @@ def uninstall(name: str, force: bool) -> None: @click.option( "--include-stderr", is_flag=True, - help="Also show stderr logs on macOS and Windows.", + help="Also show stderr logs on macOS.", ) def logs( ctx: click.Context, diff --git a/docs/en/deploy/astrbot/package.md b/docs/en/deploy/astrbot/package.md index ceda780a2f..36a7e84f10 100644 --- a/docs/en/deploy/astrbot/package.md +++ b/docs/en/deploy/astrbot/package.md @@ -36,7 +36,9 @@ The command uses the `astrbot` executable found on `PATH` (usually generated by - Linux: `systemd --user` - macOS: `LaunchAgent` -- Windows: Task Scheduler + +> [!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: @@ -68,7 +70,7 @@ astrbot service logs astrbot service logs -f ``` -On macOS and Windows, this shows stdout logs by default. To include stderr: +On macOS, this shows stdout logs by default. To include stderr: ```bash astrbot service logs --include-stderr diff --git a/docs/en/use/cli.md b/docs/en/use/cli.md index a06a341c57..6f0a7a2524 100644 --- a/docs/en/use/cli.md +++ b/docs/en/use/cli.md @@ -90,7 +90,9 @@ Each platform uses its native service manager: | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | -| Windows | Task Scheduler | + +> [!NOTE] +> `astrbot service` is not supported on Windows. Use `astrbot run` in the foreground or another process manager. ### Install @@ -190,9 +192,9 @@ Common options: | `--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 and Windows. | +| `--include-stderr` | Also show stderr logs on macOS. | -On macOS and Windows, `astrbot service logs` shows stdout logs by default, which are the `.out.log` files. Add `--include-stderr` when you also need error output. +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 diff --git a/docs/zh/deploy/astrbot/package.md b/docs/zh/deploy/astrbot/package.md index 6ad7c4df74..b841609950 100644 --- a/docs/zh/deploy/astrbot/package.md +++ b/docs/zh/deploy/astrbot/package.md @@ -35,7 +35,9 @@ astrbot service install --now - Linux:`systemd --user` - macOS:`LaunchAgent` -- Windows:任务计划程序 + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。 如果需要指定 AstrBot 工作目录或可执行文件路径,可以使用: @@ -67,7 +69,7 @@ astrbot service logs astrbot service logs -f ``` -macOS 和 Windows 下默认只显示标准输出日志;如需同时查看 stderr: +macOS 下默认只显示标准输出日志;如需同时查看 stderr: ```bash astrbot service logs --include-stderr diff --git a/docs/zh/use/cli.md b/docs/zh/use/cli.md index 96174bd09d..adb2837a02 100644 --- a/docs/zh/use/cli.md +++ b/docs/zh/use/cli.md @@ -90,7 +90,9 @@ astrbot run --reset-password | --- | --- | | Linux | `systemd --user` | | macOS | LaunchAgent | -| Windows | 任务计划程序 | + +> [!NOTE] +> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。 ### 安装服务 @@ -190,9 +192,9 @@ astrbot service logs -f | `--name ` | 指定服务名。 | | `-n, --lines ` | 显示最近 N 行,默认 200。 | | `-f, --follow` | 持续跟随日志输出。 | -| `--include-stderr` | 在 macOS 和 Windows 上同时显示 stderr 日志。 | +| `--include-stderr` | 在 macOS 上同时显示 stderr 日志。 | -macOS 和 Windows 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 +macOS 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。 ### 启用应用日志文件 diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py index 97c4581228..8668447d1b 100644 --- a/tests/test_cli_service.py +++ b/tests/test_cli_service.py @@ -1,4 +1,3 @@ -import base64 import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -13,7 +12,6 @@ WebUIStatus, _build_launchd_plist, _build_systemd_unit, - _build_windows_task_xml, _check_webui, _get_app_log_config, _health_label, @@ -23,12 +21,6 @@ ) -def _decode_windows_encoded_command(task_xml: str) -> str: - marker = "-EncodedCommand " - encoded_command = task_xml.split(marker, 1)[1].split("<", 1)[0] - return base64.b64decode(encoded_command).decode("utf-16le") - - class _HealthyHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) @@ -70,6 +62,16 @@ def test_service_install_requires_initialized_root(monkeypatch, tmp_path): 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", @@ -135,34 +137,6 @@ def fake_run_checked(command, _failure_message): assert events.index("bootstrap") < events.index("kickstart") -def test_windows_task_xml_uses_astrbot_executable_and_working_directory(): - task_xml = _build_windows_task_xml( - "astrbot", - Path("C:\\Users\\astrbot\\.local\\bin\\astrbot.exe"), - Path("C:\\Users\\astrbot\\AstrBot"), - ).decode("utf-16") - powershell_script = _decode_windows_encoded_command(task_xml) - - assert "powershell.exe" in task_xml - assert "true" in task_xml - assert "-WindowStyle Hidden" in task_xml - assert "$env:PYTHONUNBUFFERED = '1'" in powershell_script - assert "$env:PYTHONUTF8 = '1'" in powershell_script - assert "$env:PYTHONIOENCODING = 'utf-8'" in powershell_script - assert "Set-Location -LiteralPath 'C:\\Users\\astrbot\\AstrBot'" in powershell_script - assert "New-Item -ItemType Directory -Force" in powershell_script - assert "& 'C:\\Users\\astrbot\\.local\\bin\\astrbot.exe' run" in powershell_script - assert "1>> $stdoutLog 2>> $stderrLog" in powershell_script - assert "Start-Process" not in powershell_script - assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in powershell_script - assert "run" in powershell_script - assert "astrbot.out.log" in powershell_script - assert "astrbot.err.log" in powershell_script - assert ( - "C:\\Users\\astrbot\\AstrBot" in task_xml - ) - - def test_load_dashboard_port_reads_cmd_config(tmp_path): config_path = tmp_path / "data" / "cmd_config.json" config_path.parent.mkdir()