Files
soul-yongping/scripts/devlop.py

244 lines
8.7 KiB
Python
Raw Normal View History

#!/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 buildstandalone 输出)"""
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/staticstandalone 可能已有 .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)
# publicstandalone 可能已带 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())