import { execFileSync } from 'node:child_process'; import { lstat, readdir, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const GIB = 1024 ** 3; const DEFAULT_MAX_GIB = 120; const REPOSITORY_MARKERS = [ 'package.json', 'server-rs/Cargo.toml', 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', ]; const MANAGED_CACHE_TARGETS = [ { id: 'server-rs', targetRelativePath: 'server-rs/target', incrementalRelativePath: 'server-rs/target/debug/incremental', }, { id: 'agc', targetRelativePath: 'apps/ai-game-creator-shell/src-tauri/target', incrementalRelativePath: 'apps/ai-game-creator-shell/src-tauri/target/debug/incremental', }, ]; const ACTIVE_BUILD_PROCESS_NAMES = new Set([ 'cargo', 'cargo.exe', 'rustc', 'rustc.exe', ]); function pathStaysInside(root, candidate) { const relative = path.relative(root, candidate); return ( relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ); } async function pathExists(value) { try { await lstat(value); return true; } catch (error) { if (error?.code === 'ENOENT') return false; throw error; } } async function assertRepositoryRoot(repoRoot) { const resolvedRoot = path.resolve(repoRoot); const rootInfo = await lstat(resolvedRoot); if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) { throw new Error(`仓库根目录不是普通目录: ${resolvedRoot}`); } for (const marker of REPOSITORY_MARKERS) { const markerPath = path.join(resolvedRoot, ...marker.split('/')); const markerInfo = await lstat(markerPath).catch((error) => { if (error?.code === 'ENOENT') { throw new Error(`仓库标记缺失: ${marker}`); } throw error; }); if (!markerInfo.isFile() || markerInfo.isSymbolicLink()) { throw new Error(`仓库标记不是普通文件: ${marker}`); } } return resolvedRoot; } async function assertManagedPath(repoRoot, relativePath) { if ( typeof relativePath !== 'string' || relativePath.length === 0 || path.isAbsolute(relativePath) || relativePath.includes('\\') || relativePath .split('/') .some((part) => !part || part === '.' || part === '..') ) { throw new Error(`受控缓存相对路径无效: ${relativePath}`); } const target = path.resolve(repoRoot, ...relativePath.split('/')); if (!pathStaysInside(repoRoot, target)) { throw new Error(`受控缓存路径越出仓库: ${relativePath}`); } let current = repoRoot; for (const part of relativePath.split('/')) { current = path.join(current, part); if (!(await pathExists(current))) break; const info = await lstat(current); if (info.isSymbolicLink()) { throw new Error(`受控缓存路径包含符号链接或 junction: ${relativePath}`); } } if (await pathExists(target)) { const info = await lstat(target); if (!info.isDirectory() || info.isSymbolicLink()) { throw new Error(`受控缓存目标不是普通目录: ${relativePath}`); } } return target; } async function measureDirectory(directory) { if (!(await pathExists(directory))) { return { exists: false, bytes: 0, files: 0, directories: 0, links: 0 }; } const pending = [directory]; let bytes = 0; let files = 0; let directories = 0; let links = 0; while (pending.length > 0) { const current = pending.pop(); const entries = await readdir(current, { withFileTypes: true }).catch( (error) => { if (error?.code === 'ENOENT') return []; throw error; }, ); directories += 1; for (const entry of entries) { const entryPath = path.join(current, entry.name); if (entry.isSymbolicLink()) { links += 1; } else if (entry.isDirectory()) { pending.push(entryPath); } else if (entry.isFile()) { const fileInfo = await stat(entryPath).catch((error) => { if (error?.code === 'ENOENT') return null; throw error; }); if (!fileInfo) continue; bytes += fileInfo.size; files += 1; } } } return { exists: true, bytes, files, directories, links }; } async function resolveManagedCacheTargets(repoRoot) { const root = await assertRepositoryRoot(repoRoot); const targets = []; for (const spec of MANAGED_CACHE_TARGETS) { targets.push({ ...spec, targetPath: await assertManagedPath(root, spec.targetRelativePath), incrementalPath: await assertManagedPath( root, spec.incrementalRelativePath, ), }); } return { repoRoot: root, targets }; } async function auditRustBuildCache({ repoRoot, maxBytes = DEFAULT_MAX_GIB * GIB, } = {}) { const resolved = await resolveManagedCacheTargets(repoRoot); const targets = []; for (const target of resolved.targets) { targets.push({ id: target.id, targetRelativePath: target.targetRelativePath, incrementalRelativePath: target.incrementalRelativePath, target: await measureDirectory(target.targetPath), incremental: await measureDirectory(target.incrementalPath), }); } const totalBytes = targets.reduce((sum, item) => sum + item.target.bytes, 0); const incrementalBytes = targets.reduce( (sum, item) => sum + item.incremental.bytes, 0, ); return { schemaVersion: 'genarrative.rust-build-cache-audit.v1', repoRoot: resolved.repoRoot, maxBytes, warning: totalBytes > maxBytes, totalBytes, incrementalBytes, targets, }; } function parseWindowsTaskList(output) { return output .split(/\r?\n/u) .map((line) => /^"((?:[^"]|"")*)"/u.exec(line)?.[1]?.replaceAll('""', '"')) .filter(Boolean); } function readActiveProcessNames() { if (process.platform === 'win32') { const output = execFileSync('tasklist', ['/fo', 'csv', '/nh'], { encoding: 'utf8', windowsHide: true, }); return parseWindowsTaskList(output); } const output = execFileSync('ps', ['-A', '-o', 'comm='], { encoding: 'utf8', }); return output .split(/\r?\n/u) .map((value) => value.trim()) .filter(Boolean); } function activeRustBuildProcesses(processNames) { return [ ...new Set( processNames .map((name) => name.toLowerCase()) .filter((name) => ACTIVE_BUILD_PROCESS_NAMES.has(name)), ), ].sort(); } async function cleanIncrementalCaches({ repoRoot, apply = false, processNames, } = {}) { const auditBefore = await auditRustBuildCache({ repoRoot }); const resolved = await resolveManagedCacheTargets(repoRoot); if (!apply) { return { schemaVersion: 'genarrative.rust-build-cache-clean.v1', applied: false, freedBytes: 0, plannedBytes: auditBefore.incrementalBytes, targets: resolved.targets.map((target) => target.incrementalRelativePath), auditBefore, }; } const activeProcesses = activeRustBuildProcesses( processNames ?? readActiveProcessNames(), ); if (activeProcesses.length > 0) { throw new Error( `检测到活跃 Rust 构建进程,拒绝清理: ${activeProcesses.join('、')}`, ); } for (const target of resolved.targets) { await assertManagedPath(resolved.repoRoot, target.incrementalRelativePath); if (!(await pathExists(target.incrementalPath))) continue; await rm(target.incrementalPath, { recursive: true, force: false, maxRetries: 3, retryDelay: 250, }); if (await pathExists(target.incrementalPath)) { throw new Error( `增量缓存删除后仍存在: ${target.incrementalRelativePath}`, ); } } const auditAfter = await auditRustBuildCache({ repoRoot }); return { schemaVersion: 'genarrative.rust-build-cache-clean.v1', applied: true, freedBytes: Math.max(0, auditBefore.totalBytes - auditAfter.totalBytes), plannedBytes: auditBefore.incrementalBytes, targets: resolved.targets.map((target) => target.incrementalRelativePath), auditBefore, auditAfter, }; } function parseArguments(argv) { const options = { cleanIncremental: false, apply: false, json: false, maxGiB: DEFAULT_MAX_GIB, }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--clean-incremental') { options.cleanIncremental = true; } else if (argument === '--apply') { options.apply = true; } else if (argument === '--json') { options.json = true; } else if (argument === '--max-gib') { const value = Number(argv[index + 1]); if (!Number.isFinite(value) || value <= 0) { throw new Error('--max-gib 必须是正数'); } options.maxGiB = value; index += 1; } else { throw new Error(`未知参数: ${argument}`); } } if (options.apply && !options.cleanIncremental) { throw new Error('--apply 只能与 --clean-incremental 一起使用'); } return options; } function formatGiB(bytes) { return (bytes / GIB).toFixed(2); } function printAudit(audit) { for (const target of audit.targets) { console.log( `[rust-cache] ${target.id}: target=${formatGiB(target.target.bytes)} GiB, incremental=${formatGiB(target.incremental.bytes)} GiB`, ); } console.log( `[rust-cache] total=${formatGiB(audit.totalBytes)} GiB, incremental=${formatGiB(audit.incrementalBytes)} GiB, threshold=${formatGiB(audit.maxBytes)} GiB`, ); if (audit.warning) { console.warn('[rust-cache] WARNING: Rust 构建缓存已超过本地建议阈值。'); } } function isDirectExecution() { return ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ); } async function main() { const options = parseArguments(process.argv.slice(2)); const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', ); const result = options.cleanIncremental ? await cleanIncrementalCaches({ repoRoot, apply: options.apply, }) : await auditRustBuildCache({ repoRoot, maxBytes: options.maxGiB * GIB, }); if (options.json) { console.log(JSON.stringify(result, null, 2)); return; } if (options.cleanIncremental) { printAudit(result.auditBefore); if (!result.applied) { console.log( '[rust-cache] dry-run:未删除任何文件;追加 --apply 才会清理固定 incremental 目录。', ); return; } printAudit(result.auditAfter); console.log(`[rust-cache] freed=${formatGiB(result.freedBytes)} GiB`); return; } printAudit(result); } export { ACTIVE_BUILD_PROCESS_NAMES, activeRustBuildProcesses, assertManagedPath, auditRustBuildCache, cleanIncrementalCaches, DEFAULT_MAX_GIB, formatGiB, MANAGED_CACHE_TARGETS, parseArguments, parseWindowsTaskList, resolveManagedCacheTargets, }; if (isDirectExecution()) { main().catch((error) => { console.error( `[rust-cache] ${error instanceof Error ? error.message : String(error)}`, ); process.exitCode = 1; }); }