- 新增 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)
170 lines
5.6 KiB
JavaScript
170 lines
5.6 KiB
JavaScript
/**
|
||
* 预警清理原子逻辑(§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
|
||
}
|