Files
verdaccio-cache-manager/backend/clean.js
T
yinjun.chen1 115c5ccc9a fix(clean): 物理 tgz 按文件匹配删除 + semver 完整语义排序
- 新增 tgz.js:统一列出包目录物理 .tgz 文件,支持新版 {version}.tgz 精确匹配
  与旧版 {pkgname}-{version}.tgz 后缀匹配,删除按真实文件 size 统计
- semver.js 重写:完整 npm 语义比较(prerelease 逐标识比较、数字<非数字、
  公共前缀更长者更大),修复原实现剥离 prerelease 导致 beta 挤掉正式版的隐患
- clean.js / scanner.js / server.js 接入统一口径
- 新增 test/semver.test.js 与 test/tgz.test.js 回归测试(node --test)
2026-09-21 09:49:38 +08:00

170 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 预警清理原子逻辑(§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
}