/** * 预警清理原子逻辑(§3.4 / AC-7 原子性) * 1. 读 package.json * 2. 按 semver 排序保留最新 N 个 * 3. 删低版本 versions/time 记录 * 4. 修正 dist-tags(latest 指向已删版本时指向保留的最新) * 5. 写回元数据 * 6. 物理删 .tgz(按物理文件名匹配,兼容 {version}.tgz 与 {pkgname}-{version}.tgz) * 7. 记日志 * 8. 返回「删除数量、释放空间」 * 失败时不做任何文件修改(先备份→写临时文件→rename 提交) */ import fs from 'node:fs' import path from 'node:path' import semverCompare from './semver.js' import { listTgzFiles, findTgzForVersion } from './tgz.js' export function cleanPackage(storageDir, pkgName, keep) { const pkgDir = locatePackageDir(storageDir, pkgName) if (!pkgDir) return { ok: false, message: `未找到包 ${pkgName}` } const pkgFile = path.join(pkgDir, 'package.json') let meta try { meta = JSON.parse(fs.readFileSync(pkgFile, 'utf8')) } catch { return { ok: false, message: `读取 ${pkgName} 元数据失败` } } const versions = Object.keys(meta.versions || {}) if (!versions.length) return { ok: false, message: '包内没有可清理的版本' } // 按 semver 数值排序(旧 → 新),保留最新 keep 个 const sorted = versions.sort((a, b) => semverCompare(a, b)) const toDelete = sorted.slice(0, Math.max(0, sorted.length - keep)) if (!toDelete.length) { return { ok: false, message: '版本数未超过保留值,无需清理' } } // —— 准备提交内容 —— const nextMeta = JSON.parse(JSON.stringify(meta)) const time = nextMeta.time || {} const freed = [] // 先列物理 .tgz,按文件名匹配要删的版本(不猜命名规则) const tgzFiles = listTgzFiles(pkgDir) for (const v of toDelete) { const info = nextMeta.versions[v] || {} delete nextMeta.versions[v] delete time[v] const hit = findTgzForVersion(tgzFiles, v) freed.push({ v, size: hit ? hit.size : (info.dist?.unpackedSize || 0), file: hit ? hit.path : null, }) } nextMeta.time = time // —— 修正 dist-tags —— const distTags = nextMeta['dist-tags'] || {} const kept = sorted.slice(-keep) for (const [tag, v] of Object.entries(distTags)) { if (toDelete.includes(v)) { distTags[tag] = kept[kept.length - 1] } } nextMeta['dist-tags'] = distTags // —— 原子提交:临时文件写入 → rename —— const tmp = `${pkgFile}.clean.tmp` try { fs.writeFileSync(tmp, JSON.stringify(nextMeta, null, 2)) fs.renameSync(tmp, pkgFile) // 同目录 rename 为原子操作 } catch (err) { try { fs.unlinkSync(tmp) } catch { /* ignore */ } return { ok: false, message: `写回元数据失败:${err.message}` } } // —— 物理删除 tgz(元数据已提交,删除失败仅记录不阻塞) —— const deletedBytes = freed.reduce((s, f) => s + f.size, 0) for (const f of freed) { if (!f.file) continue // 该版本无实体文件(元数据残留),无需删除 try { if (fs.existsSync(f.file)) fs.unlinkSync(f.file) } catch { /* 忽略单个文件删除失败 */ } } const freedText = `${(deletedBytes / 1024 / 1024).toFixed(1)} MB` return { ok: true, pkgName, deletedVersions: toDelete.join(', '), deletedCount: toDelete.length, freedSpace: freedText, freedBytes: deletedBytes, } } /** 删除单个版本(抽屉内单版本删除) */ export function deleteSingleVersion(storageDir, pkgName, version) { const pkgDir = locatePackageDir(storageDir, pkgName) if (!pkgDir) return { ok: false, message: `未找到包 ${pkgName}` } const pkgFile = path.join(pkgDir, 'package.json') let meta try { meta = JSON.parse(fs.readFileSync(pkgFile, 'utf8')) } catch { return { ok: false, message: '读取元数据失败' } } if (!meta.versions || !meta.versions[version]) { return { ok: false, message: `版本 ${version} 不存在` } } const nextMeta = JSON.parse(JSON.stringify(meta)) const info = nextMeta.versions[version] delete nextMeta.versions[version] if (nextMeta.time) delete nextMeta.time[version] // 修正 dist-tags const distTags = nextMeta['dist-tags'] || {} const remaining = Object.keys(nextMeta.versions).sort((a, b) => semverCompare(a, b)) for (const [tag, v] of Object.entries(distTags)) { if (v === version) distTags[tag] = remaining[remaining.length - 1] || version } nextMeta['dist-tags'] = distTags const tmp = `${pkgFile}.clean.tmp` try { fs.writeFileSync(tmp, JSON.stringify(nextMeta, null, 2)) fs.renameSync(tmp, pkgFile) } catch (err) { try { fs.unlinkSync(tmp) } catch { /* ignore */ } return { ok: false, message: `写回元数据失败:${err.message}` } } let size = info.dist?.unpackedSize || 0 const hit = findTgzForVersion(listTgzFiles(pkgDir), version) if (hit) { size = hit.size try { fs.unlinkSync(hit.path) } catch { /* ignore */ } } return { ok: true, freedSpace: `${(size / 1024 / 1024).toFixed(1)} MB`, } } /** 定位包目录(支持 scoped:@scope/name 嵌套目录) */ export function locatePackageDir(storageDir, pkgName) { const parts = pkgName.split('/') const candidates = [] if (parts.length === 2 && parts[0].startsWith('@')) { candidates.push(path.join(storageDir, parts[0], parts[1])) } else { candidates.push(path.join(storageDir, parts[parts.length - 1])) // 兼容 scoped 包以全名作为单一目录的情况 if (parts.length === 2) candidates.push(path.join(storageDir, pkgName)) } for (const c of candidates) { if (fs.existsSync(path.join(c, 'package.json'))) return c } return null }