From 1282ad5004f6706f8bc7d550a20fe0f7dbfd4e51 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 21 Apr 2026 11:26:25 +0800 Subject: [PATCH] feat: improve local CLI startup management --- app/cli.py | 217 ++++++++++- app/log.py | 12 +- app/main.py | 19 +- app/utils/stdio.py | 84 +++++ docs/cli.md | 28 ++ moviepilot | 34 ++ scripts/local_setup.py | 827 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1204 insertions(+), 17 deletions(-) create mode 100644 app/utils/stdio.py diff --git a/app/cli.py b/app/cli.py index addfe327..bf2f9a8f 100644 --- a/app/cli.py +++ b/app/cli.py @@ -1,5 +1,6 @@ import json import os +import re import shutil import subprocess import sys @@ -9,7 +10,7 @@ from pathlib import Path from typing import Any, Dict, Iterable, Optional, get_args, get_origin from urllib.error import HTTPError, URLError from urllib.parse import urlencode -from urllib.request import Request, urlopen +from urllib.request import ProxyHandler, Request, build_opener, urlopen import click import psutil @@ -28,7 +29,11 @@ FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt" HEALTH_PATH = "/api/v1/system/global" HEALTH_TOKEN = "moviepilot" FRONTEND_HEALTH_PATH = "/version.txt" +BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases" +FRONTEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases" LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"} +MANAGED_ACTIVE_STATES = {"running", "starting"} +AUTO_UPDATE_ENABLED_VALUES = {"true", "release", "dev"} MASKED_FIELDS = { "API_TOKEN", "DB_POSTGRESQL_PASSWORD", @@ -199,6 +204,173 @@ def _frontend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float = return False, None +def _warn(message: str) -> None: + click.secho(message, fg="yellow") + + +def _release_prefix(version: Optional[str]) -> str: + """ + 从版本号中提取主版本前缀,用于把本地自动更新限制在当前主版本线上。 + """ + matched = re.match(r"^(v\d+)", str(version or "").strip()) + return matched.group(1) if matched else "v2" + + +def _release_sort_key(tag: str) -> tuple[int, ...]: + return tuple(int(part) for part in re.findall(r"\d+", tag)) + + +def _github_api_json(url: str, *, repo: str) -> Any: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "MoviePilot-CLI", + } + headers.update(settings.REPO_GITHUB_HEADERS(repo)) + opener = build_opener(ProxyHandler(settings.PROXY or {})) + request = Request(url=url, headers=headers, method="GET") + + try: + with opener.open(request, timeout=10.0) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"访问 GitHub API 失败(HTTP {exc.code}): {detail or url}") from exc + except URLError as exc: + raise RuntimeError(f"访问 GitHub API 失败:{exc.reason}") from exc + except json.JSONDecodeError as exc: + raise RuntimeError(f"GitHub API 返回了无法解析的响应:{url}") from exc + + +def _latest_release_tag(url: str, *, repo: str, prefix: str) -> Optional[str]: + payload = _github_api_json(url, repo=repo) + if not isinstance(payload, list): + raise RuntimeError(f"GitHub API 返回格式异常:{url}") + + matched_tags = [] + for item in payload: + if not isinstance(item, dict): + continue + tag_name = str(item.get("tag_name") or "").strip() + if tag_name.startswith(f"{prefix}."): + matched_tags.append(tag_name) + + if not matched_tags: + return None + return sorted(matched_tags, key=_release_sort_key)[-1] + + +def _git_current_branch() -> Optional[str]: + try: + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=str(_repo_root()), + text=True, + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + return branch or None + + +def _auto_update_mode() -> str: + return str(getattr(settings, "MOVIEPILOT_AUTO_UPDATE", "") or "").strip().lower() + + +def _resolve_auto_update_targets(mode: str) -> tuple[Optional[str], Optional[str]]: + backend_prefix = _release_prefix(APP_VERSION) + frontend_prefix = _release_prefix(_installed_frontend_version() or APP_VERSION) + + if mode == "dev": + current_branch = _git_current_branch() + backend_ref = "latest" + if not current_branch or current_branch == "HEAD": + # 从 release 模式切回 dev 时,detached HEAD 需要一个明确分支。 + backend_ref = backend_prefix + else: + backend_ref = _latest_release_tag( + BACKEND_RELEASES_API, + repo="jxxghp/MoviePilot", + prefix=backend_prefix, + ) + + frontend_version = _latest_release_tag( + FRONTEND_RELEASES_API, + repo="jxxghp/MoviePilot-Frontend", + prefix=frontend_prefix, + ) + return backend_ref, frontend_version + + +def _best_effort_auto_update() -> None: + mode = _auto_update_mode() + if mode not in AUTO_UPDATE_ENABLED_VALUES: + return + + try: + backend_ref, frontend_version = _resolve_auto_update_targets(mode) + except RuntimeError as exc: + _warn(f"自动更新准备失败,继续使用当前版本启动:{exc}") + return + + if not backend_ref or not frontend_version: + _warn("自动更新准备失败,未能解析当前主版本对应的远端版本,继续使用当前版本启动") + return + + update_command = [ + sys.executable, + str(_repo_root() / "scripts" / "local_setup.py"), + "update", + "all", + "--ref", + backend_ref, + "--frontend-version", + frontend_version, + "--venv", + str(_repo_root() / "venv"), + "--config-dir", + str(settings.CONFIG_PATH), + ] + + update_env = os.environ.copy() + if settings.PROXY_HOST: + update_env.setdefault("http_proxy", settings.PROXY_HOST) + update_env.setdefault("https_proxy", settings.PROXY_HOST) + update_env.setdefault("HTTP_PROXY", settings.PROXY_HOST) + update_env.setdefault("HTTPS_PROXY", settings.PROXY_HOST) + if settings.GITHUB_TOKEN: + update_env.setdefault("GITHUB_TOKEN", settings.GITHUB_TOKEN) + + click.echo(f"检测到 MOVIEPILOT_AUTO_UPDATE={mode},启动前执行本地自动更新") + result = subprocess.run( + update_command, + cwd=str(_repo_root()), + env=update_env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="ignore", + check=False, + ) + if result.returncode == 0: + click.echo("本地自动更新完成") + return + + output_lines = [line for line in (result.stdout or "").splitlines() if line.strip()] + tail = output_lines[-1] if output_lines else "未知错误" + _warn(f"本地自动更新失败,继续使用当前版本启动:{tail}") + + +def _ensure_frontend_not_running_alone(timeout: int) -> None: + """ + 如果只检测到 CLI 管理的前端仍在运行,则先停掉它,再按统一顺序重启前后端。 + """ + backend_state, _, _, _ = _managed_backend_status() + frontend_state, _, _, _ = _managed_frontend_status() + if backend_state == "stopped" and frontend_state in MANAGED_ACTIVE_STATES: + click.echo("检测到仅前端仍在运行,先停止前端后再整体启动") + _stop_frontend_service(timeout=timeout, force=True) + + def _managed_backend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]]: runtime = _backend_runtime() process = _get_process(runtime) @@ -431,18 +603,27 @@ def _ensure_local_api_token() -> bool: return result is True -def _spawn_process(command: list[str], *, cwd: Path, log_file: Path, env: Optional[Dict[str, str]] = None) -> subprocess.Popen: - log_file.parent.mkdir(parents=True, exist_ok=True) - log_handle = log_file.open("a", encoding="utf-8") - +def _spawn_process( + command: list[str], + *, + cwd: Path, + log_file: Optional[Path], + env: Optional[Dict[str, str]] = None, +) -> subprocess.Popen: kwargs: Dict[str, Any] = { "cwd": str(cwd), - "stdout": log_handle, - "stderr": subprocess.STDOUT, "stdin": subprocess.DEVNULL, "close_fds": True, "env": env or os.environ.copy(), } + if log_file: + log_file.parent.mkdir(parents=True, exist_ok=True) + log_handle = log_file.open("a", encoding="utf-8") + kwargs["stdout"] = log_handle + kwargs["stderr"] = subprocess.STDOUT + else: + kwargs["stdout"] = subprocess.DEVNULL + kwargs["stderr"] = subprocess.DEVNULL if os.name == "nt": kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS else: @@ -454,8 +635,19 @@ def _spawn_backend_process() -> subprocess.Popen: return _spawn_process( [sys.executable, "-m", "app.main"], cwd=_repo_root(), - log_file=BACKEND_STDIO_LOG_FILE, - env={**os.environ, "PYTHONUNBUFFERED": "1"}, + log_file=None, + env={ + **os.environ, + "PYTHONUNBUFFERED": "1", + "MOVIEPILOT_DISABLE_CONSOLE_LOG": "1", + "MOVIEPILOT_STDIO_LOG_FILE": str(BACKEND_STDIO_LOG_FILE), + "MOVIEPILOT_STDIO_LOG_MAX_BYTES": str( + max(int(settings.LOG_MAX_FILE_SIZE or 0), 1) * 1024 * 1024 + ), + "MOVIEPILOT_STDIO_LOG_BACKUP_COUNT": str( + max(int(settings.LOG_BACKUP_COUNT or 0), 0) + ), + }, ) @@ -649,6 +841,12 @@ def cli() -> None: @click.option("--timeout", default=60, show_default=True, help="等待后端与前端就绪的秒数") def start(timeout: int) -> None: """后台启动本地 MoviePilot 前后端服务""" + _ensure_frontend_not_running_alone(timeout=min(timeout, 15)) + backend_state, _, _, _ = _managed_backend_status() + frontend_state, _, _, _ = _managed_frontend_status() + if backend_state == "stopped" and frontend_state == "stopped": + _best_effort_auto_update() + backend_result = _start_backend_service(timeout=timeout) backend_runtime = backend_result["runtime"] try: @@ -699,6 +897,7 @@ def restart(start_timeout: int, stop_timeout: int, force: bool) -> None: """重启本地 MoviePilot 前后端服务""" _stop_frontend_service(timeout=stop_timeout, force=force) _stop_backend_service(timeout=stop_timeout, force=force) + _best_effort_auto_update() backend_result = _start_backend_service(timeout=start_timeout) frontend_result = _start_frontend_service(timeout=start_timeout, backend_port=int(backend_result["runtime"]["port"])) click.echo("MoviePilot 已重启") diff --git a/app/log.py b/app/log.py index c4aed1d2..7a922dc8 100644 --- a/app/log.py +++ b/app/log.py @@ -1,5 +1,6 @@ import asyncio import logging +import os import queue import sys import threading @@ -407,11 +408,12 @@ class LoggerManager: for handler in _logger.handlers: _logger.removeHandler(handler) - # 只设置终端日志(文件日志由 NonBlockingFileHandler 处理) - console_handler = logging.StreamHandler() - console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT) - console_handler.setFormatter(console_formatter) - _logger.addHandler(console_handler) + # 本地 CLI 已经有独立的 stdio 滚动日志时,不再把业务日志重复打一份到控制台。 + if os.getenv("MOVIEPILOT_DISABLE_CONSOLE_LOG") != "1": + console_handler = logging.StreamHandler() + console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT) + console_handler.setFormatter(console_formatter) + _logger.addHandler(console_handler) # 禁止向父级log传递 _logger.propagate = False diff --git a/app/main.py b/app/main.py index 86ae8ca7..9cc53f29 100644 --- a/app/main.py +++ b/app/main.py @@ -4,19 +4,32 @@ import setproctitle import signal import sys import threading +from pathlib import Path import uvicorn as uvicorn from PIL import Image from uvicorn import Config -from app.factory import app +from app.utils.stdio import configure_rotating_stdio from app.utils.system import SystemUtils # 禁用输出 -if SystemUtils.is_frozen(): +stdio_log_file = os.getenv("MOVIEPILOT_STDIO_LOG_FILE") +if stdio_log_file: + # 本地 CLI 会把 stdout/stderr 切到滚动日志,避免无限追加单独的大文件。 + configure_rotating_stdio( + log_file=Path(stdio_log_file), + max_bytes=max(int(os.getenv("MOVIEPILOT_STDIO_LOG_MAX_BYTES", "0") or 0), 1), + backup_count=max( + int(os.getenv("MOVIEPILOT_STDIO_LOG_BACKUP_COUNT", "0") or 0), + 0, + ), + ) +elif SystemUtils.is_frozen(): sys.stdout = open(os.devnull, 'w') sys.stderr = open(os.devnull, 'w') +from app.factory import app from app.core.config import settings from app.db.init import init_db, update_db @@ -95,4 +108,4 @@ if __name__ == '__main__': # 更新数据库 update_db() # 启动API服务 - Server.run() \ No newline at end of file + Server.run() diff --git a/app/utils/stdio.py b/app/utils/stdio.py new file mode 100644 index 00000000..f4c5fc2e --- /dev/null +++ b/app/utils/stdio.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import io +import logging +import sys +import threading +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +class RotatingLineStream(io.TextIOBase): + """ + 将 stdout/stderr 按行写入滚动日志文件。 + + 这里不复用业务 logger,避免 stdout 日志再次回流到控制台或普通业务日志文件, + 同时保证启动阶段的 print/uvicorn 输出也能按配置滚动。 + """ + + def __init__(self, log_file: Path, max_bytes: int, backup_count: int): + super().__init__() + self._buffer = "" + self._lock = threading.Lock() + + logger_name = f"moviepilot-stdio::{log_file}" + self._logger = logging.getLogger(logger_name) + self._logger.setLevel(logging.INFO) + self._logger.propagate = False + self._logger.handlers.clear() + + handler = RotatingFileHandler( + filename=str(log_file), + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + self._logger.addHandler(handler) + + @property + def encoding(self) -> str: + return "utf-8" + + def writable(self) -> bool: + return True + + def isatty(self) -> bool: + return False + + def write(self, message: str) -> int: + if not message: + return 0 + + with self._lock: + self._buffer += message.replace("\r\n", "\n") + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._logger.info(line) + return len(message) + + def flush(self) -> None: + with self._lock: + if self._buffer: + self._logger.info(self._buffer) + self._buffer = "" + for handler in self._logger.handlers: + handler.flush() + + +def configure_rotating_stdio( + *, log_file: Path, max_bytes: int, backup_count: int +) -> RotatingLineStream: + """ + 将当前进程的 stdout/stderr 统一重定向到同一个滚动日志流。 + """ + + log_file.parent.mkdir(parents=True, exist_ok=True) + stream = RotatingLineStream( + log_file=log_file, + max_bytes=max_bytes, + backup_count=backup_count, + ) + sys.stdout = stream + sys.stderr = stream + return stream diff --git a/docs/cli.md b/docs/cli.md index 2433088f..84da1041 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -64,6 +64,7 @@ moviepilot config path - 前端本地 Node 运行时:`.runtime/node/` - 后端日志:`/logs/moviepilot.log` - 后端启动日志:`/logs/moviepilot.stdout.log` + 该文件同样受 `LOG_MAX_FILE_SIZE` 与 `LOG_BACKUP_COUNT` 控制 - 前端启动日志:`/logs/moviepilot.frontend.stdout.log` ## 帮助与发现 @@ -118,6 +119,9 @@ moviepilot uninstall moviepilot update backend moviepilot update frontend moviepilot update all +moviepilot startup enable +moviepilot startup disable +moviepilot startup status moviepilot agent moviepilot start moviepilot stop @@ -232,6 +236,8 @@ moviepilot setup --config-dir /path/to/moviepilot-config 可按需启用,并配置 `LLM_PROVIDER`、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL` - 用户站点认证 可按需选择认证站点,并按站点要求填写用户名、UID、Passkey 等参数 +- 开机自启 + 可按需启用,MoviePilot 会根据当前操作系统注册登录自启动 - 下载器 - 媒体服务器 - 消息通知渠道 @@ -248,6 +254,25 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst - `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中 - 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码 +## 开机自启命令 + +管理当前本地安装的开机自启: + +```shell +moviepilot startup status +moviepilot startup enable +moviepilot startup disable +moviepilot startup enable --venv /path/to/venv +moviepilot startup enable --config-dir /path/to/moviepilot-config +``` + +说明: + +- macOS 使用 `LaunchAgent` +- Linux 优先使用 `systemd --user`,当前环境不可用时自动回退到 `XDG autostart` +- Windows 使用当前用户的 Startup 启动目录 +- 注册的启动项会调用本地 CLI 的统一启动入口,因此会同时拉起后端与前端 + ## 卸载命令 卸载本地安装产物: @@ -262,6 +287,7 @@ moviepilot uninstall --config-dir /path/to/moviepilot-config - 卸载时会先停止当前 CLI 管理的前后端服务 - 会删除本地虚拟环境、前端运行时、本地 Node 运行时、全局 `moviepilot` 软链接,以及同步到 `app/helper` 的资源文件 +- 如果之前注册过开机自启,卸载时也会一并取消 - 会询问是否同时删除配置目录,默认不删除 - 如果当前使用的是仓库内 legacy `config/` 目录,确认删除后其中的 `category.yaml` 等配置文件也会一起删除 - 整个卸载流程包含两次确认 @@ -338,10 +364,12 @@ moviepilot version 说明: - `start` 会先启动后端,再启动前端 +- 如果开启了 `MOVIEPILOT_AUTO_UPDATE=release|true|dev`,`start/restart` 会在启动前尽力执行一次本地自动更新;更新失败只告警,不阻断当前启动 - 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启 - 前端默认监听 `NGINX_PORT`,默认值 `3000` - 后端默认监听 `PORT`,默认值 `3001` - 前端通过 `service.js` 代理 `/api` 与 `/cookiecloud` 到后端 +- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务 日志: diff --git a/moviepilot b/moviepilot index 4abaf111..b5d98126 100755 --- a/moviepilot +++ b/moviepilot @@ -16,6 +16,7 @@ Bootstrap Commands: 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 startup {enable|disable|status} [--venv PATH] [--config-dir PATH] moviepilot agent [OPTIONS] MESSAGE... Runtime Commands: @@ -30,6 +31,7 @@ Discovery Commands: moviepilot help install moviepilot help uninstall moviepilot help update + moviepilot help startup moviepilot commands Examples: @@ -39,6 +41,7 @@ Examples: moviepilot setup --wizard moviepilot uninstall moviepilot update all + moviepilot startup enable moviepilot agent 帮我分析最近一次搜索失败 moviepilot help config moviepilot config keys @@ -59,6 +62,9 @@ Bootstrap Commands update backend update frontend update all + startup enable + startup disable + startup status agent Runtime Commands @@ -185,6 +191,25 @@ Options: EOF } +show_startup_help() { + cat <<'EOF' +Usage: + moviepilot startup enable [OPTIONS] + moviepilot startup disable [OPTIONS] + moviepilot startup status [OPTIONS] + +Options: + --venv PATH 虚拟环境目录,默认 ./venv + --config-dir PATH 指定配置目录,默认使用当前安装配置 + -h, --help 显示帮助 + +说明: + - macOS 使用 LaunchAgent + - Linux 优先使用 systemd --user,不可用时回退到 XDG autostart + - Windows 使用当前用户的 Startup 启动目录 +EOF +} + show_agent_help() { cat <<'EOF' Usage: @@ -328,6 +353,10 @@ show_command_help() { show_update_help exit 0 ;; + startup) + show_startup_help + exit 0 + ;; commands) show_commands exit 0 @@ -432,6 +461,11 @@ case "${1:-}" in require_bootstrap_python exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" update "$@" ;; + startup) + shift + require_bootstrap_python + exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" startup "$@" + ;; agent) shift require_bootstrap_python diff --git a/scripts/local_setup.py b/scripts/local_setup.py index 75481c0c..e2ea3a31 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -15,6 +15,7 @@ import shutil import subprocess import sys import tarfile +import textwrap import uuid import zipfile from datetime import datetime @@ -75,6 +76,183 @@ RUNTIME_PACKAGE = { "express-http-proxy": "^2.0.0", }, } +LOCAL_FRONTEND_SERVICE_SCRIPT = textwrap.dedent( + """ + const http = require('node:http') + const path = require('node:path') + const express = require('express') + const proxy = require('express-http-proxy') + + const app = express() + const backendHost = process.env.MOVIEPILOT_BACKEND_HOST || '127.0.0.1' + const backendPort = Number(process.env.PORT || 3001) + const frontendPort = Number(process.env.NGINX_PORT || 3000) + const backendHealthPath = '/api/v1/system/global?token=moviepilot' + const backendHealthTimeoutMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_TIMEOUT_MS || 3000) + const backendHealthIntervalMs = Number(process.env.MOVIEPILOT_FRONTEND_HEALTH_INTERVAL_MS || 15000) + const backendMaxFailures = Math.max( + Number(process.env.MOVIEPILOT_FRONTEND_MAX_FAILURES || 4), + 1 + ) + + function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + function checkBackendHealth () { + return new Promise(resolve => { + const request = http.request( + { + host: backendHost, + port: backendPort, + path: backendHealthPath, + method: 'GET', + timeout: backendHealthTimeoutMs + }, + response => { + let body = '' + response.setEncoding('utf8') + response.on('data', chunk => { + body += chunk + }) + response.on('end', () => { + if (response.statusCode !== 200) { + resolve(false) + return + } + + try { + const payload = JSON.parse(body) + resolve(payload?.success !== false) + } catch (error) { + // 健康检查接口只要返回 200,就允许继续提供前端服务。 + resolve(true) + } + }) + } + ) + + request.on('timeout', () => { + request.destroy(new Error('backend health check timeout')) + }) + request.on('error', () => { + resolve(false) + }) + request.end() + }) + } + + async function waitForBackendReady () { + for (let attempt = 1; attempt <= backendMaxFailures; attempt += 1) { + if (await checkBackendHealth()) { + return true + } + + if (attempt < backendMaxFailures) { + await sleep(1000) + } + } + return false + } + + function startBackendWatchdog (server) { + let consecutiveFailures = 0 + let checking = false + + const timer = setInterval(async () => { + if (checking) { + return + } + + checking = true + try { + const healthy = await checkBackendHealth() + if (healthy) { + consecutiveFailures = 0 + return + } + + consecutiveFailures += 1 + console.warn( + `Backend health check failed (${consecutiveFailures}/${backendMaxFailures})` + ) + + if (consecutiveFailures < backendMaxFailures) { + return + } + + clearInterval(timer) + console.error('Backend is unavailable, stopping frontend service') + server.close(() => process.exit(1)) + setTimeout(() => process.exit(1), 1000).unref() + } finally { + checking = false + } + }, backendHealthIntervalMs) + + timer.unref() + + const shutdown = signal => { + clearInterval(timer) + console.log(`Received ${signal}, shutting down frontend service`) + server.close(() => process.exit(0)) + setTimeout(() => process.exit(0), 1000).unref() + } + + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) + } + + // 静态文件服务目录 + app.use(express.static(__dirname)) + + // 配置代理中间件将请求转发给后端 API。 + app.use( + '/api', + proxy(`${backendHost}:${backendPort}`, { + proxyReqPathResolver: req => `/api${req.url}` + }) + ) + + // 配置代理中间件将 CookieCloud 请求转发给后端 API。 + app.use( + '/cookiecloud', + proxy(`${backendHost}:${backendPort}`, { + proxyReqPathResolver: req => `/cookiecloud${req.url}` + }) + ) + + // 处理根路径的请求。 + app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'index.html')) + }) + + // 处理所有其他请求,重定向到前端入口文件。 + app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'index.html')) + }) + + async function bootstrap () { + // 前端本地代理不再允许单独存活,避免设备重启后只剩前端进程。 + const backendReady = await waitForBackendReady() + if (!backendReady) { + console.error('Backend is unavailable, skip starting frontend service') + process.exit(1) + } + + const server = app.listen(frontendPort, () => { + console.log(`Server is running on port ${frontendPort}`) + }) + + startBackendWatchdog(server) + } + + bootstrap().catch(error => { + console.error(`Failed to start frontend service: ${error?.message || error}`) + process.exit(1) + }) + """ +).lstrip() NOTIFICATION_SWITCH_TYPES = [ "资源下载", "整理入库", @@ -88,6 +266,15 @@ NOTIFICATION_SWITCH_TYPES = [ ] UNINSTALL_CONFIRM_TEXT = "UNINSTALL" RESOURCE_FILE_PATTERNS = ("sites*", "user.sites*.bin") +AUTOSTART_ENV_KEY = "MOVIEPILOT_AUTO_START" +AUTOSTART_RUNTIME_DIR = RUNTIME_DIR / "startup" +AUTOSTART_UNIX_LAUNCHER = AUTOSTART_RUNTIME_DIR / "moviepilot-start.sh" +AUTOSTART_WINDOWS_LAUNCHER = AUTOSTART_RUNTIME_DIR / "moviepilot-start.cmd" +AUTOSTART_TIMEOUT = 120 +MACOS_LAUNCH_AGENT_LABEL = "org.moviepilot.localcli" +LINUX_SYSTEMD_UNIT_NAME = "moviepilot-autostart.service" +LINUX_XDG_AUTOSTART_FILENAME = "moviepilot.desktop" +WINDOWS_STARTUP_FILENAME = "MoviePilot Startup.cmd" def _default_config_dir() -> Path: @@ -490,6 +677,16 @@ def _frontend_runtime_ready(frontend_version: str) -> bool: return False +def _write_local_frontend_service_script(target_dir: Path) -> None: + """ + 覆盖前端 release 自带的 service.js,统一使用本地 CLI 的受控代理脚本。 + """ + (target_dir / "service.js").write_text( + LOCAL_FRONTEND_SERVICE_SCRIPT, + encoding="utf-8", + ) + + def _node_platform() -> tuple[str, str]: system_name = platform.system().lower() machine = platform.machine().lower() @@ -562,6 +759,7 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str] node_bin = install_node_runtime(node_version) if _frontend_runtime_ready(version_tag): + _write_local_frontend_service_script(PUBLIC_DIR) print_step(f"前端发布包已是最新版本:{version_tag}") return {"version": version_tag, "node": str(node_bin)} @@ -578,6 +776,8 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str] _remove_path(PUBLIC_DIR) shutil.move(str(dist_dir), str(PUBLIC_DIR)) + _write_local_frontend_service_script(PUBLIC_DIR) + runtime_package = dict(RUNTIME_PACKAGE) runtime_package["version"] = version_tag (PUBLIC_DIR / "package.json").write_text( @@ -1431,6 +1631,23 @@ def _collect_site_auth_config( } +def _collect_autostart_config() -> dict[str, Any]: + print_step("开机自启配置") + current_status = _autostart_status() + default_enabled = bool(current_status.get("enabled")) or _env_bool( + AUTOSTART_ENV_KEY, False + ) + if current_status.get("enabled"): + print( + f"当前已检测到开机自启:{current_status.get('label') or _startup_platform_name()}" + ) + else: + print(f"当前系统将使用:{_startup_platform_name()}") + + enabled = _prompt_yes_no("是否设置开机自启", default=default_enabled) + return {"enabled": enabled} + + def run_setup_wizard( force_token: bool, runtime_python: Optional[Path] = None, @@ -1492,6 +1709,7 @@ def run_setup_wizard( "mediaserver": _collect_media_server_config(), "notification": _collect_notification_config(), "site_auth": _collect_site_auth_config(runtime_python=runtime_python), + "autostart": _collect_autostart_config(), } @@ -1782,6 +2000,37 @@ def apply_local_system_config( ) +def _apply_autostart_choice( + autostart_payload: Optional[dict[str, Any]], + *, + config_dir: Path, + runtime_python: Optional[Path], + venv_dir: Optional[Path], +) -> None: + if not isinstance(autostart_payload, dict): + return + + if autostart_payload.get("enabled"): + result = enable_autostart( + config_dir=config_dir, + runtime_python=runtime_python, + venv_dir=venv_dir, + ) + print_step(f"已启用开机自启:{result.get('method')}") + if result.get("artifact"): + print(f" 注册文件:{result['artifact']}") + if result.get("note"): + print(f" 说明:{result['note']}") + return + + result = disable_autostart() + removed_paths = result.get("removed_paths") or [] + if removed_paths: + print_step("已取消开机自启注册") + else: + print_step("当前未配置开机自启,无需取消") + + def init_local( *, resources_repo: Optional[Path], @@ -1793,6 +2042,7 @@ def init_local( superuser: Optional[str], superuser_password: Optional[str], runtime_python: Optional[Path] = None, + venv_dir: Optional[Path] = None, ) -> None: ensure_local_dirs() @@ -1842,6 +2092,17 @@ def init_local( elif direct_env_settings: sync_superuser_account(runtime_python=runtime_python) + if wizard_payload: + try: + _apply_autostart_choice( + wizard_payload.get("autostart"), + config_dir=CONFIG_DIR, + runtime_python=runtime_python, + venv_dir=venv_dir, + ) + except Exception as exc: + print_step(f"开机自启配置未完成:{exc}") + def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path: ensure_supported_python(python_bin) @@ -1869,6 +2130,496 @@ def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path: return venv_python +def _startup_platform_name() -> str: + system = platform.system() + if system == "Darwin": + return "macOS LaunchAgent" + if system == "Linux": + return "Linux systemd/XDG" + if system == "Windows": + return "Windows Startup" + return system or "unknown" + + +def _runtime_python_candidates( + runtime_python: Optional[Path], venv_dir: Optional[Path] +) -> list[Path]: + candidates: list[Path] = [] + seen: set[str] = set() + + raw_candidates = [ + runtime_python, + get_venv_python((venv_dir or (ROOT / "venv")).expanduser().resolve()), + Path(sys.executable) if sys.executable else None, + ] + for candidate in raw_candidates: + if not candidate: + continue + resolved = Path(candidate).expanduser().resolve() + key = str(resolved) + if key in seen: + continue + seen.add(key) + candidates.append(resolved) + return candidates + + +def _can_run_moviepilot_cli(python_bin: Path) -> bool: + if not python_bin.exists(): + return False + + result = subprocess.run( + [str(python_bin), "-m", "app.cli", "--help"], + cwd=str(ROOT), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def _resolve_runtime_python_for_startup( + runtime_python: Optional[Path], venv_dir: Optional[Path] +) -> Path: + for candidate in _runtime_python_candidates(runtime_python, venv_dir): + if _can_run_moviepilot_cli(candidate): + return candidate + + raise RuntimeError( + "未找到可用于启动 MoviePilot 的 Python 运行环境,请先执行 moviepilot install deps 或 moviepilot setup" + ) + + +def _linux_user_systemd_dir() -> Path: + return ( + Path(os.getenv("XDG_CONFIG_HOME") or (Path.home() / ".config")) + / "systemd" + / "user" + ) + + +def _linux_xdg_autostart_dir() -> Path: + return Path(os.getenv("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "autostart" + + +def _macos_launch_agent_path() -> Path: + return Path.home() / "Library" / "LaunchAgents" / f"{MACOS_LAUNCH_AGENT_LABEL}.plist" + + +def _linux_systemd_unit_path() -> Path: + return _linux_user_systemd_dir() / LINUX_SYSTEMD_UNIT_NAME + + +def _linux_xdg_autostart_path() -> Path: + return _linux_xdg_autostart_dir() / LINUX_XDG_AUTOSTART_FILENAME + + +def _windows_startup_dir() -> Path: + appdata = os.getenv("APPDATA") + if appdata: + return ( + Path(appdata) + / "Microsoft" + / "Windows" + / "Start Menu" + / "Programs" + / "Startup" + ) + return ( + Path.home() + / "AppData" + / "Roaming" + / "Microsoft" + / "Windows" + / "Start Menu" + / "Programs" + / "Startup" + ) + + +def _windows_startup_path() -> Path: + return _windows_startup_dir() / WINDOWS_STARTUP_FILENAME + + +def _launcher_paths_for_platform(system_name: Optional[str] = None) -> list[Path]: + system_name = system_name or platform.system() + if system_name == "Windows": + return [AUTOSTART_WINDOWS_LAUNCHER] + return [AUTOSTART_UNIX_LAUNCHER] + + +def _cleanup_startup_launchers(system_name: Optional[str] = None) -> None: + for path in _launcher_paths_for_platform(system_name): + if path.exists(): + _remove_path(path) + + if AUTOSTART_RUNTIME_DIR.exists() and not any(AUTOSTART_RUNTIME_DIR.iterdir()): + AUTOSTART_RUNTIME_DIR.rmdir() + + +def _write_unix_startup_launcher(config_dir: Path, python_bin: Path) -> Path: + AUTOSTART_RUNTIME_DIR.mkdir(parents=True, exist_ok=True) + launcher_content = textwrap.dedent( + f"""\ + #!/usr/bin/env bash + set -euo pipefail + + export CONFIG_DIR={shlex.quote(str(config_dir))} + cd {shlex.quote(str(ROOT))} + exec {shlex.quote(str(python_bin))} -m app.cli start --timeout {AUTOSTART_TIMEOUT} + """ + ) + AUTOSTART_UNIX_LAUNCHER.write_text(launcher_content, encoding="utf-8") + AUTOSTART_UNIX_LAUNCHER.chmod(0o755) + return AUTOSTART_UNIX_LAUNCHER + + +def _write_windows_startup_launcher(config_dir: Path, python_bin: Path) -> Path: + AUTOSTART_RUNTIME_DIR.mkdir(parents=True, exist_ok=True) + launcher_content = textwrap.dedent( + f"""\ + @echo off + setlocal + set "CONFIG_DIR={config_dir}" + cd /d "{ROOT}" + "{python_bin}" -m app.cli start --timeout {AUTOSTART_TIMEOUT} + endlocal + """ + ) + AUTOSTART_WINDOWS_LAUNCHER.write_text(launcher_content, encoding="utf-8") + return AUTOSTART_WINDOWS_LAUNCHER + + +def _double_quote(value: Any) -> str: + escaped = str(value).replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _run_optional_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=str(ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="ignore", + check=False, + ) + + +def _last_command_line(result: subprocess.CompletedProcess[str]) -> str: + lines = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()] + return lines[-1] if lines else "命令未返回更多信息" + + +def _linux_linger_enabled() -> Optional[bool]: + loginctl_bin = shutil.which("loginctl") + if not loginctl_bin: + return None + + result = _run_optional_command( + [loginctl_bin, "show-user", getpass.getuser(), "-p", "Linger", "--value"] + ) + if result.returncode != 0: + return None + value = (result.stdout or "").strip().lower() + if value in {"yes", "no"}: + return value == "yes" + return None + + +def _autostart_status() -> dict[str, Any]: + system_name = platform.system() + if system_name == "Darwin": + artifact = _macos_launch_agent_path() + return { + "enabled": artifact.exists(), + "method": "launchagent", + "label": "LaunchAgent", + "artifact": artifact, + } + if system_name == "Linux": + systemd_unit = _linux_systemd_unit_path() + if systemd_unit.exists(): + return { + "enabled": True, + "method": "systemd-user", + "label": "systemd --user", + "artifact": systemd_unit, + "linger_enabled": _linux_linger_enabled(), + } + desktop_file = _linux_xdg_autostart_path() + return { + "enabled": desktop_file.exists(), + "method": "xdg-autostart" if desktop_file.exists() else "none", + "label": "XDG autostart" if desktop_file.exists() else "not-configured", + "artifact": desktop_file if desktop_file.exists() else None, + } + if system_name == "Windows": + artifact = _windows_startup_path() + return { + "enabled": artifact.exists(), + "method": "startup-folder", + "label": "Startup Folder", + "artifact": artifact, + } + + return { + "enabled": False, + "method": "unsupported", + "label": _startup_platform_name(), + "artifact": None, + } + + +def _enable_autostart_macos(config_dir: Path, python_bin: Path) -> dict[str, Any]: + launcher = _write_unix_startup_launcher(config_dir=config_dir, python_bin=python_bin) + agent_path = _macos_launch_agent_path() + agent_path.parent.mkdir(parents=True, exist_ok=True) + LOG_DIR.mkdir(parents=True, exist_ok=True) + + plist_content = textwrap.dedent( + f"""\ + + + + + Label + {MACOS_LAUNCH_AGENT_LABEL} + ProgramArguments + + /bin/bash + {launcher} + + WorkingDirectory + {ROOT} + RunAtLoad + + StandardOutPath + {LOG_DIR / "moviepilot.launchagent.stdout.log"} + StandardErrorPath + {LOG_DIR / "moviepilot.launchagent.stderr.log"} + + + """ + ) + agent_path.write_text(plist_content, encoding="utf-8") + + uid = str(os.getuid()) + _run_optional_command(["launchctl", "bootout", f"gui/{uid}", str(agent_path)]) + bootstrap_result = _run_optional_command( + ["launchctl", "bootstrap", f"gui/{uid}", str(agent_path)] + ) + if bootstrap_result.returncode != 0: + note = _last_command_line(bootstrap_result) + else: + enable_result = _run_optional_command( + ["launchctl", "enable", f"gui/{uid}/{MACOS_LAUNCH_AGENT_LABEL}"] + ) + note = ( + _last_command_line(enable_result) + if enable_result.returncode != 0 + else "已加载到当前登录会话" + ) + + write_env_value(AUTOSTART_ENV_KEY, "true") + return { + "method": "LaunchAgent", + "artifact": agent_path, + "note": note, + } + + +def _enable_autostart_linux_systemd( + config_dir: Path, python_bin: Path +) -> Optional[dict[str, Any]]: + systemctl_bin = shutil.which("systemctl") + if not systemctl_bin: + return None + + launcher = _write_unix_startup_launcher(config_dir=config_dir, python_bin=python_bin) + unit_path = _linux_systemd_unit_path() + unit_path.parent.mkdir(parents=True, exist_ok=True) + unit_content = textwrap.dedent( + f"""\ + [Unit] + Description=MoviePilot local autostart + Wants=network-online.target + After=network-online.target + + [Service] + Type=oneshot + WorkingDirectory={ROOT} + ExecStart=/bin/bash {_double_quote(launcher)} + + [Install] + WantedBy=default.target + """ + ) + unit_path.write_text(unit_content, encoding="utf-8") + + _run_optional_command([systemctl_bin, "--user", "daemon-reload"]) + enable_result = _run_optional_command( + [systemctl_bin, "--user", "enable", LINUX_SYSTEMD_UNIT_NAME] + ) + if enable_result.returncode != 0: + _remove_path(unit_path) + _run_optional_command([systemctl_bin, "--user", "daemon-reload"]) + return None + + start_result = _run_optional_command( + [systemctl_bin, "--user", "start", LINUX_SYSTEMD_UNIT_NAME] + ) + desktop_path = _linux_xdg_autostart_path() + if desktop_path.exists(): + _remove_path(desktop_path) + note = ( + _last_command_line(start_result) + if start_result.returncode != 0 + else "已注册 systemd --user 并尝试在当前会话执行一次" + ) + linger_enabled = _linux_linger_enabled() + if linger_enabled is False: + note += ";如需无人登录时随系统启动,请手动执行 sudo loginctl enable-linger $USER" + + write_env_value(AUTOSTART_ENV_KEY, "true") + return { + "method": "systemd --user", + "artifact": unit_path, + "note": note, + } + + +def _enable_autostart_linux_xdg(config_dir: Path, python_bin: Path) -> dict[str, Any]: + launcher = _write_unix_startup_launcher(config_dir=config_dir, python_bin=python_bin) + desktop_path = _linux_xdg_autostart_path() + desktop_path.parent.mkdir(parents=True, exist_ok=True) + unit_path = _linux_systemd_unit_path() + if unit_path.exists(): + _remove_path(unit_path) + systemctl_bin = shutil.which("systemctl") + if systemctl_bin: + _run_optional_command([systemctl_bin, "--user", "daemon-reload"]) + desktop_content = textwrap.dedent( + f"""\ + [Desktop Entry] + Type=Application + Version=1.0 + Name=MoviePilot + Comment=Start MoviePilot on login + Exec=/bin/bash {_double_quote(launcher)} + Path={ROOT} + Terminal=false + X-GNOME-Autostart-enabled=true + """ + ) + desktop_path.write_text(desktop_content, encoding="utf-8") + write_env_value(AUTOSTART_ENV_KEY, "true") + return { + "method": "XDG autostart", + "artifact": desktop_path, + "note": "当前环境未启用 systemd --user,已回退为图形会话登录自启动", + } + + +def _enable_autostart_windows(config_dir: Path, python_bin: Path) -> dict[str, Any]: + launcher = _write_windows_startup_launcher(config_dir=config_dir, python_bin=python_bin) + startup_path = _windows_startup_path() + startup_path.parent.mkdir(parents=True, exist_ok=True) + startup_content = textwrap.dedent( + f"""\ + @echo off + call "{launcher}" + """ + ) + startup_path.write_text(startup_content, encoding="utf-8") + write_env_value(AUTOSTART_ENV_KEY, "true") + return { + "method": "Startup Folder", + "artifact": startup_path, + "note": "将在当前用户登录 Windows 后自动启动", + } + + +def enable_autostart( + *, config_dir: Path, runtime_python: Optional[Path], venv_dir: Optional[Path] +) -> dict[str, Any]: + config_dir = config_dir.expanduser().resolve() + python_bin = _resolve_runtime_python_for_startup(runtime_python, venv_dir) + system_name = platform.system() + + if system_name == "Darwin": + return _enable_autostart_macos(config_dir=config_dir, python_bin=python_bin) + if system_name == "Linux": + return _enable_autostart_linux_systemd( + config_dir=config_dir, python_bin=python_bin + ) or _enable_autostart_linux_xdg(config_dir=config_dir, python_bin=python_bin) + if system_name == "Windows": + return _enable_autostart_windows(config_dir=config_dir, python_bin=python_bin) + + raise RuntimeError(f"当前系统暂不支持自动注册开机自启:{platform.system()}") + + +def disable_autostart() -> dict[str, Any]: + system_name = platform.system() + removed_paths: list[Path] = [] + + if system_name == "Darwin": + agent_path = _macos_launch_agent_path() + uid = str(os.getuid()) + _run_optional_command(["launchctl", "bootout", f"gui/{uid}", str(agent_path)]) + if agent_path.exists(): + _remove_path(agent_path) + removed_paths.append(agent_path) + _cleanup_startup_launchers(system_name) + elif system_name == "Linux": + systemctl_bin = shutil.which("systemctl") + unit_path = _linux_systemd_unit_path() + desktop_path = _linux_xdg_autostart_path() + if systemctl_bin: + _run_optional_command( + [systemctl_bin, "--user", "disable", LINUX_SYSTEMD_UNIT_NAME] + ) + _run_optional_command([systemctl_bin, "--user", "daemon-reload"]) + for path in (unit_path, desktop_path): + if path.exists(): + _remove_path(path) + removed_paths.append(path) + _cleanup_startup_launchers(system_name) + elif system_name == "Windows": + startup_path = _windows_startup_path() + for path in (startup_path, AUTOSTART_WINDOWS_LAUNCHER): + if path.exists(): + _remove_path(path) + removed_paths.append(path) + _cleanup_startup_launchers(system_name) + else: + raise RuntimeError(f"当前系统暂不支持自动取消开机自启:{platform.system()}") + + write_env_value(AUTOSTART_ENV_KEY, "false") + return {"removed_paths": removed_paths} + + +def print_autostart_status() -> None: + status = _autostart_status() + if not status.get("enabled"): + print_step(f"当前未启用开机自启({_startup_platform_name()})") + return + + print_step( + f"当前已启用开机自启:{status.get('label') or _startup_platform_name()}" + ) + artifact = status.get("artifact") + if artifact: + print(f" 注册文件:{artifact}") + linger_enabled = status.get("linger_enabled") + if linger_enabled is False: + print( + " 说明:当前为 systemd --user 模式,通常会在用户登录后启动;如需无人登录即启动,请手动启用 linger。" + ) + + def _read_runtime_file(path: Path) -> Optional[dict[str, Any]]: if not path.exists(): return None @@ -2074,6 +2825,7 @@ def uninstall_local( for path in cli_links if path.is_symlink() and path.exists() and path.resolve() == script_path ] + autostart_status = _autostart_status() delete_config = _prompt_yes_no( f"是否同时删除配置目录 {config_dir}", default=False @@ -2091,6 +2843,12 @@ def uninstall_local( print(f" {path}") else: print(" - 未检测到指向当前仓库的全局 CLI 软链接") + if autostart_status.get("enabled"): + print( + f" - 取消开机自启:{autostart_status.get('label') or _startup_platform_name()}" + ) + else: + print(" - 当前未配置开机自启") if delete_config: print(f" - 删除配置目录:{config_dir}") @@ -2112,6 +2870,8 @@ def uninstall_local( return {"cancelled": True} _stop_managed_services(venv_dir=venv_dir) + if autostart_status.get("enabled"): + disable_autostart() removed_paths: list[Path] = [] removed_paths.extend( @@ -2210,6 +2970,44 @@ def update_backend( return venv_python +def handle_startup_command( + *, + action: str, + config_dir: Path, + runtime_python: Optional[Path], + venv_dir: Optional[Path], +) -> None: + if action == "status": + print_autostart_status() + return + + if action == "enable": + result = enable_autostart( + config_dir=config_dir, + runtime_python=runtime_python, + venv_dir=venv_dir, + ) + print_step(f"已启用开机自启:{result.get('method')}") + if result.get("artifact"): + print(f"注册文件:{result['artifact']}") + if result.get("note"): + print(f"说明:{result['note']}") + return + + if action == "disable": + result = disable_autostart() + removed_paths = result.get("removed_paths") or [] + if removed_paths: + print_step("已取消开机自启注册") + for path in removed_paths: + print(f"已移除:{path}") + else: + print_step("当前未配置开机自启,无需取消") + return + + raise RuntimeError(f"未知的 startup 动作:{action}") + + def run_agent_request( *, message: str, session_id: Optional[str], new_session: bool, user_id: str ) -> dict[str, str]: @@ -2406,6 +3204,19 @@ def build_parser() -> argparse.ArgumentParser: "--config-dir", help="配置目录,默认使用程序目录外的系统配置目录" ) + startup_parser = subparsers.add_parser( + "startup", help="注册、取消或查看本地开机自启" + ) + startup_parser.add_argument( + "action", choices=["enable", "disable", "status"], help="开机自启动作" + ) + startup_parser.add_argument( + "--venv", default=str(ROOT / "venv"), help="虚拟环境目录" + ) + startup_parser.add_argument( + "--config-dir", help="配置目录,默认使用当前安装配置" + ) + apply_config_parser = subparsers.add_parser("apply-config", help=argparse.SUPPRESS) apply_config_parser.add_argument( "--config-json-file", required=True, help=argparse.SUPPRESS @@ -2490,6 +3301,7 @@ def main() -> int: superuser=args.superuser, superuser_password=args.superuser_password, runtime_python=None, + venv_dir=ROOT / "venv", ) print_step("初始化完成") print_step(f"当前配置目录:{config_dir}") @@ -2525,6 +3337,7 @@ def main() -> int: superuser=args.superuser, superuser_password=args.superuser_password, runtime_python=venv_python, + venv_dir=Path(args.venv), ) print_step(f"本地环境已完成安装与初始化:{venv_python}") print_step(f"当前配置目录:{config_dir}") @@ -2572,6 +3385,20 @@ def main() -> int: print_step(f"更新完成,当前配置目录:{config_dir}") return 0 + if args.command == "startup": + runtime_python = None + if args.action == "enable": + runtime_python = _resolve_runtime_python_for_startup( + None, Path(args.venv) + ) + handle_startup_command( + action=args.action, + config_dir=config_dir, + runtime_python=runtime_python, + venv_dir=Path(args.venv), + ) + return 0 + if args.command == "apply-config": payload = json.loads( Path(args.config_json_file).read_text(encoding="utf-8")