Commit d092d238 by lijiabin

feat: 编写部署到测试环境的相关脚本

parent 7c1cf367
import cp from 'node:child_process'
import { createHash } from 'node:crypto'
import ssh from 'ssh2'
import archiver from 'archiver'
import fs from 'node:fs'
import path from 'node:path'
import { getCurrentBranchName, getDeploySlug } from './util.js'
// 当前分支名
const branchName = getCurrentBranchName()
// 服务器路径名
const deploySlug = getDeploySlug(branchName)
// 最多部署的分支个数
const MAX_DEPLOY_BRANCH_COUNT = 5
const connectInfo = {
host: '47.119.149.50',
port: '22',
username: 'root',
password: 'Qdt20250205',
}
const remoteRootPath = '/usr/local/nginx/www/culture'
const remoteBranchPath = `${remoteRootPath}/${deploySlug}`
const remoteTarPath = `${remoteBranchPath}/dist.tar.gz`
const remoteMetaPath = `${remoteBranchPath}/.deploy-meta.json`
const unzipDirMode = {
spawn: 'npm run build:test',
// 解压文件名
unzipDir: 'culture/',
// 终端定位位置 cd
servicePath: '/usr/local/nginx/',
// 压缩包存放位置
serviceFilePath: '/usr/local/nginx/dist.tar.gz',
servicePath: remoteRootPath,
unzipDir: deploySlug,
serviceFilePath: remoteTarPath,
}
const __dirname = path.resolve()
// 文件所在地
const distPath = path.resolve(__dirname, 'dist')
// 打包后位置
const zipPath = path.resolve(__dirname, 'dist.tar.gz')
// 远程服务器存放位置
const { spawn, servicePath, serviceFilePath, unzipDir } = unzipDirMode
// 服务器连接信息
const connectInfo = {
host: '47.119.149.50',
port: '22',
username: 'root',
password: 'Qdt20250205',
const { spawn, servicePath, unzipDir, serviceFilePath } = unzipDirMode
let conn = null
function connectServer() {
return new Promise((resolve, reject) => {
conn = new ssh.Client()
conn
.on('ready', () => {
console.log('SSH 连接成功')
resolve(conn)
})
.on('error', (err) => {
reject(err)
})
.connect(connectInfo)
})
}
function execCommand(command) {
return new Promise((resolve, reject) => {
conn.exec(command, { encoding: 'utf-8' }, (err, stream) => {
if (err) {
reject(err)
return
}
let stdout = ''
let stderr = ''
stream.on('data', (data) => {
stdout += data.toString()
})
stream.stderr.on('data', (data) => {
stderr += data.toString()
})
stream.on('close', (code) => {
if (code === 0) {
resolve(stdout)
return
}
reject(new Error(stderr.trim() || `命令执行失败: ${command}`))
})
})
})
}
// 获取远程服务器已部署的分支文件夹列表
async function getFolderList() {
const result = await execCommand(
`find ${remoteRootPath} -mindepth 1 -maxdepth 1 -type d -printf "%f\\n"`,
)
return result
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
// 获取已部署的分支的元信息 中文名 服务器路径名 部署时间
async function getDeployEntries(folderList) {
const entries = await Promise.all(
folderList.map(async (slug) => {
const metaFilePath = `${remoteRootPath}/${slug}/.deploy-meta.json`
try {
const rawMeta = await execCommand(`cat '${metaFilePath}'`)
const meta = JSON.parse(rawMeta)
return {
slug,
branchName: meta.branchName || slug,
deployedAt: meta.deployedAt || '',
}
} catch {
return {
slug,
branchName: slug,
deployedAt: '',
}
}
}),
)
return entries.sort((a, b) => a.branchName.localeCompare(b.branchName, 'zh-CN'))
}
// 写入index.html文件
async function writeIndexHtml(entries) {
// 剔除掉已经部署的
const dedupedEntries = []
const seenSlugs = new Set()
for (const entry of [{ slug: deploySlug, branchName }, ...entries]) {
if (seenSlugs.has(entry.slug)) {
continue
}
seenSlugs.add(entry.slug)
dedupedEntries.push(entry)
}
const indexHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>culture</title>
<style>
:root {
--bg: linear-gradient(135deg, #f4fbf8 0%, #e9f6ff 100%);
--card-bg: rgba(255, 255, 255, 0.88);
--card-border: rgba(137, 196, 181, 0.24);
--text-main: #16332d;
--text-sub: #5e7b74;
--button-bg: linear-gradient(135deg, #8be2c8 0%, #63d4c5 100%);
--button-shadow: 0 10px 24px rgba(99, 212, 197, 0.22);
--button-shadow-hover: 0 14px 28px rgba(99, 212, 197, 0.3);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text-main);
}
.page {
width: min(960px, calc(100vw - 32px));
margin: 0 auto;
padding: 40px 0 56px;
}
.hero {
margin-bottom: 24px;
padding: 28px 28px 24px;
border: 1px solid var(--card-border);
border-radius: 24px;
background: var(--card-bg);
backdrop-filter: blur(10px);
box-shadow: 0 20px 50px rgba(87, 143, 130, 0.12);
}
.hero h1 {
margin: 0 0 10px;
font-size: 28px;
line-height: 1.2;
}
.hero p {
margin: 0;
color: var(--text-sub);
font-size: 14px;
line-height: 1.7;
}
.account-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(137, 196, 181, 0.18);
}
.account-label {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
}
.account-input {
flex: 1 1 260px;
min-width: 220px;
height: 44px;
padding: 0 14px;
border: 1px solid rgba(108, 180, 163, 0.28);
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
color: var(--text-main);
font-size: 14px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.account-input:focus {
border-color: rgba(74, 180, 152, 0.8);
box-shadow: 0 0 0 4px rgba(116, 211, 187, 0.14);
}
.account-tip {
width: 100%;
margin: 0;
color: var(--text-sub);
font-size: 12px;
line-height: 1.6;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.branch-card {
padding: 18px;
border: 1px solid rgba(137, 196, 181, 0.2);
border-radius: 20px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 14px 30px rgba(126, 178, 167, 0.1);
}
.branch-name {
margin: 0 0 12px;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
.branch-slug {
margin: 0 0 16px;
color: var(--text-sub);
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
.branch-button {
width: 100%;
border: 0;
border-radius: 14px;
padding: 12px 16px;
background: var(--button-bg);
color: #13312a;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--button-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
}
.branch-button:hover {
transform: translateY(-2px);
box-shadow: var(--button-shadow-hover);
filter: saturate(1.05);
}
.branch-button:active {
transform: translateY(0);
}
@media (max-width: 640px) {
.page {
width: min(100vw - 20px, 960px);
padding-top: 20px;
}
.hero {
padding: 22px 18px 18px;
border-radius: 18px;
}
.hero h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<h1>Culture 分支导航</h1>
<p>这里展示当前可访问的测试分支,点击卡片按钮即可进入对应环境。</p>
<div class="account-panel">
<label class="account-label" for="accountInput">当前账号</label>
<input
id="accountInput"
class="account-input"
type="text"
placeholder="请输入账号"
/>
<p class="account-tip">账号会自动保存在浏览器本地,后续点击任意分支都会带上当前输入的 code 参数。</p>
</div>
</section>
<div class="grid">
${dedupedEntries
.map(
(item) => `
<section class="branch-card">
<h2 class="branch-name">分支「${item.branchName}」</h2>
<p class="branch-slug">访问路径:/${item.slug}/</p>
<button class="branch-button" onclick="goToBranch('${item.slug}')">
进入当前分支
</button>
</section>`,
)
.join('\n')}
</div>
</div>
<script>
const STORAGE_KEY = 'culture_account_code'
const DEFAULT_ACCOUNT = 'lijiabin'
const accountInput = document.getElementById('accountInput')
function getAccountCode() {
const storedValue = window.localStorage.getItem(STORAGE_KEY)
return storedValue && storedValue.trim() ? storedValue.trim() : DEFAULT_ACCOUNT
}
function setAccountCode(value) {
const finalValue = value && value.trim() ? value.trim() : DEFAULT_ACCOUNT
window.localStorage.setItem(STORAGE_KEY, finalValue)
if (accountInput && accountInput.value !== finalValue) {
accountInput.value = finalValue
}
return finalValue
}
function goToBranch(slug) {
const code = encodeURIComponent(getAccountCode())
window.location.href = '/' + slug + '/?code=' + code
}
if (accountInput) {
accountInput.value = getAccountCode()
accountInput.addEventListener('input', (event) => {
setAccountCode(event.target.value)
})
accountInput.addEventListener('blur', (event) => {
setAccountCode(event.target.value)
})
}
setAccountCode(getAccountCode())
</script>
</body>
</html>
`
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) {
reject(new Error(`SFTP Error: ${err.message}`))
return
}
const writeStream = sftp.createWriteStream(`${remoteRootPath}/index.html`, {
encoding: 'utf8',
})
writeStream.on('error', (error) => {
sftp.end()
reject(new Error(`写入失败: ${error.message}`))
})
writeStream.on('close', () => {
console.log('index.html 写入成功')
sftp.end()
resolve()
})
writeStream.end(indexHtml)
})
})
}
// 写入部署元信息
async function writeDeployMeta() {
const meta = {
branchName,
deploySlug,
deployedAt: new Date().toLocaleString('zh-CN', {
hour12: false,
timeZone: 'Asia/Shanghai',
}),
}
const metaContent = JSON.stringify(meta, null, 2)
await execCommand(`cat <<'EOF' > '${remoteMetaPath}'
${metaContent}
EOF`)
}
//链接服务器
let conn = new ssh.Client()
async function start() {
try {
// 1. 打包
await connectServer()
const folderList = await getFolderList()
const deployEntries = await getDeployEntries(folderList)
const alreadyDeployed = deployEntries.some((item) => item.slug === deploySlug)
if (!alreadyDeployed && deployEntries.length >= MAX_DEPLOY_BRANCH_COUNT) {
console.log(
`当前已部署分支数量已达到上限 ${MAX_DEPLOY_BRANCH_COUNT},请先手动删除以下分支中的一个或多个:`,
)
deployEntries.forEach((item, index) => {
console.log(`${index + 1}. ${item.branchName} (${item.slug})`)
})
return
}
await writeIndexHtml(deployEntries)
await build()
// 2. 压缩zip
await startZip()
// 3. 将zip文件传输至远程服务器
await connect()
// 4. 部署解压
await shellCmd(conn)
await uploadTar()
await shellCmd()
await writeDeployMeta()
console.log('部署完成')
console.log(`部署完成,当前分支「${branchName}」对应目录:${deploySlug}`)
} catch (error) {
console.error('Error:', error.message)
} finally {
// 5. 断开ssh,并删除本地压缩包
if (conn) {
conn.end()
}
delZip()
}
}
start()
/**
* 1. 本地构建项目
*/
function build() {
return new Promise((resolve, reject) => {
//对项目进行打包,然后生成压缩文件
let pro = cp.spawn(spawn, {
const pro = cp.spawn(spawn, {
shell: true,
stdio: 'inherit',
})
pro.on('exit', (code) => {
//打包完成后 开始链接目标服务器,并自动部署
if (code === 0) {
console.log('---构建成功---')
resolve()
} else {
reject(new Error('构建失败'))
return
}
reject(new Error('构建失败'))
})
})
}
/**
* 2. 将打包后文件压缩zip
* @returns
*/
function startZip() {
return new Promise((resolve, reject) => {
console.log('开始打包tar')
//定义打包格式和相关配置
console.log('开始打包 tar')
const archive = archiver('tar', {
gzip: true, // 如果需要压缩,可以使用 gzip
gzipOptions: { level: 9 }, // gzip 压缩级别
}).on('error', (err) => reject(err))
gzip: true,
gzipOptions: { level: 9 },
})
archive.on('error', (err) => {
reject(err)
})
console.log('zipPath', zipPath)
const output = fs.createWriteStream(zipPath)
//监听流的打包
output.on('close', (err) => {
console.log('err', err)
output.on('close', () => {
console.log('目标打包完成')
resolve(true)
})
//开始压缩
output.on('error', (err) => {
reject(err)
})
archive.pipe(output)
// 文件夹压缩
archive.directory(distPath, false)
archive.finalize()
})
}
/**
* 3. 将zip文件传输至远程服务器
*/
function connect() {
async function uploadTar() {
await execCommand(`mkdir -p ${servicePath}/${unzipDir}`)
return new Promise((resolve, reject) => {
conn
.on('ready', () => {
conn.sftp((err, sftp) => {
if (err) {
return reject(err)
reject(err)
return
}
sftp.fastPut(zipPath, serviceFilePath, {}, (err, result) => {
if (err) {
return reject(err)
sftp.fastPut(zipPath, serviceFilePath, {}, (putErr) => {
sftp.end()
if (putErr) {
reject(putErr)
return
}
//开始上传
console.log('文件上传成功')
resolve()
})
})
})
.on('error', (err) => reject(err))
.connect(connectInfo)
})
}
/**
* 4. 解压部署操作
* @param {*} conn
*/
async function shellCmd(conn) {
return new Promise((resolve, reject) => {
conn.shell((err, stream) => {
if (err) {
return reject(err)
}
//进入服务器暂存地址
//解压上传的压缩包
//移动解压后的文件到发布目录
//删除压缩包
//退出
const commands = `
cd ${servicePath} &&
mkdir -p ${unzipDir} &&
tar -xzf dist.tar.gz -C ${unzipDir} &&
rm -rf dist.tar.gz &&
exit
`
console.log('终端执行命令:', commands)
stream
.on('close', () => resolve())
.on('data', (data) => console.log(data.toString()))
.stderr.on('data', (data) => console.error('Error:', data.toString()))
stream.end(commands)
})
})
async function shellCmd() {
const command = `cd ${remoteBranchPath} && tar -xzf dist.tar.gz && rm -f dist.tar.gz`
console.log('终端执行命令:', command)
await execCommand(command)
}
/**
* 5. 删除本地的dist.zip
*/
function delZip() {
fs.unlink(zipPath, function (err) {
if (!fs.existsSync(zipPath)) {
return
}
fs.unlink(zipPath, (err) => {
if (err) {
console.error('删除文件失败:', err.message)
return
}
console.log('文件:' + zipPath + '删除成功!')
console.log(`文件: ${zipPath} 删除成功!`)
})
}
import { execSync } from 'node:child_process'
import { createHash } from 'node:crypto'
// 获取当前分支名 去掉空格 斜杠 换行 等特殊字符
export const getCurrentBranchName = () => {
const branchName = execSync('git branch --show-current', {
encoding: 'utf-8',
})
.trim()
.replaceAll(' ', '_')
.replaceAll('/', '_')
.replaceAll('\n', '_')
return branchName
}
// 根据分支名创建一个唯一的服务器文件夹名字
export const getDeploySlug = (branchName) => {
return `branch-${createHash('md5').update(branchName).digest('hex').slice(0, 8)}`
}
......@@ -17,6 +17,7 @@ export const app_config: { [key: string]: IConfig } = {
// 测试环境 暂时无测试环境部署
test: {
// baseUrl: 'http://culture.yswg.com.cn:8089', // 线上测试机
baseUrl: 'http://192.168.2.55:8089', // 首拥本地
loginType: 1,
wxRedirect: '',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment