#!/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())