397 lines
11 KiB
JavaScript
397 lines
11 KiB
JavaScript
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;
|
||
});
|
||
}
|