Files
verdaccio-cache-manager/backend/clean.js
T
陈银军 0a9ca8894a feat: Verdaccio 缓存可视化管理面板(前端 + 后端 + Docker)
- 前端 Vue3+Vite+Element Plus 按需引入,双主题(bright/dark)CSS 变量
- 三端响应式(桌面/平板/手机),登录/概览/检索/阈值/忽略/动态 6 页面
- 后端 Express + better-sqlite3,目录扫描 + demo 模式,清理原子写入
- Dockerfile 多阶段构建 + docker-compose(storage 只读挂载)
2026-09-19 15:00:04 +08:00

167 lines
5.4 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
* 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
}