#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署 流程:本地 pnpm build → 打包 .next/standalone → SSH 上传并解压到服务器 → 宝塔 API 重启 Node 项目 使用(在项目根目录): python scripts/devlop.py python scripts/devlop.py --no-build # 跳过构建,仅上传 + API 重启 python scripts/devlop.py --no-api # 上传后不调宝塔 API 重启 环境变量: DEPLOY_HOST, DEPLOY_USER, DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY DEPLOY_PROJECT_PATH(如 /www/wwwroot/soul) BAOTA_PANEL_URL, BAOTA_API_KEY DEPLOY_PM2_APP(如 soul) 依赖:pip install -r requirements-deploy.txt (paramiko, requests) """ from __future__ import print_function import os import sys import shutil import tarfile import tempfile import subprocess import argparse # 项目根目录(scripts 的上一级) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 不在本文件重写 sys.stdout/stderr,否则与 deploy_baota_pure_api 导入时的重写叠加会导致 # 旧包装被 GC 关闭底层 buffer,后续 print 报 ValueError: I/O operation on closed file try: import paramiko except ImportError: print("请先安装: pip install paramiko") sys.exit(1) try: import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) except ImportError: print("请先安装: pip install requests") sys.exit(1) # 导入宝塔 API 重启逻辑 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from deploy_baota_pure_api import CFG as BAOTA_CFG, restart_node_project # 部署配置(与 .cursorrules、DEPLOYMENT.md、deploy_baota_pure_api 一致) # 未设置环境变量时使用 .cursorrules 中的服务器信息,可用 DEPLOY_* 覆盖 def get_cfg(): return { "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"), "app_port": os.environ.get("DEPLOY_APP_PORT", "3006"), "pm2_name": os.environ.get("DEPLOY_PM2_APP", BAOTA_CFG["pm2_name"]), } def run_build(root): """本地执行 pnpm build(standalone 输出)""" print("[1/4] 本地构建 pnpm build ...") use_shell = sys.platform == "win32" r = subprocess.run( ["pnpm", "build"], cwd=root, shell=use_shell, timeout=300, ) if r.returncode != 0: print("构建失败,退出码:", r.returncode) return False standalone = os.path.join(root, ".next", "standalone") if not os.path.isdir(standalone) or not os.path.isfile(os.path.join(standalone, "server.js")): print("未找到 .next/standalone 或 server.js,请确认 next.config 中 output: 'standalone'") return False print(" 构建完成.") return True def pack_standalone(root): """打包 standalone + .next/static + public + ecosystem.config.cjs,返回 tarball 路径""" 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") staging = tempfile.mkdtemp(prefix="soul_deploy_") try: # 复制 standalone 目录内容到 staging for name in os.listdir(standalone): src = os.path.join(standalone, name) dst = os.path.join(staging, name) if os.path.isdir(src): shutil.copytree(src, dst) else: shutil.copy2(src, dst) # .next/static(standalone 可能已有 .next,先删再拷以用项目 static 覆盖) static_dst = os.path.join(staging, ".next", "static") shutil.rmtree(static_dst, ignore_errors=True) os.makedirs(os.path.dirname(static_dst), exist_ok=True) shutil.copytree(static_src, static_dst) # public(standalone 可能已带 public 目录,先删再拷) public_dst = os.path.join(staging, "public") shutil.rmtree(public_dst, ignore_errors=True) shutil.copytree(public_src, public_dst) # ecosystem.config.cjs shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs")) 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) print(" 打包完成: %s" % tarball) return tarball finally: shutil.rmtree(staging, ignore_errors=True) def upload_and_extract(cfg, tarball_path): """SSH 上传 tarball 并解压到服务器项目目录""" print("[3/4] SSH 上传并解压 ...") host = cfg["host"] user = cfg["user"] password = cfg["password"] key_path = cfg["ssh_key"] project_path = cfg["project_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: if key_path: client.connect(host, username=user, key_filename=key_path, timeout=15) else: client.connect(host, username=user, password=password, timeout=15) sftp = client.open_sftp() remote_tar = "/tmp/soul_deploy.tar.gz" sftp.put(tarball_path, remote_tar) sftp.close() # 解压到项目目录:先清空再解压(保留 .env 等若存在可后续再配) cmd = ( "cd %s && " "rm -rf .next server.js node_modules public ecosystem.config.cjs 2>/dev/null; " "tar -xzf %s && " "rm -f %s" ) % (project_path, remote_tar, remote_tar) stdin, stdout, stderr = client.exec_command(cmd, timeout=60) err = stderr.read().decode("utf-8", errors="replace").strip() if err: print(" 服务器 stderr:", err) code = stdout.channel.recv_exit_status() if code != 0: print(" 解压命令退出码:", code) return False print(" 上传并解压完成: %s" % project_path) return True except Exception as e: print(" SSH 错误:", e) return False finally: client.close() def deploy_via_baota_api(cfg): """宝塔 API 重启 Node 项目""" print("[4/4] 宝塔 API 重启 Node 项目 ...") panel_url = BAOTA_CFG["panel_url"] api_key = BAOTA_CFG["api_key"] pm2_name = cfg["pm2_name"] ok = restart_node_project(panel_url, api_key, pm2_name) if not ok: print("提示:若 Node 接口不可用,请在宝塔面板【Node 项目】中手动重启 %s" % pm2_name) return ok def main(): parser = argparse.ArgumentParser(description="本地打包 + SSH 上传 + 宝塔 API 部署") parser.add_argument("--no-build", action="store_true", help="跳过本地构建(使用已有 .next/standalone)") parser.add_argument("--no-upload", action="store_true", help="跳过 SSH 上传(仅构建+打包或仅 API)") parser.add_argument("--no-api", action="store_true", help="上传后不调用宝塔 API 重启") args = parser.parse_args() cfg = get_cfg() print("=" * 60) print(" Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署") print("=" * 60) print(" 服务器: %s@%s | 路径: %s | PM2: %s" % (cfg["user"], cfg["host"], cfg["project_path"], cfg["pm2_name"])) print("=" * 60) tarball_path = None if not args.no_build: if not run_build(ROOT): return 1 else: # 若跳过构建,需已有 standalone,仍要打包 if not os.path.isfile(os.path.join(ROOT, ".next", "standalone", "server.js")): print("跳过构建但未找到 .next/standalone/server.js,请先执行一次完整部署或 pnpm build") return 1 tarball_path = pack_standalone(ROOT) if not tarball_path: return 1 if not args.no_upload: if not upload_and_extract(cfg, tarball_path): return 1 if os.path.isfile(tarball_path): try: os.remove(tarball_path) except Exception: pass if not args.no_api and not args.no_upload: if not deploy_via_baota_api(cfg): pass # 已打印提示 print("") print(" 站点: %s | 后台: %s/admin" % (BAOTA_CFG["site_url"], BAOTA_CFG["site_url"])) print("=" * 60) return 0 if __name__ == "__main__": sys.exit(main())