#!/usr/bin/env node
* 固件仓库校验器(零依赖)。
*
* node scripts/validate.mjs
*
* 规则见 README.md 第五节。站点同步时会用同一套规则再校验一次。
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CHIPS = ['ws63', 'bs21e']
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const FILE_RE = /^[a-z0-9][a-z0-9.-]*-[0-9]{8}\.fwpkg$/
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const VERSION_RE = /^\d+\.\d+(?:\.\d+)?$/
const errors = []
const warnings = []
let projects = 0
let files = 0
function fail(where, message) {
errors.push(`${where}: ${message}`)
}
function warn(where, message) {
warnings.push(`${where}: ${message}`)
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function checkLocalized(where, value, field, maxZh = 400, maxEn = 600) {
if (!isPlainObject(value)) return fail(where, `${field} 必须是对象 { "zh-CN", "en" }`)
for (const [locale, max] of [
['zh-CN', maxZh],
['en', maxEn],
]) {
const text = value[locale]
if (typeof text !== 'string' || text.trim() === '') {
fail(where, `${field}.${locale} 不能为空`)
} else if (text.length > max) {
fail(where, `${field}.${locale} 超过 ${max} 字符`)
}
}
for (const key of Object.keys(value)) {
if (key !== 'zh-CN' && key !== 'en') fail(where, `${field} 有未知语言 ${key}`)
}
}
function compact(date) {
return date.replaceAll('-', '')
}
for (const chip of CHIPS) {
const chipDir = join(ROOT, chip)
if (!existsSync(chipDir) || !statSync(chipDir).isDirectory()) {
fail(chip, '缺少芯片目录')
continue
}
for (const entry of readdirSync(chipDir)) {
const projectDir = join(chipDir, entry)
if (!statSync(projectDir).isDirectory()) {
warn(chip, `忽略文件 ${entry}`)
continue
}
projects++
const where = `${chip}/${entry}`
const errorCountBefore = errors.length
const manifestPath = join(projectDir, 'firmware.json')
if (!SLUG_RE.test(entry)) {
fail(where, '目录名必须是小写英文 + 连字符(slug 规则)')
}
if (!existsSync(manifestPath)) {
fail(where, '缺少 firmware.json')
continue
}
let manifest
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
} catch (error) {
fail(where, `firmware.json 解析失败:${error.message}`)
continue
}
if (!isPlainObject(manifest)) {
fail(where, 'firmware.json 必须是对象')
continue
}
if (manifest.schema !== 1) fail(where, `schema 必须是 1(当前 ${manifest.schema})`)
if (manifest.slug !== entry) fail(where, `slug "${manifest.slug}" 与目录名不一致`)
if (manifest.chip !== chip) fail(where, `chip "${manifest.chip}" 与所在目录不一致`)
if (typeof manifest.category !== 'string' || manifest.category.trim() === '') {
fail(where, 'category 不能为空')
} else if (manifest.category.length > 40) {
fail(where, 'category 超过 40 字符')
}
checkLocalized(where, manifest.name, 'name')
checkLocalized(where, manifest.summary, 'summary')
if (manifest.notes !== undefined) checkLocalized(where, manifest.notes, 'notes')
if (manifest.author !== undefined && typeof manifest.author !== 'string') {
fail(where, 'author 必须是字符串')
}
if (manifest.license !== undefined && typeof manifest.license !== 'string') {
fail(where, 'license 必须是字符串')
}
if (typeof manifest.updated !== 'string' || !DATE_RE.test(manifest.updated)) {
fail(where, 'updated 必须是 YYYY-MM-DD')
}
if (!Array.isArray(manifest.firmware) || manifest.firmware.length === 0) {
fail(where, 'firmware 必须是非空数组')
continue
}
const listed = new Set()
const variants = new Map()
const dates = []
for (const [index, item] of manifest.firmware.entries()) {
const at = `${where}.firmware[${index}]`
if (!isPlainObject(item)) {
fail(at, '必须是对象')
continue
}
const file = item.file
if (typeof file !== 'string' || !FILE_RE.test(file)) {
fail(at, `文件名不合法:${file}`)
continue
}
if (!file.startsWith(`${entry}-`)) {
fail(at, `文件名必须以项目 slug "${entry}-" 开头`)
}
if (listed.has(file)) fail(at, `重复登记 ${file}`)
listed.add(file)
const full = join(projectDir, file)
if (!existsSync(full)) {
fail(at, `文件不存在:${file}`)
continue
}
files++
if (typeof item.date !== 'string' || !DATE_RE.test(item.date)) {
fail(at, `date 必须是 YYYY-MM-DD(当前 ${item.date})`)
} else {
dates.push(item.date)
if (!file.endsWith(`-${compact(item.date)}.fwpkg`)) {
fail(at, `文件名日期与 date "${item.date}" 不一致`)
}
}
if (item.variant !== undefined) {
if (typeof item.variant !== 'string' || !SLUG_RE.test(item.variant)) {
fail(at, `variant 不合法:${item.variant}`)
} else {
if (variants.has(item.variant)) {
fail(at, `variant "${item.variant}" 重复(同一项目只能有一个当前版本)`)
}
variants.set(item.variant, file)
if (!file.startsWith(`${entry}-${item.variant}-`)) {
fail(at, `文件名里应包含 variant:"${entry}-${item.variant}-…"`)
}
}
}
if (item.version !== undefined) {
if (typeof item.version !== 'string' || !VERSION_RE.test(item.version)) {
fail(at, `version 必须是 X.Y.Z:${item.version}`)
} else if (!file.includes(`-v${item.version}-`)) {
fail(at, `文件名里应包含 -v${item.version}-`)
}
}
if (item.channel !== undefined && !['stable', 'beta'].includes(item.channel)) {
fail(at, `channel 只能是 stable / beta(当前 ${item.channel})`)
}
if (item.notes !== undefined) checkLocalized(at, item.notes, 'notes')
}
for (const name of readdirSync(projectDir)) {
const full = join(projectDir, name)
if (!statSync(full).isDirectory() && name.endsWith('.fwpkg') && !listed.has(name)) {
fail(where, `有未登记的固件:${name}`)
}
}
if (dates.length > 0 && typeof manifest.updated === 'string') {
const latest = dates.slice().sort().at(-1)
if (manifest.updated !== latest) {
fail(where, `updated "${manifest.updated}" 必须等于最晚的文件日期 "${latest}"`)
}
}
if (errors.length === errorCountBefore) {
console.log(`✓ ${where} ${manifest.firmware.length} 个文件`)
}
}
}
for (const message of warnings) console.log(`⚠ ${message}`)
if (errors.length > 0) {
console.error(`\n✗ 校验失败:${errors.length} 个问题`)
for (const message of errors) console.error(` ${message}`)
process.exitCode = 1
} else {
console.log(`\n✓ 校验通过:${projects} 个项目 · ${files} 个固件`)
}