155 lines
8.5 KiB
JavaScript
155 lines
8.5 KiB
JavaScript
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import os from 'node:os'
|
||
import crypto from 'node:crypto'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { execFileSync, spawnSync } from 'node:child_process'
|
||
import { parseArgs } from 'node:util'
|
||
|
||
export const frontendRoot = fileURLToPath(new URL('../', import.meta.url))
|
||
export const imageRepository = 'harbor.fuhua91.com/game/sw2'
|
||
export const frontendImage = tag => `${imageRepository}:${validTag(tag)}`
|
||
export const options = (definitions) => parseArgs({
|
||
options: { help: { type: 'boolean' }, ...definitions },
|
||
args: process.argv.slice(2).filter((arg, i) => !(i === 0 && arg === '--'))
|
||
}).values
|
||
|
||
export function run(command, args, settings = {}) {
|
||
return execFileSync(command, args, { cwd: frontendRoot, stdio: 'inherit', ...settings })
|
||
}
|
||
const capture = (command, args, settings = {}) => run(command, args, { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...settings }).trim()
|
||
const git = (...args) => capture('git', args)
|
||
|
||
export function validTag(value) {
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(value || '')) throw new Error('版本号只能包含字母、数字、点、下划线和短横线,最长 80 个字符')
|
||
return value
|
||
}
|
||
export const shellQuote = value => `'${String(value).replaceAll("'", "'\\''")}'`
|
||
|
||
export function assertClean() {
|
||
for (const args of [['diff', '--quiet', '--', '.'], ['diff', '--cached', '--quiet', '--', '.']]) {
|
||
const result = spawnSync('git', args, { cwd: frontendRoot, stdio: 'pipe' })
|
||
if (result.error) throw result.error
|
||
if (result.status !== 0) throw new Error('请先提交前端源代码及生成的 src/pathInfo.json,再打包')
|
||
}
|
||
if (git('ls-files', '--others', '--exclude-standard', '--', '.')) throw new Error('前端目录存在未提交文件,请提交或配置忽略规则后再打包')
|
||
}
|
||
|
||
export function sourceInfo(args = {}) {
|
||
assertClean()
|
||
const prefix = git('rev-parse', '--show-prefix').replace(/\/$/, '')
|
||
const tree = git('rev-parse', prefix ? `HEAD:${prefix}` : 'HEAD^{tree}')
|
||
const revision = git('rev-parse', '--verify', '--end-of-options', `${args['source-ref'] || 'HEAD'}^{commit}`)
|
||
if (args['source-ref'] && git('rev-parse', `${revision}^{tree}`) !== tree) throw new Error('--source-ref 的根目录必须与当前前端源码树一致')
|
||
return { formatVersion: 1, version: validTag(args.tag || revision.slice(0, 12)), revision, tree, sourceSubdirectory: args['source-ref'] ? '' : prefix, builtAt: new Date().toISOString() }
|
||
}
|
||
|
||
function pnpm(args) {
|
||
const executable = process.env.npm_execpath
|
||
if (executable && /\.(?:c?js|mjs)$/.test(executable)) return run(process.execPath, [executable, ...args])
|
||
if (process.platform !== 'win32') return run('pnpm', args)
|
||
// Only these fixed commands enter cmd.exe; no user argument is interpolated.
|
||
const commands = { build: 'pnpm build', install: 'pnpm install --frozen-lockfile' }
|
||
return run(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commands[args[0]]])
|
||
}
|
||
|
||
function regularTree(directory) {
|
||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||
const filename = path.join(directory, entry.name)
|
||
if (entry.isDirectory()) regularTree(filename)
|
||
else if (!entry.isFile()) throw new Error(`构建产物不允许符号链接或特殊文件:${filename}`)
|
||
}
|
||
}
|
||
|
||
export function writeBuildInfo(directory, info) {
|
||
fs.writeFileSync(path.join(directory, 'build-info.json'), JSON.stringify(info, null, 2) + '\n')
|
||
fs.writeFileSync(path.join(directory, 'version.txt'), info.version + '\n')
|
||
}
|
||
|
||
export function packageRelease(args = {}) {
|
||
const info = sourceInfo(args)
|
||
const output = path.resolve(frontendRoot, args.output || '.cache/deploy')
|
||
fs.mkdirSync(output, { recursive: true })
|
||
const archive = path.join(output, `sw2-frontend-${info.version}.tar.gz`)
|
||
if (fs.existsSync(archive)) throw new Error(`发布包已存在,请复用该包或指定新的 --tag:${archive}`)
|
||
const lock = `${archive}.lock`
|
||
const descriptor = fs.openSync(lock, 'wx')
|
||
fs.closeSync(descriptor)
|
||
let stage, created = false, complete = false
|
||
try {
|
||
if (!args['skip-install']) pnpm(['install', '--frozen-lockfile'])
|
||
pnpm(['build'])
|
||
const after = sourceInfo(args)
|
||
if (after.tree !== info.tree || after.revision !== info.revision) throw new Error('构建期间源码发生变化,请重新打包')
|
||
const dist = path.join(frontendRoot, 'dist')
|
||
if (!fs.statSync(path.join(dist, 'index.html')).isFile()) throw new Error('缺少 dist/index.html')
|
||
regularTree(dist)
|
||
stage = fs.mkdtempSync(path.join(output, '.frontend-stage-'))
|
||
fs.cpSync(dist, path.join(stage, 'dist'), { recursive: true })
|
||
writeBuildInfo(path.join(stage, 'dist'), info)
|
||
for (const [from, to] of [['runtime.Dockerfile', 'Dockerfile'], ['nginx.conf', 'nginx.conf'], ['deploy.sh', 'deploy.sh']]) {
|
||
fs.writeFileSync(path.join(stage, to), fs.readFileSync(path.join(frontendRoot, 'deploy', from), 'utf8').replaceAll('\r\n', '\n'))
|
||
}
|
||
fs.writeFileSync(path.join(stage, 'release.json'), JSON.stringify(info, null, 2) + '\n')
|
||
const fd = fs.openSync(archive, 'wx'); fs.closeSync(fd); created = true
|
||
run('tar', ['-czf', archive, '-C', stage, '.'])
|
||
const checksum = sha256(archive)
|
||
fs.writeFileSync(`${archive}.sha256`, `${checksum} ${path.basename(archive)}\n`)
|
||
complete = true
|
||
console.log(`发布包:${archive}\nSHA256:${checksum}`)
|
||
return { archive, checksum, info }
|
||
} finally {
|
||
if (stage && path.dirname(stage) === output && path.basename(stage).startsWith('.frontend-stage-')) fs.rmSync(stage, { recursive: true })
|
||
if (created && !complete) fs.rmSync(archive)
|
||
fs.rmSync(lock)
|
||
}
|
||
}
|
||
|
||
export const sha256 = filename => crypto.createHash('sha256').update(fs.readFileSync(filename)).digest('hex')
|
||
|
||
export function validateEntries(names, types) {
|
||
for (const name of names) {
|
||
const clean = name.replace(/^\.\//, '').replace(/\/$/, '')
|
||
if (clean === '' || clean === '.') continue
|
||
if (name.includes('\\') || name.includes('\r') || name.startsWith('/') || clean.split('/').includes('..')) throw new Error(`不安全的包内路径:${name}`)
|
||
if (!['Dockerfile', 'nginx.conf', 'deploy.sh', 'release.json', 'dist'].includes(clean) && !clean.startsWith('dist/')) throw new Error(`发布包包含非前端产物:${name}`)
|
||
}
|
||
if (types.some(type => type !== '-' && type !== 'd')) throw new Error('发布包不能包含符号链接、硬链接或特殊文件')
|
||
for (const required of ['Dockerfile', 'nginx.conf', 'deploy.sh', 'release.json', 'dist/index.html', 'dist/version.txt']) {
|
||
if (!names.some(name => name.replace(/^\.\//, '') === required)) throw new Error(`发布包缺少 ${required}`)
|
||
}
|
||
}
|
||
|
||
export function inspectPackage(filename) {
|
||
const archive = path.resolve(filename)
|
||
const names = capture('tar', ['-tzf', archive]).split(/\r?\n/)
|
||
const types = capture('tar', ['-tvzf', archive]).split(/\r?\n/).map(line => line[0])
|
||
validateEntries(names, types)
|
||
const entry = name => names.find(item => item.replace(/^\.\//, '') === name)
|
||
const info = JSON.parse(capture('tar', ['-xOf', archive, entry('release.json')]))
|
||
validTag(info.version)
|
||
if (info.formatVersion !== 1 || !/^[a-f0-9]{40,64}$/.test(info.revision || '') || !/^[a-f0-9]{40,64}$/.test(info.tree || '')) throw new Error('发布包版本信息无效')
|
||
if (capture('tar', ['-xOf', archive, entry('dist/version.txt')]) !== info.version) throw new Error('发布包与前端版本标记不一致')
|
||
return { archive, info, checksum: sha256(archive) }
|
||
}
|
||
|
||
export function deploymentTarget(args = {}) {
|
||
const host = args.host || '192.168.2.226', user = args.user || 'root', port = String(args.port || 22)
|
||
const base = args.directory || '/www/wwwroot/sw2.fuhua91.com'
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(host) || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(user)) throw new Error('SSH 主机或用户名无效')
|
||
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('SSH 端口无效')
|
||
if (!/^\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(base) || base.split('/').some(part => part === '..' || part === '.')) throw new Error('部署目录须为不含空格及相对路径段的绝对路径')
|
||
return { host, user, port, base, remote: `${user}@${host}` }
|
||
}
|
||
|
||
export function fail(error) {
|
||
console.error(error.message || String(error))
|
||
process.exitCode = 1
|
||
}
|
||
|
||
export function bashExecutable() {
|
||
if (process.env.BASH) return process.env.BASH
|
||
const gitBash = path.join(process.env.ProgramFiles || 'C:/Program Files', 'Git', 'bin', 'bash.exe')
|
||
return os.platform() === 'win32' && fs.existsSync(gitBash) ? gitBash : 'bash'
|
||
}
|