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' }