mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
Add uninstall workflow to local CLI
This commit is contained in:
+23
@@ -41,6 +41,8 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
|
|||||||
- macOS:`~/Library/Application Support/MoviePilot`
|
- macOS:`~/Library/Application Support/MoviePilot`
|
||||||
- Linux:`${XDG_CONFIG_HOME:-~/.config}/moviepilot`
|
- Linux:`${XDG_CONFIG_HOME:-~/.config}/moviepilot`
|
||||||
|
|
||||||
|
如果在交互式终端中执行一键安装脚本,或直接执行 `moviepilot setup` / `moviepilot init` 且未传入 `--config-dir`,程序会先询问配置目录,并把上面的默认路径作为默认值展示出来。
|
||||||
|
|
||||||
可以在安装或初始化时手动指定:
|
可以在安装或初始化时手动指定:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
@@ -80,6 +82,7 @@ moviepilot commands
|
|||||||
moviepilot help install
|
moviepilot help install
|
||||||
moviepilot help init
|
moviepilot help init
|
||||||
moviepilot help setup
|
moviepilot help setup
|
||||||
|
moviepilot help uninstall
|
||||||
moviepilot help update
|
moviepilot help update
|
||||||
moviepilot help agent
|
moviepilot help agent
|
||||||
moviepilot help config
|
moviepilot help config
|
||||||
@@ -111,6 +114,7 @@ moviepilot install frontend
|
|||||||
moviepilot install resources
|
moviepilot install resources
|
||||||
moviepilot init
|
moviepilot init
|
||||||
moviepilot setup
|
moviepilot setup
|
||||||
|
moviepilot uninstall
|
||||||
moviepilot update backend
|
moviepilot update backend
|
||||||
moviepilot update frontend
|
moviepilot update frontend
|
||||||
moviepilot update all
|
moviepilot update all
|
||||||
@@ -244,6 +248,25 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
|
|||||||
- `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中
|
- `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中
|
||||||
- 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码
|
- 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码
|
||||||
|
|
||||||
|
## 卸载命令
|
||||||
|
|
||||||
|
卸载本地安装产物:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
moviepilot uninstall
|
||||||
|
moviepilot uninstall --venv /path/to/venv
|
||||||
|
moviepilot uninstall --config-dir /path/to/moviepilot-config
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 卸载时会先停止当前 CLI 管理的前后端服务
|
||||||
|
- 会删除本地虚拟环境、前端运行时、本地 Node 运行时、全局 `moviepilot` 软链接,以及同步到 `app/helper` 的资源文件
|
||||||
|
- 会询问是否同时删除配置目录,默认不删除
|
||||||
|
- 如果当前使用的是仓库内 legacy `config/` 目录,确认删除后其中的 `category.yaml` 等配置文件也会一起删除
|
||||||
|
- 整个卸载流程包含两次确认
|
||||||
|
- 源码目录会保留,如需彻底移除仓库请在确认后手动删除项目目录
|
||||||
|
|
||||||
## 更新命令
|
## 更新命令
|
||||||
|
|
||||||
更新后端:
|
更新后端:
|
||||||
|
|||||||
+30
@@ -14,6 +14,7 @@ Bootstrap Commands:
|
|||||||
moviepilot install resources [--resources-repo PATH] [--resource-dir PATH] [--config-dir PATH]
|
moviepilot install resources [--resources-repo PATH] [--resource-dir PATH] [--config-dir PATH]
|
||||||
moviepilot init [--skip-resources] [--force-token] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
|
moviepilot init [--skip-resources] [--force-token] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
|
||||||
moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
|
moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
|
||||||
|
moviepilot uninstall [--venv PATH] [--config-dir PATH]
|
||||||
moviepilot update {backend|frontend|all} [OPTIONS]
|
moviepilot update {backend|frontend|all} [OPTIONS]
|
||||||
moviepilot agent [OPTIONS] MESSAGE...
|
moviepilot agent [OPTIONS] MESSAGE...
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ Discovery Commands:
|
|||||||
moviepilot help
|
moviepilot help
|
||||||
moviepilot help config
|
moviepilot help config
|
||||||
moviepilot help install
|
moviepilot help install
|
||||||
|
moviepilot help uninstall
|
||||||
moviepilot help update
|
moviepilot help update
|
||||||
moviepilot commands
|
moviepilot commands
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ Examples:
|
|||||||
moviepilot install frontend
|
moviepilot install frontend
|
||||||
moviepilot install resources
|
moviepilot install resources
|
||||||
moviepilot setup --wizard
|
moviepilot setup --wizard
|
||||||
|
moviepilot uninstall
|
||||||
moviepilot update all
|
moviepilot update all
|
||||||
moviepilot agent 帮我分析最近一次搜索失败
|
moviepilot agent 帮我分析最近一次搜索失败
|
||||||
moviepilot help config
|
moviepilot help config
|
||||||
@@ -52,6 +55,7 @@ Bootstrap Commands
|
|||||||
install resources
|
install resources
|
||||||
init
|
init
|
||||||
setup
|
setup
|
||||||
|
uninstall
|
||||||
update backend
|
update backend
|
||||||
update frontend
|
update frontend
|
||||||
update all
|
update all
|
||||||
@@ -145,6 +149,22 @@ Options:
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
show_uninstall_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: moviepilot uninstall [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--venv PATH 虚拟环境目录,默认 ./venv
|
||||||
|
--config-dir PATH 指定配置目录,默认使用当前安装配置
|
||||||
|
-h, --help 显示帮助
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 默认保留配置目录,过程中会询问是否删除
|
||||||
|
- 卸载时会进行两次确认
|
||||||
|
- 源码目录不会被删除
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
show_update_help() {
|
show_update_help() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage:
|
Usage:
|
||||||
@@ -296,6 +316,10 @@ show_command_help() {
|
|||||||
show_setup_help
|
show_setup_help
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
|
uninstall)
|
||||||
|
show_uninstall_help
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
agent)
|
agent)
|
||||||
show_agent_help
|
show_agent_help
|
||||||
exit 0
|
exit 0
|
||||||
@@ -397,6 +421,12 @@ case "${1:-}" in
|
|||||||
require_bootstrap_python
|
require_bootstrap_python
|
||||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" setup "$@"
|
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" setup "$@"
|
||||||
;;
|
;;
|
||||||
|
uninstall)
|
||||||
|
shift
|
||||||
|
require_bootstrap_python
|
||||||
|
COMMAND_PATH="$(command -v moviepilot 2>/dev/null || true)"
|
||||||
|
MOVIEPILOT_LAUNCH_PATH="$0" MOVIEPILOT_COMMAND_PATH="$COMMAND_PATH" exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" uninstall "$@"
|
||||||
|
;;
|
||||||
update)
|
update)
|
||||||
shift
|
shift
|
||||||
require_bootstrap_python
|
require_bootstrap_python
|
||||||
|
|||||||
+317
-4
@@ -17,6 +17,7 @@ import sys
|
|||||||
import tarfile
|
import tarfile
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -85,6 +86,8 @@ NOTIFICATION_SWITCH_TYPES = [
|
|||||||
"智能体",
|
"智能体",
|
||||||
"其它",
|
"其它",
|
||||||
]
|
]
|
||||||
|
UNINSTALL_CONFIRM_TEXT = "UNINSTALL"
|
||||||
|
RESOURCE_FILE_PATTERNS = ("sites*", "user.sites*.bin")
|
||||||
|
|
||||||
|
|
||||||
def _default_config_dir() -> Path:
|
def _default_config_dir() -> Path:
|
||||||
@@ -201,6 +204,31 @@ def configure_config_dir(
|
|||||||
return config_dir
|
return config_dir
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config_dir(
|
||||||
|
explicit: Optional[Path] = None,
|
||||||
|
*,
|
||||||
|
prefer_external: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
解析当前命令应使用的配置目录,但不写入环境变量或安装元数据。
|
||||||
|
|
||||||
|
该函数用于交互式命令在真正持久化配置目录前,先给用户展示默认值。
|
||||||
|
"""
|
||||||
|
if explicit:
|
||||||
|
return explicit.expanduser().resolve()
|
||||||
|
if os.getenv("CONFIG_DIR"):
|
||||||
|
return Path(os.environ["CONFIG_DIR"]).expanduser().resolve()
|
||||||
|
|
||||||
|
install_env_dir = _read_install_env_config_dir()
|
||||||
|
if install_env_dir:
|
||||||
|
return install_env_dir.resolve()
|
||||||
|
if prefer_external:
|
||||||
|
return _default_config_dir().resolve()
|
||||||
|
if _legacy_runtime_config_exists():
|
||||||
|
return LEGACY_CONFIG_DIR.resolve()
|
||||||
|
return _default_config_dir().resolve()
|
||||||
|
|
||||||
|
|
||||||
configure_config_dir()
|
configure_config_dir()
|
||||||
|
|
||||||
|
|
||||||
@@ -842,6 +870,23 @@ def _prompt_path(label: str, *, default: Path, allow_empty: bool = False) -> str
|
|||||||
return str(Path(value).expanduser().resolve())
|
return str(Path(value).expanduser().resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_interactive_config_dir(
|
||||||
|
command: str, explicit_config_dir: Optional[Path]
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
`setup` / `init` 是最常见的本地安装入口。
|
||||||
|
当用户没有显式传入 `--config-dir` 且当前终端可交互时,先询问一次配置目录,
|
||||||
|
并把程序外默认路径展示出来,避免用户安装后才发现配置写到了别处。
|
||||||
|
"""
|
||||||
|
if explicit_config_dir or command not in {"init", "setup"} or not _is_interactive():
|
||||||
|
return explicit_config_dir
|
||||||
|
|
||||||
|
default_config_dir = resolve_config_dir(prefer_external=True)
|
||||||
|
print_step("安装将使用程序目录外的配置目录,直接回车可接受默认值")
|
||||||
|
selected_path = _prompt_path("配置目录", default=default_config_dir)
|
||||||
|
return Path(selected_path) if selected_path else default_config_dir
|
||||||
|
|
||||||
|
|
||||||
def _validate_superuser_name(username: str) -> Optional[str]:
|
def _validate_superuser_name(username: str) -> Optional[str]:
|
||||||
if not username:
|
if not username:
|
||||||
return "超级管理员用户名不能为空。"
|
return "超级管理员用户名不能为空。"
|
||||||
@@ -1843,6 +1888,22 @@ def _pid_exists(pid: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _read_process_start_time(pid: int) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
output = capture(["ps", "-p", str(pid), "-o", "lstart="])
|
||||||
|
except (OSError, subprocess.CalledProcessError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
started = output.strip()
|
||||||
|
if not started:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return datetime.strptime(started, "%a %b %d %H:%M:%S %Y").timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _services_running() -> list[str]:
|
def _services_running() -> list[str]:
|
||||||
running: list[str] = []
|
running: list[str] = []
|
||||||
runtime_files = {
|
runtime_files = {
|
||||||
@@ -1852,8 +1913,27 @@ def _services_running() -> list[str]:
|
|||||||
for name, runtime_file in runtime_files.items():
|
for name, runtime_file in runtime_files.items():
|
||||||
payload = _read_runtime_file(runtime_file)
|
payload = _read_runtime_file(runtime_file)
|
||||||
pid = payload.get("pid") if isinstance(payload, dict) else None
|
pid = payload.get("pid") if isinstance(payload, dict) else None
|
||||||
if pid and _pid_exists(int(pid)):
|
if not pid:
|
||||||
running.append(name)
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid_int = int(pid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not _pid_exists(pid_int):
|
||||||
|
continue
|
||||||
|
|
||||||
|
runtime_start_time = payload.get("create_time") if isinstance(payload, dict) else None
|
||||||
|
process_start_time = _read_process_start_time(pid_int)
|
||||||
|
if runtime_start_time is not None and process_start_time is not None:
|
||||||
|
try:
|
||||||
|
if abs(process_start_time - float(runtime_start_time)) > 3:
|
||||||
|
continue
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
running.append(name)
|
||||||
return running
|
return running
|
||||||
|
|
||||||
|
|
||||||
@@ -1866,6 +1946,208 @@ def ensure_services_stopped() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_managed_services(venv_dir: Path) -> None:
|
||||||
|
venv_dir = venv_dir.expanduser().resolve()
|
||||||
|
venv_python = get_venv_python(venv_dir)
|
||||||
|
if venv_python.exists():
|
||||||
|
print_step("停止本地前后端服务")
|
||||||
|
run(
|
||||||
|
[str(venv_python), "-m", "app.cli", "stop", "--timeout", "30", "--force"],
|
||||||
|
cwd=ROOT,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
running = _services_running()
|
||||||
|
if running:
|
||||||
|
raise RuntimeError(
|
||||||
|
"检测到本地服务仍在运行(%s),但当前未找到虚拟环境 %s,无法安全停止。"
|
||||||
|
" 请先执行 `moviepilot stop`,或在卸载时通过 `--venv PATH` 指定正确的虚拟环境目录。"
|
||||||
|
% (", ".join(running), venv_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_cli_link_candidates(
|
||||||
|
*, command_path: Optional[str] = None, launch_path: Optional[str] = None
|
||||||
|
) -> list[Path]:
|
||||||
|
candidates: list[Path] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
raw_candidates = [
|
||||||
|
launch_path,
|
||||||
|
command_path,
|
||||||
|
os.getenv("MOVIEPILOT_LAUNCH_PATH"),
|
||||||
|
os.getenv("MOVIEPILOT_COMMAND_PATH"),
|
||||||
|
shutil.which("moviepilot"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for raw_value in raw_candidates:
|
||||||
|
if not raw_value:
|
||||||
|
continue
|
||||||
|
candidate = Path(raw_value).expanduser()
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = (ROOT / candidate).resolve()
|
||||||
|
try:
|
||||||
|
key = str(candidate.resolve())
|
||||||
|
except OSError:
|
||||||
|
key = str(candidate)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
candidates.append(candidate)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_cli_symlinks(
|
||||||
|
*, command_path: Optional[str] = None, launch_path: Optional[str] = None
|
||||||
|
) -> list[Path]:
|
||||||
|
removed: list[Path] = []
|
||||||
|
script_path = (ROOT / "moviepilot").resolve()
|
||||||
|
|
||||||
|
for candidate in _collect_cli_link_candidates(
|
||||||
|
command_path=command_path, launch_path=launch_path
|
||||||
|
):
|
||||||
|
if not candidate.is_symlink():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if candidate.resolve() != script_path:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
candidate.unlink()
|
||||||
|
removed.append(candidate)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_runtime_state_files() -> list[Path]:
|
||||||
|
removed: list[Path] = []
|
||||||
|
for path in (
|
||||||
|
TEMP_DIR / "moviepilot.runtime.json",
|
||||||
|
TEMP_DIR / "moviepilot.frontend.runtime.json",
|
||||||
|
):
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
_remove_path(path)
|
||||||
|
removed.append(path)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_installed_resource_files() -> list[Path]:
|
||||||
|
removed: list[Path] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for pattern in RESOURCE_FILE_PATTERNS:
|
||||||
|
for path in sorted(HELPER_DIR.glob(pattern)):
|
||||||
|
if path in seen or not path.exists() or path.is_dir():
|
||||||
|
continue
|
||||||
|
_remove_path(path)
|
||||||
|
removed.append(path)
|
||||||
|
seen.add(path)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_config_data(config_dir: Path) -> list[Path]:
|
||||||
|
config_dir = config_dir.expanduser().resolve()
|
||||||
|
removed: list[Path] = []
|
||||||
|
|
||||||
|
if config_dir.exists():
|
||||||
|
_remove_path(config_dir)
|
||||||
|
removed.append(config_dir)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall_local(
|
||||||
|
*,
|
||||||
|
venv_dir: Path,
|
||||||
|
config_dir: Path,
|
||||||
|
command_path: Optional[str] = None,
|
||||||
|
launch_path: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not _is_interactive():
|
||||||
|
raise RuntimeError("卸载命令需要在交互式终端中运行,以完成两次确认。")
|
||||||
|
|
||||||
|
venv_dir = venv_dir.expanduser().resolve()
|
||||||
|
config_dir = config_dir.expanduser().resolve()
|
||||||
|
cli_links = _collect_cli_link_candidates(
|
||||||
|
command_path=command_path, launch_path=launch_path
|
||||||
|
)
|
||||||
|
script_path = (ROOT / "moviepilot").resolve()
|
||||||
|
linked_cli_paths = [
|
||||||
|
path
|
||||||
|
for path in cli_links
|
||||||
|
if path.is_symlink() and path.exists() and path.resolve() == script_path
|
||||||
|
]
|
||||||
|
|
||||||
|
delete_config = _prompt_yes_no(
|
||||||
|
f"是否同时删除配置目录 {config_dir}", default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
print_step("卸载将执行以下操作")
|
||||||
|
print(f" - 保留源码目录:{ROOT}")
|
||||||
|
print(f" - 删除虚拟环境:{venv_dir}")
|
||||||
|
print(f" - 删除前端运行时目录:{PUBLIC_DIR}")
|
||||||
|
print(f" - 删除本地 Node 运行时目录:{RUNTIME_DIR}")
|
||||||
|
print(f" - 删除资源文件:{HELPER_DIR}/sites*、{HELPER_DIR}/user.sites*.bin")
|
||||||
|
if linked_cli_paths:
|
||||||
|
print(" - 删除全局 CLI 软链接:")
|
||||||
|
for path in linked_cli_paths:
|
||||||
|
print(f" {path}")
|
||||||
|
else:
|
||||||
|
print(" - 未检测到指向当前仓库的全局 CLI 软链接")
|
||||||
|
|
||||||
|
if delete_config:
|
||||||
|
print(f" - 删除配置目录:{config_dir}")
|
||||||
|
if config_dir == LEGACY_CONFIG_DIR.resolve():
|
||||||
|
print(" 包括 legacy config 目录中的 category.yaml 等配置文件")
|
||||||
|
else:
|
||||||
|
print(f" - 保留配置目录:{config_dir}")
|
||||||
|
|
||||||
|
if not _prompt_yes_no("第一次确认:是否继续卸载 MoviePilot", default=False):
|
||||||
|
print_step("已取消卸载")
|
||||||
|
return {"cancelled": True}
|
||||||
|
|
||||||
|
confirm_text = _prompt_text(
|
||||||
|
f"第二次确认:请输入 {UNINSTALL_CONFIRM_TEXT} 以继续",
|
||||||
|
allow_empty=False,
|
||||||
|
)
|
||||||
|
if confirm_text != UNINSTALL_CONFIRM_TEXT:
|
||||||
|
print_step("确认文本不匹配,已取消卸载")
|
||||||
|
return {"cancelled": True}
|
||||||
|
|
||||||
|
_stop_managed_services(venv_dir=venv_dir)
|
||||||
|
|
||||||
|
removed_paths: list[Path] = []
|
||||||
|
removed_paths.extend(
|
||||||
|
_remove_cli_symlinks(command_path=command_path, launch_path=launch_path)
|
||||||
|
)
|
||||||
|
removed_paths.extend(_remove_runtime_state_files())
|
||||||
|
removed_paths.extend(_remove_installed_resource_files())
|
||||||
|
for path in (venv_dir, RUNTIME_DIR, PUBLIC_DIR):
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
_remove_path(path)
|
||||||
|
removed_paths.append(path)
|
||||||
|
|
||||||
|
removed_config_paths: list[Path] = []
|
||||||
|
if delete_config:
|
||||||
|
removed_config_paths = _remove_config_data(config_dir)
|
||||||
|
removed_paths.extend(removed_config_paths)
|
||||||
|
if INSTALL_ENV_FILE.exists():
|
||||||
|
_remove_path(INSTALL_ENV_FILE)
|
||||||
|
removed_paths.append(INSTALL_ENV_FILE)
|
||||||
|
|
||||||
|
print_step("卸载完成")
|
||||||
|
if delete_config:
|
||||||
|
print_step(f"已删除配置目录:{config_dir}")
|
||||||
|
else:
|
||||||
|
print_step(f"已保留配置目录:{config_dir}")
|
||||||
|
print_step(f"源码目录仍保留在:{ROOT}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cancelled": False,
|
||||||
|
"config_deleted": delete_config,
|
||||||
|
"removed_paths": [str(path) for path in removed_paths],
|
||||||
|
"removed_config_paths": [str(path) for path in removed_config_paths],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _git_output(*args: str) -> str:
|
def _git_output(*args: str) -> str:
|
||||||
return capture(["git", *args], cwd=ROOT)
|
return capture(["git", *args], cwd=ROOT)
|
||||||
|
|
||||||
@@ -2068,6 +2350,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
"--config-dir", help="配置目录,默认使用程序目录外的系统配置目录"
|
"--config-dir", help="配置目录,默认使用程序目录外的系统配置目录"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
uninstall_parser = subparsers.add_parser(
|
||||||
|
"uninstall", help="卸载本地安装产物,并可选删除配置目录"
|
||||||
|
)
|
||||||
|
uninstall_parser.add_argument(
|
||||||
|
"--venv", default=str(ROOT / "venv"), help="虚拟环境目录"
|
||||||
|
)
|
||||||
|
uninstall_parser.add_argument(
|
||||||
|
"--config-dir", help="配置目录,默认使用当前安装配置"
|
||||||
|
)
|
||||||
|
|
||||||
agent_parser = subparsers.add_parser(
|
agent_parser = subparsers.add_parser(
|
||||||
"agent", help="直接向 MoviePilot 智能体发送一次请求"
|
"agent", help="直接向 MoviePilot 智能体发送一次请求"
|
||||||
)
|
)
|
||||||
@@ -2140,10 +2432,22 @@ def main() -> int:
|
|||||||
explicit_config_dir = (
|
explicit_config_dir = (
|
||||||
Path(args.config_dir) if getattr(args, "config_dir", None) else None
|
Path(args.config_dir) if getattr(args, "config_dir", None) else None
|
||||||
)
|
)
|
||||||
|
explicit_config_dir = _resolve_interactive_config_dir(
|
||||||
|
args.command, explicit_config_dir
|
||||||
|
)
|
||||||
|
persist_config_commands = {
|
||||||
|
"install-deps",
|
||||||
|
"install-frontend",
|
||||||
|
"install-resources",
|
||||||
|
"init",
|
||||||
|
"setup",
|
||||||
|
"agent",
|
||||||
|
"update",
|
||||||
|
}
|
||||||
config_dir = configure_config_dir(
|
config_dir = configure_config_dir(
|
||||||
explicit=explicit_config_dir,
|
explicit=explicit_config_dir,
|
||||||
persist=True,
|
persist=args.command in persist_config_commands,
|
||||||
prefer_external=True,
|
prefer_external=args.command in persist_config_commands,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2226,6 +2530,15 @@ def main() -> int:
|
|||||||
print_step(f"当前配置目录:{config_dir}")
|
print_step(f"当前配置目录:{config_dir}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.command == "uninstall":
|
||||||
|
uninstall_local(
|
||||||
|
venv_dir=Path(args.venv),
|
||||||
|
config_dir=config_dir,
|
||||||
|
command_path=os.getenv("MOVIEPILOT_COMMAND_PATH"),
|
||||||
|
launch_path=os.getenv("MOVIEPILOT_LAUNCH_PATH"),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
if args.command == "agent":
|
if args.command == "agent":
|
||||||
result = run_agent_request(
|
result = run_agent_request(
|
||||||
message=" ".join(args.message),
|
message=" ".join(args.message),
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_setup_module():
|
||||||
|
module_name = f"moviepilot_local_setup_config_{uuid.uuid4().hex}"
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSetupConfigDirTests(unittest.TestCase):
|
||||||
|
def test_setup_prompts_for_config_dir_when_not_provided(self):
|
||||||
|
module = load_local_setup_module()
|
||||||
|
default_dir = Path("/tmp/default-moviepilot-config")
|
||||||
|
custom_dir = Path("/tmp/custom-moviepilot-config")
|
||||||
|
|
||||||
|
with patch.object(module, "_is_interactive", return_value=True), patch.object(
|
||||||
|
module, "resolve_config_dir", return_value=default_dir
|
||||||
|
), patch.object(
|
||||||
|
module, "_prompt_path", return_value=str(custom_dir)
|
||||||
|
):
|
||||||
|
result = module._resolve_interactive_config_dir("setup", None)
|
||||||
|
|
||||||
|
self.assertEqual(result, custom_dir)
|
||||||
|
|
||||||
|
def test_setup_keeps_default_config_dir_when_user_accepts_default(self):
|
||||||
|
module = load_local_setup_module()
|
||||||
|
default_dir = Path("/tmp/default-moviepilot-config")
|
||||||
|
|
||||||
|
with patch.object(module, "_is_interactive", return_value=True), patch.object(
|
||||||
|
module, "resolve_config_dir", return_value=default_dir
|
||||||
|
), patch.object(
|
||||||
|
module, "_prompt_path", return_value=str(default_dir)
|
||||||
|
):
|
||||||
|
result = module._resolve_interactive_config_dir("init", None)
|
||||||
|
|
||||||
|
self.assertEqual(result, default_dir)
|
||||||
|
|
||||||
|
def test_non_setup_command_does_not_prompt_for_config_dir(self):
|
||||||
|
module = load_local_setup_module()
|
||||||
|
|
||||||
|
with patch.object(module, "_is_interactive", return_value=True), patch.object(
|
||||||
|
module, "_prompt_path"
|
||||||
|
) as prompt_mock:
|
||||||
|
result = module._resolve_interactive_config_dir("install-deps", None)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
prompt_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_setup_module():
|
||||||
|
module_name = f"moviepilot_local_setup_{uuid.uuid4().hex}"
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSetupUninstallTests(unittest.TestCase):
|
||||||
|
def prepare_install_tree(self, *, legacy_config: bool = False):
|
||||||
|
module = load_local_setup_module()
|
||||||
|
temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temp_dir.cleanup)
|
||||||
|
|
||||||
|
temp_path = Path(temp_dir.name)
|
||||||
|
root_dir = temp_path / "MoviePilot"
|
||||||
|
helper_dir = root_dir / "app" / "helper"
|
||||||
|
runtime_dir = root_dir / ".runtime"
|
||||||
|
public_dir = root_dir / "public"
|
||||||
|
venv_dir = root_dir / "venv"
|
||||||
|
install_env_file = root_dir / ".moviepilot.env"
|
||||||
|
config_dir = root_dir / "config" if legacy_config else temp_path / "moviepilot-config"
|
||||||
|
temp_config_dir = config_dir / "temp"
|
||||||
|
|
||||||
|
helper_dir.mkdir(parents=True)
|
||||||
|
runtime_dir.mkdir(parents=True)
|
||||||
|
public_dir.mkdir(parents=True)
|
||||||
|
venv_dir.mkdir(parents=True)
|
||||||
|
temp_config_dir.mkdir(parents=True)
|
||||||
|
install_env_file.write_text("CONFIG_DIR=/tmp/moviepilot-config\n", encoding="utf-8")
|
||||||
|
(root_dir / "moviepilot").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||||
|
(helper_dir / "sites.py").write_text("generated\n", encoding="utf-8")
|
||||||
|
(helper_dir / "user.sites.v2.bin").write_bytes(b"binary")
|
||||||
|
(temp_config_dir / "moviepilot.runtime.json").write_text("{}", encoding="utf-8")
|
||||||
|
(temp_config_dir / "moviepilot.frontend.runtime.json").write_text(
|
||||||
|
"{}", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
stack = ExitStack()
|
||||||
|
self.addCleanup(stack.close)
|
||||||
|
stack.enter_context(patch.object(module, "ROOT", root_dir))
|
||||||
|
stack.enter_context(patch.object(module, "HELPER_DIR", helper_dir))
|
||||||
|
stack.enter_context(patch.object(module, "RUNTIME_DIR", runtime_dir))
|
||||||
|
stack.enter_context(patch.object(module, "PUBLIC_DIR", public_dir))
|
||||||
|
stack.enter_context(patch.object(module, "INSTALL_ENV_FILE", install_env_file))
|
||||||
|
stack.enter_context(patch.object(module, "LEGACY_CONFIG_DIR", root_dir / "config"))
|
||||||
|
stack.enter_context(patch.object(module, "CONFIG_DIR", config_dir))
|
||||||
|
stack.enter_context(patch.object(module, "TEMP_DIR", temp_config_dir))
|
||||||
|
|
||||||
|
return module, root_dir, config_dir, venv_dir, install_env_file
|
||||||
|
|
||||||
|
def test_remove_config_data_deletes_legacy_config_directory(self):
|
||||||
|
module, _, config_dir, _, _ = self.prepare_install_tree(legacy_config=True)
|
||||||
|
category_file = config_dir / "category.yaml"
|
||||||
|
category_file.write_text("seed\n", encoding="utf-8")
|
||||||
|
(config_dir / "logs").mkdir(exist_ok=True)
|
||||||
|
(config_dir / "user.db").write_text("db\n", encoding="utf-8")
|
||||||
|
|
||||||
|
removed = module._remove_config_data(config_dir)
|
||||||
|
|
||||||
|
self.assertFalse(config_dir.exists())
|
||||||
|
self.assertFalse(category_file.exists())
|
||||||
|
self.assertIn(
|
||||||
|
str(config_dir.resolve()),
|
||||||
|
{str(path.resolve()) for path in removed},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_uninstall_keeps_config_by_default(self):
|
||||||
|
module, root_dir, config_dir, venv_dir, install_env_file = self.prepare_install_tree()
|
||||||
|
cli_dir = root_dir.parent / "bin"
|
||||||
|
cli_dir.mkdir()
|
||||||
|
cli_link = cli_dir / "moviepilot"
|
||||||
|
cli_link.symlink_to(root_dir / "moviepilot")
|
||||||
|
|
||||||
|
yes_no_answers = iter([False, True])
|
||||||
|
with patch.object(module, "_is_interactive", return_value=True), patch.object(
|
||||||
|
module,
|
||||||
|
"_prompt_yes_no",
|
||||||
|
side_effect=lambda label, default=False: next(yes_no_answers),
|
||||||
|
), patch.object(
|
||||||
|
module,
|
||||||
|
"_prompt_text",
|
||||||
|
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
|
||||||
|
), patch.object(module, "_stop_managed_services", return_value=None):
|
||||||
|
result = module.uninstall_local(
|
||||||
|
venv_dir=venv_dir,
|
||||||
|
config_dir=config_dir,
|
||||||
|
launch_path=str(cli_link),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["cancelled"])
|
||||||
|
self.assertTrue(config_dir.exists())
|
||||||
|
self.assertTrue(install_env_file.exists())
|
||||||
|
self.assertFalse(venv_dir.exists())
|
||||||
|
self.assertFalse((root_dir / ".runtime").exists())
|
||||||
|
self.assertFalse((root_dir / "public").exists())
|
||||||
|
self.assertFalse((root_dir / "app" / "helper" / "sites.py").exists())
|
||||||
|
self.assertFalse((root_dir / "app" / "helper" / "user.sites.v2.bin").exists())
|
||||||
|
self.assertFalse(cli_link.exists())
|
||||||
|
|
||||||
|
def test_uninstall_deletes_external_config_when_requested(self):
|
||||||
|
module, _, config_dir, venv_dir, install_env_file = self.prepare_install_tree()
|
||||||
|
yes_no_answers = iter([True, True])
|
||||||
|
|
||||||
|
with patch.object(module, "_is_interactive", return_value=True), patch.object(
|
||||||
|
module,
|
||||||
|
"_prompt_yes_no",
|
||||||
|
side_effect=lambda label, default=False: next(yes_no_answers),
|
||||||
|
), patch.object(
|
||||||
|
module,
|
||||||
|
"_prompt_text",
|
||||||
|
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
|
||||||
|
), patch.object(module, "_stop_managed_services", return_value=None):
|
||||||
|
result = module.uninstall_local(
|
||||||
|
venv_dir=venv_dir,
|
||||||
|
config_dir=config_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["cancelled"])
|
||||||
|
self.assertTrue(result["config_deleted"])
|
||||||
|
self.assertFalse(config_dir.exists())
|
||||||
|
self.assertFalse(install_env_file.exists())
|
||||||
|
|
||||||
|
def test_uninstall_deletes_legacy_config_when_requested(self):
|
||||||
|
module, _, config_dir, venv_dir, install_env_file = self.prepare_install_tree(
|
||||||
|
legacy_config=True
|
||||||
|
)
|
||||||
|
(config_dir / "category.yaml").write_text("seed\n", encoding="utf-8")
|
||||||
|
yes_no_answers = iter([True, True])
|
||||||
|
|
||||||
|
with patch.object(module, "_is_interactive", return_value=True), patch.object(
|
||||||
|
module,
|
||||||
|
"_prompt_yes_no",
|
||||||
|
side_effect=lambda label, default=False: next(yes_no_answers),
|
||||||
|
), patch.object(
|
||||||
|
module,
|
||||||
|
"_prompt_text",
|
||||||
|
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
|
||||||
|
), patch.object(module, "_stop_managed_services", return_value=None):
|
||||||
|
result = module.uninstall_local(
|
||||||
|
venv_dir=venv_dir,
|
||||||
|
config_dir=config_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["cancelled"])
|
||||||
|
self.assertTrue(result["config_deleted"])
|
||||||
|
self.assertFalse(config_dir.exists())
|
||||||
|
self.assertFalse(install_env_file.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user