167 lines
5.4 KiB
JavaScript
167 lines
5.4 KiB
JavaScript
/**
|
||||
|
|
* 预警清理原子逻辑(§3.4 / AC-7 原子性)
|
|||
|
|
* 1. 读 package.json
|
|||
|
|
* 2. 按 semver 排序保留最新 N 个
|
|||
|
|
* 3. 删低版本 versions/time 记录
|
|||
|
|
* 4. 修正 dist-tags(latest 指向已删版本时指向保留的最新)
|
|||
|
|
* 5. 写回元数据
|
|||
|
|
* 6. 物理删 .tgz
|
|||
|
|
* 7. 记日志
|
|||
|
|
* 8. 返回「删除数量、释放空间」
|
|||
|
|
* 失败时不做任何文件修改(先备份→写临时文件→rename 提交)
|
|||
|
|
*/
|
|||
|
|
import fs from 'node:fs'
|
|||
|
|
import path from 'node:path'
|
|||
|
|
import semverCompare from './semver.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 = []
|
|||
|
|
for (const v of toDelete) {
|
|||
|
|
const info = nextMeta.versions[v] || {}
|
|||
|
|
delete nextMeta.versions[v]
|
|||
|
|
delete time[v]
|
|||
|
|
// 物理文件大小(用于释放空间统计)
|
|||
|
|
const tgz = path.join(pkgDir, `${v}.tgz`)
|
|||
|
|
try {
|
|||
|
|
freed.push({ v, size: fs.statSync(tgz).size, tgz })
|
|||
|
|
} catch {
|
|||
|
|
freed.push({ v, size: info.dist?.unpackedSize || 0, tgz })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
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) {
|
|||
|
|
try {
|
|||
|
|
if (f.tgz && fs.existsSync(f.tgz)) fs.unlinkSync(f.tgz)
|
|||
|
|
} 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 tgz = path.join(pkgDir, `${version}.tgz`)
|
|||
|
|
try {
|
|||
|
|
if (fs.existsSync(tgz)) {
|
|||
|
|
size = fs.statSync(tgz).size
|
|||
|
|
fs.unlinkSync(tgz)
|
|||
|
|
}
|
|||
|
|
} 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
|
|||
|
|
}
|