244 lines
8.7 KiB
Python
244 lines
8.7 KiB
Python
#!/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())
|