Files
soul-yongping/scripts/devlop.py

244 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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