import { execFile, spawn } from 'node:child_process' import { promises as fsp, statSync } from 'node:fs' import * as path from 'node:path' /** Gitea 工单附件上限。压缩后的 tgz 超过这个值,服务器会拒收。 */ export const HANDOFF_ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024 export function formatByteSize(bytes: number): string { const mb = bytes / (1024 * 1024) if (mb >= 10) return `${Math.round(mb)}MB` if (mb >= 1) return `${mb.toFixed(1)}MB` const kb = bytes / 1024 if (kb >= 10) return `${Math.round(kb)}KB` if (kb >= 1) return `${kb.toFixed(1)}KB` return `${bytes}B` } export class HandoffArchiveTooLargeError extends Error { readonly packedBytes: number readonly limitBytes: number constructor(packedBytes: number, limitBytes: number) { super(`压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}`) this.name = 'HandoffArchiveTooLargeError' this.packedBytes = packedBytes this.limitBytes = limitBytes } } function tarUnpack(args: string[]): Promise { return new Promise((resolve, reject) => { execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => { if (err) { reject(new Error(`tar ${args[0]} 失败: ${(stderr ?? '').toString().trim() || err.message}`)) return } resolve() }) }) } export async function packHandoff( stagingDir: string, outFile: string, opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void }, ): Promise { const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES await fsp.mkdir(path.dirname(outFile), { recursive: true }) await new Promise((resolve, reject) => { const child = spawn('tar', ['-czf', outFile, '-C', stagingDir, '.'], { stdio: ['ignore', 'ignore', 'pipe'] }) const decode = new TextDecoder() let stderr = '' let settled = false let poll: ReturnType | undefined child.stderr?.on('data', (chunk: Uint8Array) => { stderr += decode.decode(chunk, { stream: true }) }) const finish = (err?: Error): void => { if (settled) return settled = true if (poll) clearInterval(poll) if (err) reject(err) else resolve() } const checkSize = (): void => { let size = 0 try { size = statSync(outFile).size } catch { return } opts?.onProgress?.(size) // 产出一超过上限就杀掉。等 tar 自己打完再失败,大会话会卡很久。 if (size > maxBytes) { child.kill('SIGKILL') finish(new HandoffArchiveTooLargeError(size, maxBytes)) } } poll = setInterval(checkSize, 200) child.on('error', err => finish(err)) child.on('close', (code) => { if (settled) return let size = 0 try { size = statSync(outFile).size } catch { // 包还没写出来 } opts?.onProgress?.(size) if (size > maxBytes) { finish(new HandoffArchiveTooLargeError(size, maxBytes)) return } if (code === 0) finish() else finish(new Error(`tar -czf 失败: ${stderr.trim() || `exit ${code}`}`)) }) }) } export async function unpackHandoff(archive: string, dstDir: string): Promise { await fsp.mkdir(dstDir, { recursive: true }) await tarUnpack(['-xzf', archive, '-C', dstDir]) }