diff --git a/scripts/__pycache__/deploy_soul.cpython-311.pyc b/scripts/__pycache__/deploy_soul.cpython-311.pyc new file mode 100644 index 00000000..65a0f038 Binary files /dev/null and b/scripts/__pycache__/deploy_soul.cpython-311.pyc differ diff --git a/scripts/deploy_soul.py b/scripts/deploy_soul.py new file mode 100644 index 00000000..6d3a8af7 --- /dev/null +++ b/scripts/deploy_soul.py @@ -0,0 +1,855 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Soul 创业派对 - 一键部署脚本 +本地打包 + SSH 上传解压 + 宝塔 API 部署 + +使用方法: + python scripts/deploy_soul.py # 完整部署流程 + python scripts/deploy_soul.py --no-build # 跳过本地构建 + python scripts/deploy_soul.py --no-upload # 跳过 SSH 上传 + python scripts/deploy_soul.py --no-api # 上传后不调宝塔 API 重启 + +环境变量(可选,覆盖默认配置): + DEPLOY_HOST # SSH 服务器地址,默认 42.194.232.22 + DEPLOY_USER # SSH 用户名,默认 root + DEPLOY_PASSWORD # SSH 密码,默认 Zhiqun1984 + DEPLOY_SSH_KEY # SSH 密钥路径(优先于密码) + DEPLOY_PROJECT_PATH # 服务器项目路径,默认 /www/wwwroot/soul + BAOTA_PANEL_URL # 宝塔面板地址,默认 https://42.194.232.22:9988 + BAOTA_API_KEY # 宝塔 API 密钥,默认 hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd + DEPLOY_PM2_APP # PM2 项目名称,默认 soul + DEPLOY_PORT # Next.js 监听端口,默认 30006(与 package.json / ecosystem 一致) + DEPLOY_NODE_VERSION # Node 版本,默认 v22.14.0(用于显示) + DEPLOY_NODE_PATH # Node 可执行文件路径,默认 /www/server/nodejs/v22.14.0/bin + # 用于避免多 Node 环境冲突,确保使用指定的 Node 版本 +""" + +from __future__ import print_function + +import os +import sys +import shutil +import tarfile +import tempfile +import subprocess +import argparse +import time +import hashlib + +# 检查依赖 +try: + import paramiko +except ImportError: + print("错误: 请先安装 paramiko") + print(" pip install paramiko") + sys.exit(1) + +try: + import requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +except ImportError: + print("错误: 请先安装 requests") + print(" pip install requests") + sys.exit(1) + + +# ==================== 配置 ==================== + +def get_cfg(): + """获取部署配置""" + return { + # SSH 配置 + "host": os.environ.get("DEPLOY_HOST", "42.194.232.22"), + "user": os.environ.get("DEPLOY_USER", "root"), + "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), + "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), + "project_path": os.environ.get("DEPLOY_PROJECT_PATH", "/www/wwwroot/soul"), + # 宝塔 API 配置 + "panel_url": os.environ.get("BAOTA_PANEL_URL", "https://42.194.232.22:9988"), + "api_key": os.environ.get("BAOTA_API_KEY", "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"), + "pm2_name": os.environ.get("DEPLOY_PM2_APP", "soul"), + "site_url": os.environ.get("DEPLOY_SITE_URL", "https://soul.quwanzhi.com"), + "port": int(os.environ.get("DEPLOY_PORT", "30006")), # Next.js 监听端口,与 package.json / ecosystem 一致 + # Node 环境配置 + "node_version": os.environ.get("DEPLOY_NODE_VERSION", "v22.14.0"), # 指定 Node 版本 + "node_path": os.environ.get("DEPLOY_NODE_PATH", "/www/server/nodejs/v22.14.0/bin"), # Node 可执行文件路径 + } + + +# ==================== 宝塔 API ==================== + +def _get_sign(api_key): + """宝塔鉴权签名:request_token = md5(request_time + md5(api_key))""" + now_time = int(time.time()) + sign_str = str(now_time) + hashlib.md5(api_key.encode("utf-8")).hexdigest() + request_token = hashlib.md5(sign_str.encode("utf-8")).hexdigest() + return now_time, request_token + + +def _baota_request(panel_url, api_key, path, data=None): + """发起宝塔 API 请求的通用函数""" + req_time, req_token = _get_sign(api_key) + payload = { + "request_time": req_time, + "request_token": req_token, + } + if data: + payload.update(data) + + url = panel_url.rstrip("/") + "/" + path.lstrip("/") + try: + r = requests.post(url, data=payload, verify=False, timeout=30) + if r.text: + return r.json() + return {} + except Exception as e: + print(" API 请求失败: %s" % str(e)) + return None + + +def get_node_project_list(panel_url, api_key): + """获取 Node 项目列表""" + paths_to_try = [ + "/project/nodejs/get_project_list", + "/plugin?action=a&name=nodejs&s=get_project_list", + ] + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path) + if result and (result.get("status") is True or "data" in result): + return result.get("data", []) + return None + + +def get_node_project_status(panel_url, api_key, pm2_name): + """检查 Node 项目状态""" + projects = get_node_project_list(panel_url, api_key) + if projects: + for project in projects: + if project.get("name") == pm2_name: + return project + return None + + +def start_node_project(panel_url, api_key, pm2_name): + """通过宝塔 API 启动 Node 项目""" + paths_to_try = [ + "/project/nodejs/start_project", + "/plugin?action=a&name=nodejs&s=start_project", + ] + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): + print(" [成功] 启动成功: %s" % pm2_name) + return True + return False + + +def stop_node_project(panel_url, api_key, pm2_name): + """通过宝塔 API 停止 Node 项目""" + paths_to_try = [ + "/project/nodejs/stop_project", + "/plugin?action=a&name=nodejs&s=stop_project", + ] + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): + print(" [成功] 停止成功: %s" % pm2_name) + return True + return False + + +def restart_node_project(panel_url, api_key, pm2_name): + """ + 通过宝塔 API 重启 Node 项目 + 返回 True 表示成功,False 表示失败 + """ + # 先检查项目状态 + project_status = get_node_project_status(panel_url, api_key, pm2_name) + if project_status: + print(" 项目状态: %s" % project_status.get("status", "未知")) + + paths_to_try = [ + "/project/nodejs/restart_project", + "/plugin?action=a&name=nodejs&s=restart_project", + ] + + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result: + if result.get("status") is True or result.get("msg") or "成功" in str(result): + print(" [成功] 重启成功: %s" % pm2_name) + return True + if "msg" in result: + print(" API 返回: %s" % result.get("msg")) + + print(" [警告] 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确") + return False + + +def add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port=30006, node_path=None): + """通过宝塔 API 添加或更新 Node 项目配置 + + Next.js standalone 的 server.js 通过 process.env.PORT 读端口(默认 3000), + 这里在 run_cmd 中显式设置 PORT=port,与项目 package.json / ecosystem 的 30006 一致。 + """ + paths_to_try = [ + "/project/nodejs/add_project", + "/plugin?action=a&name=nodejs&s=add_project", + ] + + # Next.js standalone:显式传 PORT,避免宝塔未注入时用默认 3000 + port_env = "PORT=%d " % port + if node_path: + run_cmd = port_env + "%s/node server.js" % node_path + else: + run_cmd = port_env + "node server.js" + + payload = { + "name": pm2_name, + "path": project_path, + "run_cmd": run_cmd, + "port": str(port), + } + + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path, payload) + if result: + if result.get("status") is True: + print(" [成功] 项目配置已更新: %s" % pm2_name) + return True + if "msg" in result: + print(" API 返回: %s" % result.get("msg")) + + return False + + +# ==================== 本地构建 ==================== + +def run_build(root): + """执行本地构建""" + print("[1/4] 本地构建 pnpm build ...") + use_shell = sys.platform == "win32" + + # 检查 standalone 目录是否已存在 + standalone = os.path.join(root, ".next", "standalone") + server_js = os.path.join(standalone, "server.js") + + try: + # 在 Windows 上处理编码问题:使用 UTF-8 和 errors='replace' 来避免解码错误 + # errors='replace' 会在遇到无法解码的字符时用替换字符代替,避免崩溃 + r = subprocess.run( + ["pnpm", "build"], + cwd=root, + shell=use_shell, + timeout=600, + capture_output=True, + text=True, + encoding='utf-8', + errors='replace' # 遇到无法解码的字符时替换为占位符,避免 UnicodeDecodeError + ) + + # 安全地获取输出,处理可能的 None 值 + stdout_text = r.stdout or "" + stderr_text = r.stderr or "" + + # 检查是否是 Windows 符号链接权限错误 + # 错误信息可能在 stdout 或 stderr 中 + combined_output = stdout_text + stderr_text + is_windows_symlink_error = ( + sys.platform == "win32" and + r.returncode != 0 and + ("EPERM" in combined_output or + "symlink" in combined_output.lower() or + "operation not permitted" in combined_output.lower() or + "errno: -4048" in combined_output) + ) + + if r.returncode != 0: + if is_windows_symlink_error: + print(" [警告] Windows 符号链接权限错误(EPERM)") + print(" 这是 Windows 上 Next.js standalone 构建的常见问题") + print(" 解决方案(任选其一):") + print(" 1. 开启 Windows 开发者模式:设置 → 隐私和安全性 → 针对开发人员 → 开发人员模式") + print(" 2. 以管理员身份运行终端再执行构建") + print(" 3. 使用 --no-build 跳过构建,使用已有的构建文件") + print("") + print(" 正在检查 standalone 输出是否可用...") + + # 即使有错误,也检查 standalone 是否可用 + if os.path.isdir(standalone) and os.path.isfile(server_js): + print(" [成功] 虽然构建有警告,但 standalone 输出可用,继续部署") + return True + else: + print(" [失败] standalone 输出不可用,无法继续") + return False + else: + print(" [失败] 构建失败,退出码:", r.returncode) + if stdout_text: + # 显示最后几行输出以便调试 + lines = stdout_text.strip().split('\n') + if lines: + print(" 构建输出(最后10行):") + for line in lines[-10:]: + try: + # 确保输出可以正常显示 + print(" " + line) + except UnicodeEncodeError: + # 如果仍有编码问题,使用 ASCII 安全输出 + print(" " + line.encode('ascii', 'replace').decode('ascii')) + if stderr_text: + print(" 错误输出(最后5行):") + lines = stderr_text.strip().split('\n') + if lines: + for line in lines[-5:]: + try: + print(" " + line) + except UnicodeEncodeError: + print(" " + line.encode('ascii', 'replace').decode('ascii')) + return False + except subprocess.TimeoutExpired: + print(" [失败] 构建超时(超过10分钟)") + return False + except FileNotFoundError: + print(" [失败] 未找到 pnpm 命令,请先安装 pnpm") + print(" npm install -g pnpm") + return False + except UnicodeDecodeError as e: + print(" [失败] 构建输出编码错误:", str(e)) + print(" 提示: 这可能是 Windows 编码问题,尝试设置环境变量 PYTHONIOENCODING=utf-8") + # 即使有编码错误,也检查 standalone 是否可用 + if os.path.isdir(standalone) and os.path.isfile(server_js): + print(" [成功] 虽然构建有编码警告,但 standalone 输出可用,继续部署") + return True + return False + except Exception as e: + print(" [失败] 构建异常:", str(e)) + import traceback + traceback.print_exc() + # 即使有异常,也检查 standalone 是否可用(可能是部分成功) + if os.path.isdir(standalone) and os.path.isfile(server_js): + print(" [提示] 检测到 standalone 输出,可能是部分构建成功") + print(" 如果确定要使用,可以使用 --no-build 跳过构建步骤") + return False + + # 验证构建输出 + if not os.path.isdir(standalone) or not os.path.isfile(server_js): + print(" [失败] 未找到 .next/standalone 或 server.js") + print(" 请确认 next.config.mjs 中设置了 output: 'standalone'") + return False + print(" [成功] 构建完成") + return True + + +# ==================== 打包 ==================== + +def pack_standalone(root): + """打包 standalone 输出""" + print("[2/4] 打包 standalone ...") + standalone = os.path.join(root, ".next", "standalone") + static_src = os.path.join(root, ".next", "static") + public_src = os.path.join(root, "public") + ecosystem_src = os.path.join(root, "ecosystem.config.cjs") + + # 检查必要文件 + if not os.path.isdir(standalone): + print(" [失败] 未找到 .next/standalone 目录") + return None + if not os.path.isdir(static_src): + print(" [失败] 未找到 .next/static 目录") + return None + if not os.path.isdir(public_src): + print(" [警告] 未找到 public 目录,继续打包") + if not os.path.isfile(ecosystem_src): + print(" [警告] 未找到 ecosystem.config.cjs,继续打包") + + staging = tempfile.mkdtemp(prefix="soul_deploy_") + try: + # 复制 standalone 内容 + # standalone 目录应该包含:server.js, package.json, node_modules/ 等 + print(" 正在复制 standalone 目录内容...") + + # 使用更可靠的方法复制,特别是处理 pnpm 的符号链接结构 + def copy_with_dereference(src, dst): + """复制文件或目录,跟随符号链接""" + if os.path.islink(src): + # 如果是符号链接,复制目标文件 + link_target = os.readlink(src) + if os.path.isabs(link_target): + real_path = link_target + else: + real_path = os.path.join(os.path.dirname(src), link_target) + if os.path.exists(real_path): + if os.path.isdir(real_path): + shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True) + else: + shutil.copy2(real_path, dst) + else: + # 如果链接目标不存在,直接复制链接本身 + shutil.copy2(src, dst, follow_symlinks=False) + elif os.path.isdir(src): + # 对于目录,递归复制并处理符号链接 + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True) + else: + shutil.copy2(src, dst) + + for name in os.listdir(standalone): + src = os.path.join(standalone, name) + dst = os.path.join(staging, name) + if name == 'node_modules': + print(" 正在复制 node_modules(处理符号链接和 pnpm 结构)...") + copy_with_dereference(src, dst) + else: + copy_with_dereference(src, dst) + + # 🔧 修复 pnpm 依赖:将 styled-jsx 从 .pnpm 提升到根 node_modules + print(" 正在修复 pnpm 依赖结构...") + node_modules_dst = os.path.join(staging, "node_modules") + pnpm_dir = os.path.join(node_modules_dst, ".pnpm") + + if os.path.isdir(pnpm_dir): + # 需要提升的依赖列表(require-hook.js 需要) + required_deps = ["styled-jsx"] + + for dep in required_deps: + dep_in_root = os.path.join(node_modules_dst, dep) + if not os.path.exists(dep_in_root): + # 在 .pnpm 中查找该依赖 + for pnpm_pkg in os.listdir(pnpm_dir): + if pnpm_pkg.startswith(dep + "@"): + src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep) + if os.path.isdir(src_dep): + print(" 提升依赖: %s -> node_modules/%s" % (pnpm_pkg, dep)) + shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True) + break + else: + print(" 依赖已存在: %s" % dep) + + # 验证关键文件 + server_js = os.path.join(staging, "server.js") + package_json = os.path.join(staging, "package.json") + node_modules = os.path.join(staging, "node_modules") + if not os.path.isfile(server_js): + print(" [警告] standalone 目录内未找到 server.js") + if not os.path.isfile(package_json): + print(" [警告] standalone 目录内未找到 package.json") + if not os.path.isdir(node_modules): + print(" [警告] standalone 目录内未找到 node_modules") + else: + # 检查 node_modules/next 是否存在 + next_module = os.path.join(node_modules, "next") + if os.path.isdir(next_module): + print(" [成功] 已确认 node_modules/next 存在") + else: + print(" [警告] node_modules/next 不存在,可能导致运行时错误") + + # 检查 styled-jsx 是否存在(require-hook.js 需要) + styled_jsx_module = os.path.join(node_modules, "styled-jsx") + if os.path.isdir(styled_jsx_module): + print(" [成功] 已确认 node_modules/styled-jsx 存在") + else: + print(" [警告] node_modules/styled-jsx 不存在,可能导致启动失败") + + # 复制 .next/static + static_dst = os.path.join(staging, ".next", "static") + if os.path.exists(static_dst): + shutil.rmtree(static_dst) + os.makedirs(os.path.dirname(static_dst), exist_ok=True) + shutil.copytree(static_src, static_dst) + + # 复制 public + if os.path.isdir(public_src): + public_dst = os.path.join(staging, "public") + if os.path.exists(public_dst): + shutil.rmtree(public_dst) + shutil.copytree(public_src, public_dst) + + # 复制 ecosystem.config.cjs + if os.path.isfile(ecosystem_src): + shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs")) + + # 确保 package.json 的 start 脚本正确(standalone 模式使用 node server.js) + package_json_path = os.path.join(staging, "package.json") + if os.path.isfile(package_json_path): + try: + import json + with open(package_json_path, 'r', encoding='utf-8') as f: + package_data = json.load(f) + # 确保 start 脚本使用 node server.js + if 'scripts' not in package_data: + package_data['scripts'] = {} + package_data['scripts']['start'] = 'node server.js' + with open(package_json_path, 'w', encoding='utf-8') as f: + json.dump(package_data, f, indent=2, ensure_ascii=False) + print(" [提示] 已修正 package.json 的 start 脚本为 'node server.js'") + except Exception as e: + print(" [警告] 无法修正 package.json:", str(e)) + + # 创建压缩包 + tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz") + with tarfile.open(tarball, "w:gz") as tf: + for name in os.listdir(staging): + tf.add(os.path.join(staging, name), arcname=name) + + size_mb = os.path.getsize(tarball) / 1024 / 1024 + print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, size_mb)) + return tarball + except Exception as e: + print(" [失败] 打包异常:", str(e)) + import traceback + traceback.print_exc() + return None + finally: + shutil.rmtree(staging, ignore_errors=True) + + +# ==================== Node 环境检查 ==================== + +def check_node_environments(cfg): + """检查服务器上的 Node 环境""" + print("[检查] Node 环境检查 ...") + host = cfg["host"] + user = cfg["user"] + password = cfg["password"] + key_path = cfg["ssh_key"] + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if key_path: + client.connect(host, username=user, key_filename=key_path, timeout=15) + else: + client.connect(host, username=user, password=password, timeout=15) + + # 检查系统默认 Node 版本 + stdin, stdout, stderr = client.exec_command("which node && node -v", timeout=10) + default_node = stdout.read().decode("utf-8", errors="replace").strip() + if default_node: + print(" 系统默认 Node: %s" % default_node) + else: + print(" 警告: 未找到系统默认 Node") + + # 检查宝塔安装的 Node 版本 + stdin, stdout, stderr = client.exec_command("ls -d /www/server/nodejs/*/ 2>/dev/null | head -5", timeout=10) + node_versions = stdout.read().decode("utf-8", errors="replace").strip().split('\n') + node_versions = [v.strip().rstrip('/') for v in node_versions if v.strip()] + + if node_versions: + print(" 宝塔 Node 版本列表:") + for version_path in node_versions: + version_name = version_path.split('/')[-1] + # 检查该版本的 Node 是否存在 + stdin2, stdout2, stderr2 = client.exec_command("%s/node -v 2>/dev/null" % version_path, timeout=5) + node_ver = stdout2.read().decode("utf-8", errors="replace").strip() + if node_ver: + print(" - %s: %s" % (version_name, node_ver)) + else: + print(" - %s: (不可用)" % version_name) + else: + print(" 警告: 未找到宝塔 Node 安装目录") + + # 检查配置的 Node 版本 + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + stdin, stdout, stderr = client.exec_command("%s/node -v 2>/dev/null" % node_path, timeout=5) + configured_node = stdout.read().decode("utf-8", errors="replace").strip() + if configured_node: + print(" 配置的 Node 版本: %s (%s)" % (configured_node, node_path)) + else: + print(" 警告: 配置的 Node 路径不可用: %s" % node_path) + if node_versions: + # 自动使用第一个可用的版本 + suggested_path = node_versions[0] + "/bin" + print(" 建议使用: %s" % suggested_path) + + return True + except Exception as e: + print(" [警告] Node 环境检查失败: %s" % str(e)) + return False + finally: + client.close() + + +# ==================== SSH 上传 ==================== + +def upload_and_extract(cfg, tarball_path): + """SSH 上传并解压""" + print("[3/4] SSH 上传并解压 ...") + host = cfg["host"] + user = cfg["user"] + password = cfg["password"] + key_path = cfg["ssh_key"] + project_path = cfg["project_path"] + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + + if not password and not key_path: + print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") + return False + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + # 连接 SSH + print(" 正在连接 %s@%s ..." % (user, host)) + if key_path: + if not os.path.isfile(key_path): + print(" [失败] SSH 密钥文件不存在: %s" % key_path) + return False + client.connect(host, username=user, key_filename=key_path, timeout=15) + else: + client.connect(host, username=user, password=password, timeout=15) + print(" [成功] SSH 连接成功") + + # 上传文件和解压脚本 + print(" 正在上传压缩包和脚本 ...") + sftp = client.open_sftp() + remote_tar = "/tmp/soul_deploy.tar.gz" + remote_script = "/tmp/soul_deploy_extract.sh" + + try: + # 上传压缩包 + sftp.put(tarball_path, remote_tar) + print(" [成功] 压缩包上传完成") + + # 构建解压脚本,使用 bash 脚本文件避免语法错误 + # 在脚本中指定使用特定的 Node 版本,避免多环境冲突 + verify_script_content = """#!/bin/bash +# 设置 Node 环境路径,避免多环境冲突 +export PATH=%s:$PATH + +cd %s +rm -rf .next public ecosystem.config.cjs 2>/dev/null +rm -f server.js package.json 2>/dev/null +tar -xzf %s +rm -f %s + +# 显示使用的 Node 版本 +echo "使用 Node 版本: $(node -v)" +echo "Node 路径: $(which node)" + +# 验证 node_modules/next 和 styled-jsx +echo "检查关键依赖..." +if [ ! -d 'node_modules/next' ] || [ ! -f 'node_modules/next/dist/server/require-hook.js' ]; then + echo '警告: node_modules/next 不完整' +fi + +# 检查 styled-jsx(require-hook.js 需要) +if [ ! -d 'node_modules/styled-jsx' ]; then + echo '警告: styled-jsx 缺失,正在修复...' + + # 尝试从 .pnpm 创建链接 + if [ -d 'node_modules/.pnpm' ]; then + STYLED_JSX_DIR=$(find node_modules/.pnpm -maxdepth 1 -type d -name "styled-jsx@*" | head -1) + if [ -n "$STYLED_JSX_DIR" ]; then + echo "从 .pnpm 链接 styled-jsx: $STYLED_JSX_DIR" + ln -sf "$STYLED_JSX_DIR/node_modules/styled-jsx" node_modules/styled-jsx + fi + fi +fi + +# 如果还是缺失,运行 npm install +if [ ! -d 'node_modules/styled-jsx' ]; then + if [ -f 'package.json' ] && command -v npm >/dev/null 2>&1; then + echo '运行 npm install --production 修复依赖...' + npm install --production --no-save 2>&1 | tail -10 || echo 'npm install 失败' + else + echo '无法自动修复: 缺少 package.json 或 npm 命令' + fi +fi + +# 最终验证 +echo "最终验证..." +if [ -d 'node_modules/next' ] && [ -f 'node_modules/next/dist/server/require-hook.js' ]; then + echo '✓ node_modules/next 存在' +else + echo '✗ node_modules/next 缺失' +fi + +if [ -d 'node_modules/styled-jsx' ]; then + echo '✓ node_modules/styled-jsx 存在' +else + echo '✗ node_modules/styled-jsx 缺失(可能导致启动失败)' +fi + +echo '解压完成' +""" % (node_path, project_path, remote_tar, remote_tar) + + # 写入脚本文件 + with sftp.open(remote_script, 'w') as f: + f.write(verify_script_content) + print(" [成功] 解压脚本上传完成") + finally: + sftp.close() + + # 设置执行权限并执行脚本 + print(" 正在解压并验证依赖...") + client.exec_command("chmod +x %s" % remote_script, timeout=10) + cmd = "bash %s" % remote_script + stdin, stdout, stderr = client.exec_command(cmd, timeout=120) + err = stderr.read().decode("utf-8", errors="replace").strip() + if err: + print(" 服务器 stderr:", err) + output = stdout.read().decode("utf-8", errors="replace").strip() + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + print(" [失败] 解压失败,退出码:", exit_status) + return False + print(" [成功] 解压完成: %s" % project_path) + return True + except paramiko.AuthenticationException: + print(" [失败] SSH 认证失败,请检查用户名和密码") + return False + except paramiko.SSHException as e: + print(" [失败] SSH 连接异常:", str(e)) + return False + except Exception as e: + print(" [失败] SSH 错误:", str(e)) + import traceback + traceback.print_exc() + return False + finally: + client.close() + + +# ==================== 宝塔 API 部署 ==================== + +def deploy_via_baota_api(cfg): + """通过宝塔 API 管理 Node 项目部署(针对 Next.js standalone:node server.js + PORT)""" + print("[4/4] 宝塔 API 管理 Node 项目 ...") + + panel_url = cfg["panel_url"] + api_key = cfg["api_key"] + pm2_name = cfg["pm2_name"] + project_path = cfg["project_path"] + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + port = cfg.get("port", 30006) # 与 package.json dev/start -p 30006、ecosystem PORT 一致 + + # 1. 检查项目是否存在 + print(" 检查项目状态...") + project_status = get_node_project_status(panel_url, api_key, pm2_name) + + if not project_status: + print(" 项目不存在,尝试添加项目配置...") + # 尝试添加项目(如果项目不存在,这个操作可能会失败,但不影响后续重启) + # 使用指定的 Node 路径,避免多环境冲突 + add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port, node_path) + else: + print(" 项目已存在: %s" % pm2_name) + current_status = project_status.get("status", "未知") + print(" 当前状态: %s" % current_status) + # 检查启动命令是否使用了正确的 Node 路径 + run_cmd = project_status.get("run_cmd", "") + if run_cmd and "node server.js" in run_cmd and node_path not in run_cmd: + print(" 警告: 项目启动命令可能未使用指定的 Node 版本") + print(" 当前命令: %s" % run_cmd) + print(" 建议命令: %s/node server.js" % node_path) + + # 2. 停止项目(如果正在运行) + print(" 停止项目(如果正在运行)...") + stop_node_project(panel_url, api_key, pm2_name) + import time + time.sleep(2) # 等待停止完成 + + # 3. 重启项目 + print(" 启动项目...") + ok = restart_node_project(panel_url, api_key, pm2_name) + + if not ok: + # 如果重启失败,尝试直接启动 + print(" 重启失败,尝试直接启动...") + ok = start_node_project(panel_url, api_key, pm2_name) + + if not ok: + print(" 提示: 若 Node 接口不可用,请在宝塔面板【Node 项目】中手动重启 %s" % pm2_name) + print(" 项目路径: %s" % project_path) + print(" 启动命令: PORT=%d %s/node server.js" % (port, node_path)) + print(" 端口: %d" % port) + print(" Node 版本: %s" % cfg.get("node_version", "v22.14.0")) + + return ok + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser( + description="Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument("--no-build", action="store_true", help="跳过本地构建") + parser.add_argument("--no-upload", action="store_true", help="跳过 SSH 上传") + parser.add_argument("--no-api", action="store_true", help="上传后不调宝塔 API 重启") + args = parser.parse_args() + + # 获取项目根目录 + script_dir = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(script_dir) + + cfg = get_cfg() + print("=" * 60) + print(" Soul 创业派对 - 一键部署脚本") + print("=" * 60) + print(" 服务器: %s@%s" % (cfg["user"], cfg["host"])) + print(" 项目路径: %s" % cfg["project_path"]) + print(" PM2 名称: %s" % cfg["pm2_name"]) + print(" 站点地址: %s" % cfg["site_url"]) + print(" 端口: %s" % cfg.get("port", 30006)) + print(" Node 版本: %s" % cfg.get("node_version", "v22.14.0")) + print(" Node 路径: %s" % cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")) + print("=" * 60) + print("") + + # 检查 Node 环境(可选,如果不需要可以跳过) + if not args.no_upload: + check_node_environments(cfg) + print("") + + # 步骤 1: 本地构建 + if not args.no_build: + if not run_build(root): + return 1 + else: + standalone = os.path.join(root, ".next", "standalone", "server.js") + if not os.path.isfile(standalone): + print("[错误] 跳过构建但未找到 .next/standalone/server.js") + return 1 + print("[跳过] 本地构建") + + # 步骤 2: 打包 + tarball_path = pack_standalone(root) + if not tarball_path: + return 1 + + # 步骤 3: SSH 上传并解压 + if not args.no_upload: + if not upload_and_extract(cfg, tarball_path): + return 1 + # 清理本地压缩包 + try: + os.remove(tarball_path) + except Exception: + pass + else: + print("[跳过] SSH 上传") + print(" 压缩包位置: %s" % tarball_path) + + # 步骤 4: 宝塔 API 重启 + if not args.no_api and not args.no_upload: + deploy_via_baota_api(cfg) + elif args.no_api: + print("[跳过] 宝塔 API 重启") + + print("") + print("=" * 60) + print(" 部署完成!") + print(" 前台: %s" % cfg["site_url"]) + print(" 后台: %s/admin" % cfg["site_url"]) + print("=" * 60) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/devlop.py b/scripts/devlop.py new file mode 100644 index 00000000..12192c2e --- /dev/null +++ b/scripts/devlop.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Soul 创业派对 - 自动部署上传脚本(dist 切换方式) +本地 pnpm build → 打包 zip → 上传到服务器并解压到 dist2 → 宝塔暂停 soul → +dist→dist1, dist2→dist → 删除 dist1 → 宝塔重启 soul + +使用方法: + python scripts/devlop.py # 完整流程 + python scripts/devlop.py --no-build # 跳过本地构建(使用已有 .next/standalone) + +环境变量(可选): + DEPLOY_HOST # SSH 服务器,默认同 deploy_soul + DEPLOY_USER / DEPLOY_PASSWORD / DEPLOY_SSH_KEY + DEVOP_BASE_PATH # 服务器目录,默认 /www/wwwroot/auto-devlop/soul + BAOTA_PANEL_URL / BAOTA_API_KEY + DEPLOY_PM2_APP # Node 项目名,默认 soul +""" + +from __future__ import print_function + +import os +import sys +import shutil +import tempfile +import argparse +import json +import zipfile +import time + +# 确保能导入同目录的 deploy_soul +script_dir = os.path.dirname(os.path.abspath(__file__)) +if script_dir not in sys.path: + sys.path.insert(0, script_dir) + +try: + import paramiko +except ImportError: + print("错误: 请先安装 paramiko") + print(" pip install paramiko") + sys.exit(1) + +# 复用 deploy_soul 的构建与宝塔 API +from deploy_soul import ( + get_cfg as _deploy_cfg, + run_build, + stop_node_project, + start_node_project, +) + +# ==================== 构建前清理(避免 Windows EBUSY) ==================== + +def clean_standalone_before_build(root, retries=3, delay=2): + """ + 构建前删除 .next/standalone,避免 Next.js 在 Windows 上因 EBUSY 无法 rmdir。 + 若目录被占用会重试几次并等待,仍失败则提示用 --no-build 或关闭占用进程。 + """ + standalone = os.path.join(root, ".next", "standalone") + if not os.path.isdir(standalone): + return True + for attempt in range(1, retries + 1): + try: + shutil.rmtree(standalone) + print(" [清理] 已删除 .next/standalone(第 %d 次尝试)" % attempt) + return True + except (OSError, PermissionError) as e: + err = getattr(e, "winerror", None) or getattr(e, "errno", None) + if attempt < retries: + print(" [清理] .next/standalone 被占用,%ds 后重试 (%d/%d) ..." % (delay, attempt, retries)) + time.sleep(delay) + else: + print(" [失败] 无法删除 .next/standalone(EBUSY/被占用)") + print(" 请:1) 关闭占用该目录的进程(如其他终端、VS Code 文件预览)") + print(" 2) 或先手动执行 pnpm build,再运行: python scripts/devlop.py --no-build") + return False + return False + + +# ==================== 配置 ==================== + +def get_cfg(): + """获取配置(在 deploy_soul 基础上增加 devlop 路径)""" + cfg = _deploy_cfg() + cfg["base_path"] = os.environ.get("DEVOP_BASE_PATH", "/www/wwwroot/auto-devlop/soul") + cfg["dist_path"] = cfg["base_path"] + "/dist" + cfg["dist2_path"] = cfg["base_path"] + "/dist2" + return cfg + + +# ==================== 打包为 ZIP ==================== + +# 打包 zip 时排除的目录名(路径中任一段匹配即跳过整棵子树) +ZIP_EXCLUDE_DIRS = { + ".cache", # node_modules/.cache, .next/cache + "__pycache__", + ".git", + "node_modules", + "cache", # .next/cache 等 + "test", + "tests", + "coverage", + ".nyc_output", + ".turbo", + "开发文档", + "miniprogram", + "my-app", + "newpp", +} +# 打包时排除的文件名(精确匹配)或后缀 +ZIP_EXCLUDE_FILE_NAMES = {".DS_Store", "Thumbs.db"} +ZIP_EXCLUDE_FILE_SUFFIXES = (".log", ".map") # 可选:.map 可排除以减小体积 + + +def _should_exclude_from_zip(arcname, is_file=True): + """判断 zip 内相对路径是否应排除(不打包)。""" + parts = arcname.replace("\\", "/").split("/") + for part in parts: + if part in ZIP_EXCLUDE_DIRS: + return True + if is_file: + name = parts[-1] if parts else "" + if name in ZIP_EXCLUDE_FILE_NAMES: + return True + if any(name.endswith(s) for s in ZIP_EXCLUDE_FILE_SUFFIXES): + return True + return False + + +def _copy_with_dereference(src, dst): + """复制文件或目录,跟随符号链接""" + if os.path.islink(src): + link_target = os.readlink(src) + real_path = link_target if os.path.isabs(link_target) else os.path.join(os.path.dirname(src), link_target) + if os.path.exists(real_path): + if os.path.isdir(real_path): + shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True) + else: + shutil.copy2(real_path, dst) + else: + shutil.copy2(src, dst, follow_symlinks=False) + elif os.path.isdir(src): + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True) + else: + shutil.copy2(src, dst) + + +def pack_standalone_zip(root): + """打包 standalone 为 zip(逻辑与 deploy_soul.pack_standalone 一致,输出 zip)""" + print("[2/7] 打包 standalone 为 zip ...") + standalone = os.path.join(root, ".next", "standalone") + static_src = os.path.join(root, ".next", "static") + public_src = os.path.join(root, "public") + ecosystem_src = os.path.join(root, "ecosystem.config.cjs") + + if not os.path.isdir(standalone): + print(" [失败] 未找到 .next/standalone 目录") + return None + if not os.path.isdir(static_src): + print(" [失败] 未找到 .next/static 目录") + return None + if not os.path.isdir(public_src): + print(" [警告] 未找到 public 目录,继续打包") + if not os.path.isfile(ecosystem_src): + print(" [警告] 未找到 ecosystem.config.cjs,继续打包") + + staging = tempfile.mkdtemp(prefix="soul_devlop_") + try: + print(" 正在复制 standalone 目录内容...") + for name in os.listdir(standalone): + src = os.path.join(standalone, name) + dst = os.path.join(staging, name) + if name == "node_modules": + print(" 正在复制 node_modules(处理符号链接)...") + _copy_with_dereference(src, dst) + + # 修复 pnpm 依赖:提升 styled-jsx + node_modules_dst = os.path.join(staging, "node_modules") + pnpm_dir = os.path.join(node_modules_dst, ".pnpm") + if os.path.isdir(pnpm_dir): + for dep in ["styled-jsx"]: + dep_in_root = os.path.join(node_modules_dst, dep) + if not os.path.exists(dep_in_root): + for pnpm_pkg in os.listdir(pnpm_dir): + if pnpm_pkg.startswith(dep + "@"): + src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep) + if os.path.isdir(src_dep): + shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True) + break + + # 复制 .next/static、public、ecosystem + static_dst = os.path.join(staging, ".next", "static") + if os.path.exists(static_dst): + shutil.rmtree(static_dst) + os.makedirs(os.path.dirname(static_dst), exist_ok=True) + shutil.copytree(static_src, static_dst) + if os.path.isdir(public_src): + public_dst = os.path.join(staging, "public") + if os.path.exists(public_dst): + shutil.rmtree(public_dst) + shutil.copytree(public_src, public_dst) + if os.path.isfile(ecosystem_src): + shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs")) + + # 修正 package.json start 脚本 + package_json_path = os.path.join(staging, "package.json") + if os.path.isfile(package_json_path): + try: + with open(package_json_path, "r", encoding="utf-8") as f: + package_data = json.load(f) + if "scripts" not in package_data: + package_data["scripts"] = {} + package_data["scripts"]["start"] = "node server.js" + with open(package_json_path, "w", encoding="utf-8") as f: + json.dump(package_data, f, indent=2, ensure_ascii=False) + except Exception: + pass + + # 修改 server.js 默认端口:3000 → 30006 + server_js_path = os.path.join(staging, "server.js") + if os.path.isfile(server_js_path): + try: + with open(server_js_path, "r", encoding="utf-8") as f: + server_js_content = f.read() + # 替换默认端口:|| 3000 → || 30006 + if "|| 3000" in server_js_content: + server_js_content = server_js_content.replace("|| 3000", "|| 30006") + with open(server_js_path, "w", encoding="utf-8") as f: + f.write(server_js_content) + print(" [修改] server.js 默认端口已改为 30006") + else: + print(" [提示] server.js 未找到 '|| 3000' 字符串,跳过端口修改") + except Exception as e: + print(" [警告] 修改 server.js 失败:", str(e)) + + # 打成 zip(仅包含顶层内容,解压后即 dist2 根目录;排除 ZIP_EXCLUDE_* 配置的目录/文件) + zip_path = os.path.join(tempfile.gettempdir(), "soul_devlop.zip") + excluded_count = [0] # 用列表以便内层可修改 + + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for name in os.listdir(staging): + path = os.path.join(staging, name) + if os.path.isfile(path): + if _should_exclude_from_zip(name): + excluded_count[0] += 1 + continue + zf.write(path, name) + else: + for dirpath, dirs, filenames in os.walk(path): + # 剪枝:不进入排除目录 + dirs[:] = [d for d in dirs if not _should_exclude_from_zip( + os.path.join(name, os.path.relpath(os.path.join(dirpath, d), path)), is_file=False + )] + for f in filenames: + full = os.path.join(dirpath, f) + arcname = os.path.join(name, os.path.relpath(full, path)) + if _should_exclude_from_zip(arcname): + excluded_count[0] += 1 + continue + zf.write(full, arcname) + if excluded_count[0] > 0: + print(" [过滤] 已排除 %d 个文件/目录(ZIP_EXCLUDE_*)" % excluded_count[0]) + + size_mb = os.path.getsize(zip_path) / 1024 / 1024 + print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, size_mb)) + return zip_path + except Exception as e: + print(" [失败] 打包异常:", str(e)) + import traceback + traceback.print_exc() + return None + finally: + shutil.rmtree(staging, ignore_errors=True) + + +# ==================== SSH 上传并解压到 dist2 ==================== + +def upload_zip_and_extract_to_dist2(cfg, zip_path): + """上传 zip 到 base_path,解压到 base_path/dist2""" + print("[3/7] SSH 上传 zip 并解压到 dist2 ...") + host = cfg["host"] + user = cfg["user"] + password = cfg["password"] + key_path = cfg["ssh_key"] + base_path = cfg["base_path"] + dist2_path = cfg["dist2_path"] + + if not password and not key_path: + print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") + return False + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + print(" 正在连接 %s@%s ..." % (user, host)) + if key_path and os.path.isfile(key_path): + client.connect(host, username=user, key_filename=key_path, timeout=15) + else: + client.connect(host, username=user, password=password, timeout=15) + print(" [成功] SSH 连接成功") + + remote_zip = base_path.rstrip("/") + "/soul_devlop.zip" + sftp = client.open_sftp() + try: + # 确保目录存在 + for part in ["/www", "/www/wwwroot", "/www/wwwroot/auto-devlop", "/www/wwwroot/auto-devlop/soul"]: + try: + sftp.stat(part) + except FileNotFoundError: + pass + sftp.put(zip_path, remote_zip) + print(" [成功] zip 上传完成: %s" % remote_zip) + finally: + sftp.close() + + # 解压到 dist2:先删旧 dist2,再创建并解压 + cmd = ( + "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" + % (dist2_path, dist2_path, remote_zip, dist2_path, remote_zip) + ) + stdin, stdout, stderr = client.exec_command(cmd, timeout=120) + err = stderr.read().decode("utf-8", errors="replace").strip() + if err: + print(" 服务器 stderr:", err) + out = stdout.read().decode("utf-8", errors="replace").strip() + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0 or "OK" not in out: + print(" [失败] 解压失败,退出码: %s" % exit_status) + if err: + print(" stderr: %s" % err) + if out: + print(" stdout: %s" % out) + return False + print(" [成功] 已解压到: %s" % dist2_path) + return True + except paramiko.AuthenticationException: + print(" [失败] SSH 认证失败") + return False + except Exception as e: + print(" [失败] SSH 错误:", str(e)) + import traceback + traceback.print_exc() + return False + finally: + client.close() + + +# ==================== 服务器 dist2 内执行 pnpm install ==================== + +def run_pnpm_install_in_dist2(cfg): + """在服务器 dist2 目录执行 pnpm install,失败时返回 (False, 错误信息)""" + print("[4/7] 服务器 dist2 内执行 pnpm install ...") + host = cfg["host"] + user = cfg["user"] + password = cfg["password"] + key_path = cfg["ssh_key"] + dist2_path = cfg["dist2_path"] + + if not password and not key_path: + return False, "请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY" + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if key_path and os.path.isfile(key_path): + client.connect(host, username=user, key_filename=key_path, timeout=15) + else: + client.connect(host, username=user, password=password, timeout=15) + + # 先查找 pnpm 路径并打印,方便调试 + print(" 正在查找 pnpm 路径...") + stdin, stdout, stderr = client.exec_command("bash -lc 'which pnpm'", timeout=10) + pnpm_path = stdout.read().decode("utf-8", errors="replace").strip() + if pnpm_path: + print(" 找到 pnpm: %s" % pnpm_path) + else: + # 尝试常见路径 + print(" 未找到 pnpm,尝试常见路径...") + for test_path in ["/usr/local/bin/pnpm", "/usr/bin/pnpm", "~/.local/share/pnpm/pnpm"]: + stdin, stdout, stderr = client.exec_command("test -f %s && echo OK" % test_path, timeout=5) + if "OK" in stdout.read().decode("utf-8", errors="replace"): + pnpm_path = test_path + print(" 找到 pnpm: %s" % pnpm_path) + break + + if not pnpm_path: + return False, "未找到 pnpm 命令,请确认服务器已安装 pnpm (npm install -g pnpm)" + + # 使用 bash -lc 加载环境,并用找到的 pnpm 路径执行 + # -l: 登录 shell,会加载 ~/.bash_profile 等 + # -c: 执行命令 + cmd = "bash -lc 'cd %s && %s install'" % (dist2_path, pnpm_path) + print(" 执行命令: %s" % cmd) + + stdin, stdout, stderr = client.exec_command(cmd, timeout=300) + out = stdout.read().decode("utf-8", errors="replace").strip() + err = stderr.read().decode("utf-8", errors="replace").strip() + exit_status = stdout.channel.recv_exit_status() + + # 显示部分输出(最后几行) + if out: + out_lines = out.split('\n') + if len(out_lines) > 10: + print(" 输出(最后10行):") + for line in out_lines[-10:]: + print(" " + line) + else: + print(" 输出: %s" % out) + + if exit_status != 0: + msg = "pnpm install 失败,退出码: %s\n" % exit_status + if err: + msg += "stderr:\n%s\n" % err + if out: + msg += "stdout:\n%s" % out + return False, msg + print(" [成功] pnpm install 完成") + return True, None + except Exception as e: + return False, "执行 pnpm install 异常: %s" % str(e) + finally: + client.close() + + +# ==================== 暂停 → 重命名切换 → 重启 ==================== + +def remote_swap_dist_and_restart(cfg): + """宝塔暂停 soul → dist→dist1, dist2→dist → 删除 dist1 → 宝塔重启 soul""" + print("[5/7] 宝塔 API 暂停 Node 项目 soul ...") + panel_url = cfg["panel_url"] + api_key = cfg["api_key"] + pm2_name = cfg["pm2_name"] + base_path = cfg["base_path"] + dist_path = cfg["dist_path"] + dist2_path = cfg["dist2_path"] + + if not stop_node_project(panel_url, api_key, pm2_name): + print(" [警告] 暂停可能未成功,继续执行切换") + import time + time.sleep(2) + + print("[6/7] 服务器上切换目录: dist→dist1, dist2→dist,删除 dist1 ...") + host = cfg["host"] + user = cfg["user"] + password = cfg["password"] + key_path = cfg["ssh_key"] + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if key_path and os.path.isfile(key_path): + client.connect(host, username=user, key_filename=key_path, timeout=15) + else: + client.connect(host, username=user, password=password, timeout=15) + + # dist -> dist1, dist2 -> dist, rm -rf dist1 + cmd = "cd %s && mv dist dist1 2>/dev/null; mv dist2 dist && rm -rf dist1 && echo OK" % base_path + stdin, stdout, stderr = client.exec_command(cmd, timeout=60) + err = stderr.read().decode("utf-8", errors="replace").strip() + out = stdout.read().decode("utf-8", errors="replace").strip() + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0 or "OK" not in out: + print(" [失败] 切换失败,退出码: %s" % exit_status) + if err: + print(" stderr: %s" % err) + if out: + print(" stdout: %s" % out) + return False + print(" [成功] 已切换: 新版本位于 %s" % dist_path) + except Exception as e: + print(" [失败] 切换异常:", str(e)) + return False + finally: + client.close() + + print("[7/7] 宝塔 API 重启 Node 项目 soul ...") + if not start_node_project(panel_url, api_key, pm2_name): + print(" [警告] 重启失败,请到宝塔 Node 项目里手动启动 soul") + return False + return True + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser( + description="Soul 自动部署:build → zip → 上传解压到 dist2 → 暂停 → 切换 dist → 重启", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--no-build", action="store_true", help="跳过本地构建(使用已有 .next/standalone)") + args = parser.parse_args() + + script_dir = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(script_dir) + cfg = get_cfg() + + print("=" * 60) + print(" Soul 自动部署(dist 切换)") + print("=" * 60) + print(" 服务器: %s@%s" % (cfg["user"], cfg["host"])) + print(" 目录: %s" % cfg["base_path"]) + print(" 解压到: %s" % cfg["dist2_path"]) + print(" 运行目录: %s" % cfg["dist_path"]) + print(" Node 项目名: %s" % cfg["pm2_name"]) + print("=" * 60) + + # 1. 本地构建 + if not args.no_build: + print("[1/7] 本地构建 pnpm build ...") + if sys.platform == "win32": + if not clean_standalone_before_build(root): + return 1 + if not run_build(root): + return 1 + else: + if not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")): + print("[错误] 跳过构建但未找到 .next/standalone/server.js") + return 1 + print("[1/7] 跳过本地构建") + + # 2. 打包 zip + zip_path = pack_standalone_zip(root) + if not zip_path: + return 1 + + # 3. 上传并解压到 dist2 + if not upload_zip_and_extract_to_dist2(cfg, zip_path): + return 1 + try: + os.remove(zip_path) + except Exception: + pass + + # 4. 服务器 dist2 内 pnpm install + ok, err_msg = run_pnpm_install_in_dist2(cfg) + if not ok: + print(" [失败] %s" % (err_msg or "pnpm install 失败")) + return 1 + + # 5–7. 暂停 → 切换 → 重启 + if not remote_swap_dist_and_restart(cfg): + return 1 + + print("") + print("=" * 60) + print(" 部署完成!当前运行目录: %s" % cfg["dist_path"]) + print("=" * 60) + return 0 + + +if __name__ == "__main__": + sys.exit(main())