222 lines
7.1 KiB
JavaScript
222 lines
7.1 KiB
JavaScript
/**
|
||||
|
|
* Verdaccio 缓存目录扫描(§12.1)
|
|||
|
|
* - storage 目录解析优先级:VERDACCIO_STORAGE 环境变量 → UI 设置(settings.storage_dir)
|
|||
|
|
* - 递归扫描目录内所有 package.json,解析 versions/time,匹配 .tgz 计算占用,支持 scoped 包
|
|||
|
|
* - 未配置或无效时进入 demo 模式(VCM_DEMO=1 或首次无配置)
|
|||
|
|
*/
|
|||
|
|
import fs from 'node:fs'
|
|||
|
|
import path from 'node:path'
|
|||
|
|
import { getSetting } from './db.js'
|
|||
|
|
|
|||
|
|
// —— storage 目录解析 ——
|
|||
|
|
export function resolveStorageDir() {
|
|||
|
|
return process.env.VERDACCIO_STORAGE || getSetting('storage_dir') || null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function isValidStorage(dir) {
|
|||
|
|
if (!dir) return false
|
|||
|
|
try {
|
|||
|
|
const st = fs.statSync(dir)
|
|||
|
|
if (!st.isDirectory()) return false
|
|||
|
|
fs.accessSync(dir, fs.constants.R_OK)
|
|||
|
|
return true
|
|||
|
|
} catch {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function validateStorage(dir) {
|
|||
|
|
if (!isValidStorage(dir)) return { ok: false, message: '目录不存在或不可读' }
|
|||
|
|
// 至少包含 package.json 或 .tgz 之一,才算有效缓存目录
|
|||
|
|
const files = listFiles(dir, 1)
|
|||
|
|
const hasPkg = files.some(f => f.endsWith('package.json'))
|
|||
|
|
const hasTgz = files.some(f => f.endsWith('.tgz'))
|
|||
|
|
if (!hasPkg && !hasTgz) {
|
|||
|
|
return { ok: false, message: '目录中未找到 package.json / .tgz,不是有效的 Verdaccio 缓存目录' }
|
|||
|
|
}
|
|||
|
|
return { ok: true, message: 'ok' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function listFiles(dir, depth = Infinity, out = []) {
|
|||
|
|
let entries
|
|||
|
|
try {
|
|||
|
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|||
|
|
} catch {
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
for (const e of entries) {
|
|||
|
|
if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === '.DS_Store') continue
|
|||
|
|
const full = path.join(dir, e.name)
|
|||
|
|
if (e.isDirectory()) {
|
|||
|
|
if (depth > 0) listFiles(full, depth - 1, out)
|
|||
|
|
} else {
|
|||
|
|
out.push(full)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readJson(file) {
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|||
|
|
} catch {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fmtMb(bytes) {
|
|||
|
|
return Number((bytes / 1024 / 1024).toFixed(1))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 目录名 → 包名(支持 scoped:@scope/name)
|
|||
|
|
function dirToPkgName(segments) {
|
|||
|
|
if (segments.length >= 2 && segments[0].startsWith('@')) {
|
|||
|
|
return `${segments[0]}/${segments[1]}`
|
|||
|
|
}
|
|||
|
|
return segments[segments.length - 1]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 从 package.json 所在目录向上推导包名
|
|||
|
|
function pkgNameFromDir(pkgDir, storageDir) {
|
|||
|
|
const rel = path.relative(storageDir, pkgDir).split(path.sep)
|
|||
|
|
return dirToPkgName(rel)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseVersion(v) {
|
|||
|
|
// 去前缀 v、prerelease 后缀仅比较主体
|
|||
|
|
return (String(v).replace(/^v/, '') || '').trim()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 扫描 storage 目录
|
|||
|
|
* @returns {Array} 包列表(versionsDetail: [{version, size(MB), date}])
|
|||
|
|
*/
|
|||
|
|
export function scanStorage(storageDir) {
|
|||
|
|
const packages = []
|
|||
|
|
if (!isValidStorage(storageDir)) return packages
|
|||
|
|
|
|||
|
|
const pkgFiles = listFiles(storageDir).filter(f => f.endsWith('package.json'))
|
|||
|
|
const storageRoot = path.resolve(storageDir)
|
|||
|
|
|
|||
|
|
for (const pkgFile of pkgFiles) {
|
|||
|
|
const pkgDir = path.dirname(pkgFile)
|
|||
|
|
const meta = readJson(pkgFile)
|
|||
|
|
if (!meta) {
|
|||
|
|
// 元数据损坏:标记异常包,不影响其他包
|
|||
|
|
packages.push({
|
|||
|
|
name: pkgNameFromDir(pkgDir, storageRoot),
|
|||
|
|
status: 'broken',
|
|||
|
|
versions: 0,
|
|||
|
|
size: 0,
|
|||
|
|
lastUpdated: null,
|
|||
|
|
versionsDetail: [],
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const versionsObj = meta.versions || {}
|
|||
|
|
const timeObj = meta.time || {}
|
|||
|
|
const distTags = meta['dist-tags'] || {}
|
|||
|
|
|
|||
|
|
const versionsDetail = []
|
|||
|
|
let totalBytes = 0
|
|||
|
|
for (const [v, info] of Object.entries(versionsObj)) {
|
|||
|
|
// tgz 物理文件:Verdaccio 约定 {pkgDir}/{version}.tgz
|
|||
|
|
const tgz = path.join(pkgDir, `${v}.tgz`)
|
|||
|
|
let size = 0
|
|||
|
|
try {
|
|||
|
|
size = fs.statSync(tgz).size
|
|||
|
|
} catch {
|
|||
|
|
// 无物理文件:用元数据 dist.tarball 大小兜底(无则 0)
|
|||
|
|
size = (info && info.dist && typeof info.dist.unpackedSize === 'number') ? info.dist.unpackedSize : 0
|
|||
|
|
}
|
|||
|
|
totalBytes += size
|
|||
|
|
versionsDetail.push({
|
|||
|
|
version: parseVersion(v),
|
|||
|
|
size: fmtMb(size),
|
|||
|
|
date: timeObj[v] ? timeObj[v].slice(0, 10) : null,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
// 按 semver 数值排序(旧 → 新)
|
|||
|
|
versionsDetail.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }))
|
|||
|
|
|
|||
|
|
// 最近更新时间:取 time 字段最大
|
|||
|
|
const lastUpdated = Object.keys(timeObj).length
|
|||
|
|
? Object.entries(timeObj).sort((a, b) => b[1].localeCompare(a[1]))[0][1].slice(0, 10)
|
|||
|
|
: null
|
|||
|
|
|
|||
|
|
packages.push({
|
|||
|
|
name: pkgNameFromDir(pkgDir, storageRoot),
|
|||
|
|
versions: versionsDetail.length,
|
|||
|
|
size: fmtMb(totalBytes),
|
|||
|
|
lastUpdated,
|
|||
|
|
visits: 0, // 真实环境无访问统计,界面不依赖真实值
|
|||
|
|
distTags,
|
|||
|
|
versionsDetail,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return packages
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// —— demo 模式数据(无真实目录时的演示) ——
|
|||
|
|
const DEMO_SEED = [
|
|||
|
|
['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832],
|
|||
|
|
['react', 14, 10, 6543], ['vue', 12, 9, 5218], ['express', 16, 0.8, 4376],
|
|||
|
|
['tensorflow', 24, 90, 2103], ['typescript', 20, 22, 3421], ['webpack', 19, 18, 2876],
|
|||
|
|
['echarts', 15, 16, 1987], ['dayjs', 8, 0.6, 1789], ['uuid', 11, 0.4, 1654],
|
|||
|
|
['vite', 9, 14, 1432], ['prisma', 7, 28, 1120], ['pg', 6, 5, 986],
|
|||
|
|
['three', 10, 30, 876], ['sharp', 5, 24, 654], ['chalk', 6, 0.3, 743],
|
|||
|
|
['semver', 4, 0.3, 532], ['zod', 5, 0.5, 421],
|
|||
|
|
['prettier', 3, 1, 388], ['rollup', 8, 6, 356], ['eslint', 13, 8, 342],
|
|||
|
|
['moment', 20, 2, 312], ['mocha', 7, 1.4, 301], ['cheerio', 9, 3, 287],
|
|||
|
|
['@vue/compiler-sfc', 11, 5, 265], ['ioredis', 10, 4, 254], ['graphql', 12, 7, 243],
|
|||
|
|
['socket.io', 6, 2, 231], ['xlsx', 14, 20, 218], ['d3', 9, 12, 205],
|
|||
|
|
['puppeteer', 4, 15, 187], ['sharp', 5, 24, 176],
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
function pad(n) { return String(n).padStart(2, '0') }
|
|||
|
|
function dateStr(offset) {
|
|||
|
|
const d = new Date(Date.now() - offset * 86400000)
|
|||
|
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
|
|||
|
|
}
|
|||
|
|
function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
|
|||
|
|
|
|||
|
|
function genVersions(count) {
|
|||
|
|
const list = []
|
|||
|
|
for (let i = 0; i < count; i++) {
|
|||
|
|
list.push({
|
|||
|
|
version: `1.${rnd(0, 9)}.${rnd(0, 20)}`,
|
|||
|
|
size: Number((rnd(5, 400) / 10).toFixed(1)),
|
|||
|
|
date: dateStr((count - i) * rnd(3, 9)),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
list.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }))
|
|||
|
|
return list
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function demoPackages() {
|
|||
|
|
const seen = new Set()
|
|||
|
|
return DEMO_SEED.filter(([n]) => !seen.has(n) && seen.add(n)).map(([name, verCount, sizeBase, visits]) => {
|
|||
|
|
const detail = genVersions(verCount)
|
|||
|
|
return {
|
|||
|
|
name,
|
|||
|
|
versions: detail.length,
|
|||
|
|
size: Number((detail.reduce((s, v) => s + v.size, 0)).toFixed(1)),
|
|||
|
|
lastUpdated: detail.length ? detail[detail.length - 1].date : null,
|
|||
|
|
visits,
|
|||
|
|
distTags: { latest: detail.length ? detail[detail.length - 1].version : '1.0.0' },
|
|||
|
|
versionsDetail: detail,
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function demoSummary() {
|
|||
|
|
return {
|
|||
|
|
demo: true,
|
|||
|
|
todayDownloads: 1234,
|
|||
|
|
todayTrend: '+18%',
|
|||
|
|
note: '演示模式:未配置 Verdaccio 缓存目录,展示内置演示数据。',
|
|||
|
|
}
|
|||
|
|
}
|