From 1193fbe74c97a8fb541fc62257967328b8174e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=98=E9=A3=8E?= Date: Tue, 3 Feb 2026 12:02:52 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=96=87=E4=BB=B6=EF=BC=8C=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=86=8D=E4=BD=BF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=92=8C=E7=BB=84=E4=BB=B6=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84=E4=BB=A5=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E5=8F=AF=E7=BB=B4=E6=8A=A4=E6=80=A7=EF=BC=9B=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=85=A8=E5=B1=80=E6=A0=B7=E5=BC=8F=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E6=A0=B7=E5=BC=8F=E4=B8=80=E8=87=B4=E6=80=A7=E5=92=8C?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/deploy_soul.cpython-311.pyc | Bin 0 -> 46014 bytes scripts/deploy_soul.py | 855 ++++++++++++++++++ scripts/devlop.py | 554 ++++++++++++ 3 files changed, 1409 insertions(+) create mode 100644 scripts/__pycache__/deploy_soul.cpython-311.pyc create mode 100644 scripts/deploy_soul.py create mode 100644 scripts/devlop.py diff --git a/scripts/__pycache__/deploy_soul.cpython-311.pyc b/scripts/__pycache__/deploy_soul.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..65a0f038a0160bfb90c4acabbb0aff0b9ca4d834 GIT binary patch literal 46014 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 literal 0 HcmV?d00001 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())