/** * 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 缓存目录,展示内置演示数据。', } }