diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py index de09e5852..97b92d1a5 100644 --- a/astrbot/cli/commands/cmd_run.py +++ b/astrbot/cli/commands/cmd_run.py @@ -9,6 +9,8 @@ from filelock import FileLock, Timeout from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root +DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_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="Reset dashboard initial password on startup", +) @click.command() -def run(reload: bool, port: str) -> None: +def run(reload: bool, port: str | None, 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 b7da0ff43..92df17114 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -16,6 +16,7 @@ from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP 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_RESET_DASHBOARD_PASSWORD" logger = logging.getLogger("astrbot") @@ -77,7 +78,11 @@ class AstrBotConfig(dict): ) # 检查配置完整性,并插入 has_new = self.check_config_integrity(default_config, conf) - if ( + reset_dashboard_password = self._consume_reset_dashboard_password_flag() + if reset_dashboard_password and "dashboard" in conf: + self._reset_generated_dashboard_password(conf) + has_new = True + elif ( "dashboard" in conf and isinstance(conf["dashboard"], dict) and not conf["dashboard"].get("pbkdf2_password") @@ -118,6 +123,11 @@ class AstrBotConfig(dict): True, ) + @staticmethod + def _consume_reset_dashboard_password_flag() -> bool: + raw_value = os.environ.pop(DASHBOARD_RESET_PASSWORD_ENV, "") + return raw_value.strip().lower() in {"1", "true", "yes", "on"} + @staticmethod def _resolve_initial_dashboard_password() -> str: env_password = os.environ.get(DASHBOARD_INITIAL_PASSWORD_ENV) diff --git a/docs/en/use/cli.md b/docs/en/use/cli.md new file mode 100644 index 000000000..077447cd4 --- /dev/null +++ b/docs/en/use/cli.md @@ -0,0 +1,169 @@ +# CLI Commands + +The AstrBot CLI initializes instances, starts AstrBot, 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. + +## 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 conf` | Read or update common config values. | +| `astrbot password` | Change the WebUI login password interactively. | +| `astrbot plug` | Create, install, update, remove, or search plugins. | +| `astrbot help` | Show CLI help. | +| `astrbot --version` | Show the AstrBot CLI version. | + +## Start AstrBot + +```bash +astrbot run +``` + +Common options: + +| Option | Purpose | +| --- | --- | +| `-p, --port ` | Set the WebUI port. | +| `-r, --reload` | Enable plugin auto-reload for plugin development. | +| `--reset-password` | Reset the WebUI initial password on startup and print the new password in startup logs. | + +Examples: + +```bash +astrbot run --port 6185 +astrbot run --reload +astrbot run --reset-password +``` + +If you forget the WebUI login password, run this from the AstrBot working directory: + +```bash +astrbot run --reset-password +``` + +AstrBot regenerates the initial password during startup and prints it in startup logs. After logging in, change the password in the WebUI immediately. + +When starting directly from source, you can also run: + +```bash +python main.py --reset-password +``` + +## Config + +`astrbot conf` reads and updates common config values. + +```bash +astrbot conf get +astrbot conf get dashboard.port +astrbot conf 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 conf set dashboard.password "new-password" +``` + +You can also use the dedicated interactive password command: + +```bash +astrbot password +astrbot password --username admin +``` + +## Plugins + +`astrbot plug` manages plugins under `data/plugins`. + +| Command | Purpose | +| --- | --- | +| `astrbot plug list` | List installed plugins. | +| `astrbot plug list --all` | Also show uninstalled plugins. | +| `astrbot plug search ` | Search plugins. | +| `astrbot plug install ` | Install a plugin. | +| `astrbot plug update [NAME]` | Update one plugin, or all updatable plugins if no name is given. | +| `astrbot plug remove ` | Remove an installed plugin. | +| `astrbot plug new ` | Create a new plugin from the template. | + +Use a GitHub proxy when installing or updating plugins: + +```bash +astrbot plug install example-plugin --proxy https://gh-proxy.example.com/ +astrbot plug update --proxy https://gh-proxy.example.com/ +``` + +Creating a new plugin asks for the author, description, version, and repository URL: + +```bash +astrbot plug new my-plugin +``` + +## Help + +Show general CLI help: + +```bash +astrbot help +``` + +Show help for a specific command: + +```bash +astrbot help run +astrbot run --help +astrbot help conf +astrbot plug --help +``` + +Show the version: + +```bash +astrbot --version +``` diff --git a/docs/zh/use/cli.md b/docs/zh/use/cli.md new file mode 100644 index 000000000..689534dc1 --- /dev/null +++ b/docs/zh/use/cli.md @@ -0,0 +1,169 @@ +# 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 工作目录中执行。 + +## 快速开始 + +第一次部署时先初始化目录,再启动 AstrBot: + +```bash +astrbot init +astrbot run +``` + +`astrbot init` 会在当前目录创建 AstrBot 所需的数据目录和配置文件。初始化完成后,后续启动只需要执行 `astrbot run`。 + +## 顶层指令 + +| 指令 | 用途 | +| --- | --- | +| `astrbot init` | 初始化当前目录为 AstrBot 工作目录。 | +| `astrbot run` | 在前台启动 AstrBot。 | +| `astrbot conf` | 查看或修改常用配置项。 | +| `astrbot password` | 交互式修改 WebUI 登录密码。 | +| `astrbot plug` | 创建、安装、更新、删除或搜索插件。 | +| `astrbot help` | 查看 CLI 帮助。 | +| `astrbot --version` | 查看 AstrBot CLI 版本。 | + +## 启动 AstrBot + +```bash +astrbot run +``` + +常用选项: + +| 选项 | 用途 | +| --- | --- | +| `-p, --port ` | 指定 WebUI 端口。 | +| `-r, --reload` | 启用插件自动重载,适合插件开发调试。 | +| `--reset-password` | 启动时重置 WebUI 初始密码,并在启动日志中打印新密码。 | + +示例: + +```bash +astrbot run --port 6185 +astrbot run --reload +astrbot run --reset-password +``` + +如果你忘记了 WebUI 登录密码,可以在 AstrBot 工作目录中执行: + +```bash +astrbot run --reset-password +``` + +AstrBot 会在启动时重新生成初始密码,并在启动日志中打印。登录后请立即在 WebUI 中修改密码。 + +使用源码方式直接启动时,也可以执行: + +```bash +python main.py --reset-password +``` + +## 配置 + +`astrbot conf` 用于查看和修改常用配置项。 + +```bash +astrbot conf get +astrbot conf get dashboard.port +astrbot conf 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 conf set dashboard.password "new-password" +``` + +也可以使用专门的交互式密码指令: + +```bash +astrbot password +astrbot password --username admin +``` + +## 插件 + +`astrbot plug` 用于管理 `data/plugins` 下的插件。 + +| 指令 | 用途 | +| --- | --- | +| `astrbot plug list` | 查看已安装插件。 | +| `astrbot plug list --all` | 同时显示未安装插件。 | +| `astrbot plug search ` | 搜索插件。 | +| `astrbot plug install ` | 安装插件。 | +| `astrbot plug update [NAME]` | 更新指定插件;不传名称时更新所有可更新插件。 | +| `astrbot plug remove ` | 删除已安装插件。 | +| `astrbot plug new ` | 基于模板创建新插件。 | + +安装或更新插件时可以使用 GitHub 代理: + +```bash +astrbot plug install example-plugin --proxy https://gh-proxy.example.com/ +astrbot plug update --proxy https://gh-proxy.example.com/ +``` + +创建新插件会交互式询问作者、描述、版本和仓库地址: + +```bash +astrbot plug new my-plugin +``` + +## 帮助 + +查看全部 CLI 帮助: + +```bash +astrbot help +``` + +查看指定指令帮助: + +```bash +astrbot help run +astrbot run --help +astrbot help conf +astrbot plug --help +``` + +查看版本: + +```bash +astrbot --version +``` diff --git a/main.py b/main.py index 9f62fdf41..1cb5f3c45 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,28 @@ import runtime_bootstrap runtime_bootstrap.initialize_runtime_bootstrap() +DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD" + + +def _apply_startup_env_flags(argv: list[str]) -> None: + """Apply startup flags that must take effect before core imports. + + Args: + argv: Command-line arguments excluding the executable name. + """ + + if "-h" in argv or "--help" in argv: + return + + startup_parser = argparse.ArgumentParser(add_help=False) + startup_parser.add_argument("--reset-password", action="store_true") + startup_args, _ = startup_parser.parse_known_args(argv) + if startup_args.reset_password: + os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1" + + +_apply_startup_env_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 @@ -141,6 +163,14 @@ if __name__ == "__main__": help="Specify the directory path for WebUI static files", default=None, ) + parser.add_argument( + "--reset-password", + action="store_true", + help=( + "Reset the dashboard initial password on startup and print it in " + "startup logs" + ), + ) 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 000000000..2f14a8894 --- /dev/null +++ b/tests/test_cli_run.py @@ -0,0 +1,43 @@ +import os +import sys + +from click.testing import CliRunner + +from astrbot.cli.commands import cmd_run + + +def test_run_reset_password_sets_startup_env(monkeypatch, tmp_path): + (tmp_path / ".astrbot").touch() + monkeypatch.chdir(tmp_path) + monkeypatch.delenv(cmd_run.DASHBOARD_RESET_PASSWORD_ENV, raising=False) + original_env = { + "ASTRBOT_CLI": os.environ.get("ASTRBOT_CLI"), + "ASTRBOT_ROOT": os.environ.get("ASTRBOT_ROOT"), + cmd_run.DASHBOARD_RESET_PASSWORD_ENV: os.environ.get( + cmd_run.DASHBOARD_RESET_PASSWORD_ENV + ), + } + original_sys_path = list(sys.path) + + called = False + + async def fake_run_astrbot(astrbot_root): + nonlocal called + called = True + assert astrbot_root == tmp_path + assert os.environ[cmd_run.DASHBOARD_RESET_PASSWORD_ENV] == "1" + + monkeypatch.setattr(cmd_run, "run_astrbot", fake_run_astrbot) + + try: + result = CliRunner().invoke(cmd_run.run, ["--reset-password"]) + finally: + for key, value in original_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + sys.path[:] = original_sys_path + + assert result.exit_code == 0, result.output + assert called is True diff --git a/tests/test_main.py b/tests/test_main.py index ae6036631..7ee5d02c0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,7 +10,12 @@ from unittest import mock import pytest from astrbot.core.utils.io import should_use_bundled_dashboard_dist -from main import check_dashboard_files, check_env +from main import ( + DASHBOARD_RESET_PASSWORD_ENV, + _apply_startup_env_flags, + check_dashboard_files, + check_env, +) class _version_info: @@ -62,6 +67,30 @@ def test_check_env(monkeypatch): check_env() +def test_apply_startup_env_flags_sets_reset_password_env(monkeypatch): + monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False) + + _apply_startup_env_flags(["--webui-dir", "/tmp/webui", "--reset-password"]) + + assert os.environ[DASHBOARD_RESET_PASSWORD_ENV] == "1" + + +def test_apply_startup_env_flags_ignores_unrelated_args(monkeypatch): + monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False) + + _apply_startup_env_flags(["--webui-dir", "/tmp/webui"]) + + assert DASHBOARD_RESET_PASSWORD_ENV not in os.environ + + +def test_apply_startup_env_flags_does_not_reset_for_help(monkeypatch): + monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False) + + _apply_startup_env_flags(["--reset-password", "--help"]) + + assert DASHBOARD_RESET_PASSWORD_ENV not in os.environ + + def test_check_env_appends_user_site_packages_after_runtime_paths(monkeypatch): astrbot_root = "/tmp/astrbot-root" site_packages_path = "/tmp/astrbot-site-packages" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d6747930d..56eebade0 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -10,6 +10,8 @@ from astrbot.core.config.default import DEFAULT_VALUE_MAP from astrbot.core.config.i18n_utils import ConfigMetadataI18n from astrbot.core.utils.auth_password import ( DEFAULT_DASHBOARD_PASSWORD, + hash_dashboard_password, + hash_md5_dashboard_password, validate_dashboard_password, verify_dashboard_password, ) @@ -320,6 +322,53 @@ class TestAstrBotConfigLoad: config["dashboard"]["password"], generated_password ) + def test_reset_dashboard_password_env_rotates_existing_password( + self, temp_config_path, monkeypatch + ): + """Test startup reset flag rotates an already configured dashboard password.""" + old_password = "OldPassword123" + default_config = { + "dashboard": { + "username": "astrbot", + "password": "", + "pbkdf2_password": "", + }, + } + with open(temp_config_path, "w", encoding="utf-8") as f: + json.dump( + { + "dashboard": { + "username": "astrbot", + "password": hash_md5_dashboard_password(old_password), + "pbkdf2_password": hash_dashboard_password(old_password), + "password_change_required": False, + "password_storage_upgraded": True, + } + }, + f, + ) + + monkeypatch.setenv("ASTRBOT_RESET_DASHBOARD_PASSWORD", "1") + config = AstrBotConfig( + config_path=temp_config_path, + default_config=default_config, + ) + generated_password = getattr(config, "_generated_dashboard_password", None) + + assert isinstance(generated_password, str) + assert config["dashboard"]["password_change_required"] is True + assert config["dashboard"]["password_storage_upgraded"] is True + assert "ASTRBOT_RESET_DASHBOARD_PASSWORD" not in os.environ + assert verify_dashboard_password( + config["dashboard"]["pbkdf2_password"], generated_password + ) + assert not verify_dashboard_password( + config["dashboard"]["pbkdf2_password"], old_password + ) + assert verify_dashboard_password( + config["dashboard"]["password"], generated_password + ) + def test_legacy_astrbot_user_without_change_flag_keeps_legacy_password( self, temp_config_path ):