feat(cli): optimize installation command and support initializing user password

This commit is contained in:
jxxghp
2026-04-16 23:43:20 +08:00
parent 810cb0a203
commit eea7e3b55f
4 changed files with 284 additions and 4 deletions

View File

@@ -12,6 +12,8 @@ CONFIG_DIR=""
RUN_WIZARD="true"
START_AFTER_INSTALL="true"
NON_INTERACTIVE="false"
SUPERUSER=""
SUPERUSER_PASSWORD=""
OS_NAME="Unknown"
PYTHON_BIN=""
BREW_BIN=""
@@ -32,6 +34,8 @@ Options:
--app-dir NAME MoviePilot 目录名,默认 ${APP_DIR_NAME}
--repo-url URL 主项目仓库地址
--config-dir PATH 配置目录,默认使用程序目录外的系统配置目录
--superuser NAME 预设超级管理员用户名
--superuser-password PWD 预设超级管理员密码
--link-path PATH 全局 moviepilot 软链接位置
--no-link-cli 安装完成后不创建全局 moviepilot 命令
--no-wizard 跳过 moviepilot setup 的交互式初始化向导
@@ -43,6 +47,7 @@ Examples:
$(basename "$0")
$(basename "$0") --workdir ~/Projects
$(basename "$0") --config-dir ~/.config/moviepilot-local
$(basename "$0") --superuser admin --superuser-password 'ChangeMe123!'
$(basename "$0") --non-interactive --workdir ~/Projects --no-start
EOF
}
@@ -572,6 +577,14 @@ while [[ $# -gt 0 ]]; do
CONFIG_DIR="$2"
shift 2
;;
--superuser)
SUPERUSER="$2"
shift 2
;;
--superuser-password)
SUPERUSER_PASSWORD="$2"
shift 2
;;
--link-path)
LINK_PATH="$2"
shift 2
@@ -629,6 +642,12 @@ SETUP_ARGS=(setup --python "$PYTHON_BIN" --config-dir "$CONFIG_DIR")
if [[ "$RUN_WIZARD" == "true" ]]; then
SETUP_ARGS+=(--wizard)
fi
if [[ -n "$SUPERUSER" ]]; then
SETUP_ARGS+=(--superuser "$SUPERUSER")
fi
if [[ -n "$SUPERUSER_PASSWORD" ]]; then
SETUP_ARGS+=(--superuser-password "$SUPERUSER_PASSWORD")
fi
if [[ "$HAS_TTY" == "true" ]]; then
"$PYTHON_BIN" ./scripts/local_setup.py "${SETUP_ARGS[@]}" <"$PROMPT_INPUT"
else

View File

@@ -9,6 +9,7 @@ import json
import os
import platform
import secrets
import re
import shlex
import shutil
import subprocess
@@ -716,6 +717,86 @@ def _prompt_path(label: str, *, default: Path, allow_empty: bool = False) -> str
return str(Path(value).expanduser().resolve())
def _validate_superuser_name(username: str) -> Optional[str]:
if not username:
return "超级管理员用户名不能为空。"
if any(char.isspace() for char in username):
return "超级管理员用户名不能包含空白字符。"
if len(username) > 64:
return "超级管理员用户名长度不能超过 64 个字符。"
return None
def _validate_superuser_password(password: str) -> Optional[str]:
if len(password) < 6 or len(password) > 50:
return "超级管理员密码长度需为 6 到 50 位。"
categories = 0
if re.search(r"[A-Za-z]", password):
categories += 1
if re.search(r"\d", password):
categories += 1
if re.search(r"[^\w\s]", password):
categories += 1
if categories < 2:
return "超级管理员密码需至少包含字母、数字、特殊字符中的两类。"
return None
def _collect_superuser_config(
*,
preset_username: Optional[str] = None,
preset_password: Optional[str] = None,
) -> dict[str, str]:
print_step("超级管理员配置")
default_username = (preset_username or _env_default("SUPERUSER", "admin")).strip() or "admin"
while True:
username = _prompt_text("超级管理员用户名", default=default_username).strip()
error = _validate_superuser_name(username)
if not error:
break
print(error)
if preset_password is not None:
password = preset_password.strip()
if not password:
return {"SUPERUSER": username}
error = _validate_superuser_password(password)
if error:
raise RuntimeError(error)
return {
"SUPERUSER": username,
"SUPERUSER_PASSWORD": password,
}
current_password = read_env_value("SUPERUSER_PASSWORD")
while True:
password = _prompt_secret_text(
"超级管理员密码(留空则保留现有值或首次启动时随机生成)",
current_value=current_password,
allow_empty=True,
).strip()
if not password:
return {"SUPERUSER": username}
error = _validate_superuser_password(password)
if error:
print(error)
continue
confirmed = _prompt_secret_text("请再次输入超级管理员密码", required=True).strip()
if password != confirmed:
print("两次输入的超级管理员密码不一致,请重新输入。")
continue
return {
"SUPERUSER": username,
"SUPERUSER_PASSWORD": password,
}
def _collect_path_mapping() -> list[tuple[str, str]]:
if not _prompt_yes_no("是否配置下载器路径映射", default=False):
return []
@@ -1159,7 +1240,12 @@ def _collect_site_auth_config(runtime_python: Optional[Path] = None) -> Optional
}
def run_setup_wizard(force_token: bool, runtime_python: Optional[Path] = None) -> dict[str, Any]:
def run_setup_wizard(
force_token: bool,
runtime_python: Optional[Path] = None,
preset_superuser: Optional[str] = None,
preset_superuser_password: Optional[str] = None,
) -> dict[str, Any]:
if not _is_interactive():
raise RuntimeError("交互式向导需要在终端中运行,请直接执行 moviepilot setup --wizard 或 moviepilot init --wizard")
@@ -1193,6 +1279,10 @@ def run_setup_wizard(force_token: bool, runtime_python: Optional[Path] = None) -
return {
"api_token": api_token,
"env_settings": {
**_collect_superuser_config(
preset_username=preset_superuser,
preset_password=preset_superuser_password,
),
**_collect_database_config(),
**_collect_agent_config(),
},
@@ -1275,7 +1365,11 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None:
raise RuntimeError("当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup") from exc
init_db()
generated_password = _prepare_superuser_password_for_bootstrap()
update_db()
_ensure_superuser_account_inner()
if generated_password:
print_step(f"超级管理员初始密码:{generated_password}")
system_config = SystemConfigOper()
directory_items = config_payload.get("directories") or []
@@ -1335,6 +1429,112 @@ def _current_python_matches(target_python: Optional[Path]) -> bool:
return str(current_python) == str(target_python)
def _ensure_superuser_account_inner() -> None:
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.core.config import settings
from app.core.security import get_password_hash
from app.db.user_oper import UserOper
username = str(settings.SUPERUSER or "").strip()
username_error = _validate_superuser_name(username)
if username_error:
raise RuntimeError(username_error)
password = str(settings.SUPERUSER_PASSWORD or "").strip()
if password:
password_error = _validate_superuser_password(password)
if password_error:
raise RuntimeError(password_error)
user_oper = UserOper()
user = user_oper.get_by_name(username)
if not user:
init_password = password or secrets.token_urlsafe(16)
user_oper.add(
name=username,
email="admin@movie-pilot.org",
hashed_password=get_password_hash(init_password),
is_active=True,
is_superuser=True,
avatar="",
)
print_step(f"已创建超级管理员用户:{username}")
if not password:
print_step(f"超级管理员初始密码:{init_password}")
return
update_payload: dict[str, Any] = {}
if not user.is_active:
update_payload["is_active"] = True
if not user.is_superuser:
update_payload["is_superuser"] = True
if password:
update_payload["hashed_password"] = get_password_hash(password)
if update_payload:
user.update(user_oper._db, update_payload)
if password:
print_step(f"已同步超级管理员账号与密码:{username}")
else:
print_step(f"已同步超级管理员账号权限:{username}")
else:
print_step(f"已确认超级管理员账号:{username}")
def _prepare_superuser_password_for_bootstrap() -> Optional[str]:
from app.core.config import settings
from app.db.user_oper import UserOper
username = str(settings.SUPERUSER or "").strip()
username_error = _validate_superuser_name(username)
if username_error:
raise RuntimeError(username_error)
if str(settings.SUPERUSER_PASSWORD or "").strip():
return None
if UserOper().get_by_name(username):
return None
generated_password = secrets.token_urlsafe(16)
settings.SUPERUSER_PASSWORD = generated_password
return generated_password
def _sync_superuser_account_inner() -> None:
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
try:
from app.db.init import init_db, update_db
except ModuleNotFoundError as exc:
raise RuntimeError("当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup") from exc
init_db()
generated_password = _prepare_superuser_password_for_bootstrap()
update_db()
_ensure_superuser_account_inner()
if generated_password:
print_step(f"超级管理员初始密码:{generated_password}")
def sync_superuser_account(runtime_python: Optional[Path] = None) -> None:
if _current_python_matches(runtime_python):
_sync_superuser_account_inner()
return
run(
[
str(runtime_python),
str(Path(__file__).resolve()),
"sync-superuser",
],
cwd=ROOT,
)
def apply_local_system_config(config_payload: dict[str, Any], runtime_python: Optional[Path] = None) -> None:
if _current_python_matches(runtime_python):
_apply_local_system_config_inner(config_payload)
@@ -1366,15 +1566,40 @@ def init_local(
resources_ready: bool,
force_token: bool,
wizard: bool,
superuser: Optional[str],
superuser_password: Optional[str],
runtime_python: Optional[Path] = None,
) -> None:
ensure_local_dirs()
wizard_payload: Optional[dict[str, Any]] = None
direct_env_settings: dict[str, str] = {}
if superuser:
superuser = superuser.strip()
error = _validate_superuser_name(superuser)
if error:
raise RuntimeError(error)
direct_env_settings["SUPERUSER"] = superuser
if superuser_password is not None:
superuser_password = superuser_password.strip()
if superuser_password:
error = _validate_superuser_password(superuser_password)
if error:
raise RuntimeError(error)
direct_env_settings["SUPERUSER_PASSWORD"] = superuser_password
if wizard:
wizard_payload = run_setup_wizard(force_token=force_token, runtime_python=runtime_python)
wizard_payload = run_setup_wizard(
force_token=force_token,
runtime_python=runtime_python,
preset_superuser=direct_env_settings.get("SUPERUSER"),
preset_superuser_password=direct_env_settings.get("SUPERUSER_PASSWORD"),
)
else:
ensure_api_token(force_token=force_token)
if direct_env_settings:
write_env_values(direct_env_settings)
print_step(f"已写入环境配置到 {ENV_FILE}")
if wizard_payload and wizard_payload.get("env_settings"):
write_env_values(wizard_payload["env_settings"])
@@ -1390,6 +1615,8 @@ def init_local(
if wizard_payload:
apply_local_system_config(wizard_payload, runtime_python=runtime_python)
elif direct_env_settings:
sync_superuser_account(runtime_python=runtime_python)
def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
@@ -1571,6 +1798,8 @@ def build_parser() -> argparse.ArgumentParser:
init_parser.add_argument("--skip-resources", action="store_true", help="只初始化配置,不同步资源文件")
init_parser.add_argument("--force-token", action="store_true", help="强制重置 API_TOKEN")
init_parser.add_argument("--wizard", action="store_true", help="启动交互式初始化向导")
init_parser.add_argument("--superuser", help="预设超级管理员用户名")
init_parser.add_argument("--superuser-password", help="预设超级管理员密码")
init_parser.add_argument("--config-dir", help="配置目录,默认使用程序目录外的系统配置目录")
setup_parser = subparsers.add_parser("setup", help="执行 install-deps、install-frontend、install-resources 和 init")
@@ -1584,6 +1813,8 @@ def build_parser() -> argparse.ArgumentParser:
setup_parser.add_argument("--skip-resources", action="store_true", help="只初始化配置,不同步资源文件")
setup_parser.add_argument("--force-token", action="store_true", help="强制重置 API_TOKEN")
setup_parser.add_argument("--wizard", action="store_true", help="安装完成后启动交互式初始化向导")
setup_parser.add_argument("--superuser", help="预设超级管理员用户名")
setup_parser.add_argument("--superuser-password", help="预设超级管理员密码")
setup_parser.add_argument("--config-dir", help="配置目录,默认使用程序目录外的系统配置目录")
agent_parser = subparsers.add_parser("agent", help="直接向 MoviePilot 智能体发送一次请求")
@@ -1607,6 +1838,9 @@ def build_parser() -> argparse.ArgumentParser:
apply_config_parser = subparsers.add_parser("apply-config", help=argparse.SUPPRESS)
apply_config_parser.add_argument("--config-json-file", required=True, help=argparse.SUPPRESS)
sync_superuser_parser = subparsers.add_parser("sync-superuser", help=argparse.SUPPRESS)
sync_superuser_parser.add_argument("--config-dir", help=argparse.SUPPRESS)
query_auth_sites_parser = subparsers.add_parser("query-auth-sites", help=argparse.SUPPRESS)
query_auth_sites_parser.add_argument("--output-json-file", required=True, help=argparse.SUPPRESS)
@@ -1654,6 +1888,8 @@ def main() -> int:
resources_ready=False,
force_token=args.force_token,
wizard=args.wizard,
superuser=args.superuser,
superuser_password=args.superuser_password,
runtime_python=None,
)
print_step("初始化完成")
@@ -1681,6 +1917,8 @@ def main() -> int:
resources_ready=resources_installed,
force_token=args.force_token,
wizard=args.wizard,
superuser=args.superuser,
superuser_password=args.superuser_password,
runtime_python=venv_python,
)
print_step(f"本地环境已完成安装与初始化:{venv_python}")
@@ -1724,6 +1962,10 @@ def main() -> int:
_apply_local_system_config_inner(payload)
return 0
if args.command == "sync-superuser":
_sync_superuser_account_inner()
return 0
if args.command == "query-auth-sites":
payload = _load_auth_site_definitions_inner()
Path(args.output_json_file).write_text(