feat: improve local CLI startup management

This commit is contained in:
jxxghp
2026-04-21 11:26:25 +08:00
parent 6f6fcc79f2
commit 1282ad5004
7 changed files with 1204 additions and 17 deletions

View File

@@ -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 已重启")

View File

@@ -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

View File

@@ -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()
Server.run()

84
app/utils/stdio.py Normal file
View File

@@ -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

View File

@@ -64,6 +64,7 @@ moviepilot config path
- 前端本地 Node 运行时:`.runtime/node/`
- 后端日志:`<Config Dir>/logs/moviepilot.log`
- 后端启动日志:`<Config Dir>/logs/moviepilot.stdout.log`
该文件同样受 `LOG_MAX_FILE_SIZE``LOG_BACKUP_COUNT` 控制
- 前端启动日志:`<Config Dir>/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` 到后端
- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务
日志:

View File

@@ -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

View File

@@ -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"""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{MACOS_LAUNCH_AGENT_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>{launcher}</string>
</array>
<key>WorkingDirectory</key>
<string>{ROOT}</string>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>{LOG_DIR / "moviepilot.launchagent.stdout.log"}</string>
<key>StandardErrorPath</key>
<string>{LOG_DIR / "moviepilot.launchagent.stderr.log"}</string>
</dict>
</plist>
"""
)
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")