feat: prepare GoodBuddy 0.8.0

This commit is contained in:
lofyer
2026-08-05 19:40:40 +08:00
parent 38ac2206f2
commit 39a457ded8
96 changed files with 14221 additions and 1027 deletions
+325
View File
@@ -0,0 +1,325 @@
const {
copyFileSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
writeFileSync
} = require('node:fs')
const { basename, dirname, isAbsolute, join, relative, resolve } = require('node:path')
const { sha256File } = require('./file-hash.cjs')
const root = join(__dirname, '..')
const packageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8')
)
const productName = packageJson.build?.productName ?? packageJson.name
const manifestName = 'release-manifest.json'
const targetDefinitions = [
{ platform: 'windows', arch: 'x64', formats: ['nsis', 'portable'] },
{ platform: 'windows', arch: 'arm64', formats: ['nsis', 'portable'] },
{ platform: 'macos', arch: 'x64', formats: ['dmg', 'zip'] },
{ platform: 'macos', arch: 'arm64', formats: ['dmg', 'zip'] },
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb'] },
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb'] }
]
const allowedExtensions = {
nsis: '.exe',
portable: '.exe',
dmg: '.dmg',
zip: '.zip',
AppImage: '.AppImage',
deb: '.deb'
}
function parseArguments(argv) {
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--input' || argument === '--output') {
const value = argv[index + 1]
if (!value) {
throw new Error(`${argument} 缺少值`)
}
options[argument.slice(2)] = resolve(value)
index += 1
} else {
throw new Error(`未知参数:${argument}`)
}
}
if (!options.input || !options.output) {
throw new Error('必须指定 --input 和 --output')
}
return options
}
function assertPlainFile(filePath, description) {
const status = lstatSync(filePath, { throwIfNoEntry: false })
if (!status?.isFile() || status.isSymbolicLink()) {
throw new Error(`${description}必须是普通文件:${filePath}`)
}
}
function assertSafeName(name, description) {
if (
typeof name !== 'string' ||
name.length === 0 ||
isAbsolute(name) ||
basename(name) !== name ||
name === '.' ||
name === '..' ||
name.includes('/') ||
name.includes('\\') ||
name.includes('\0')
) {
throw new Error(`${description}包含不安全路径:${String(name)}`)
}
}
function readManifest(directory) {
const filePath = join(directory, manifestName)
assertPlainFile(filePath, '平台 manifest')
let manifest
try {
manifest = JSON.parse(readFileSync(filePath, 'utf8'))
} catch (error) {
throw new Error(`无法解析平台 manifest${filePath}`, {
cause: error
})
}
return { filePath, manifest }
}
function assertManifest(manifest, expected) {
if (
!manifest ||
manifest.formatVersion !== 1 ||
manifest.productName !== productName ||
manifest.version !== packageJson.version ||
manifest.platform !== expected.platform ||
manifest.arch !== expected.arch ||
!Array.isArray(manifest.formats) ||
manifest.formats.length !== expected.formats.length ||
!expected.formats.every(
(format, index) => manifest.formats[index] === format
) ||
!Array.isArray(manifest.files)
) {
throw new Error(
`平台 manifest 元数据错误:${expected.platform}-${expected.arch}`
)
}
}
function expectedFormatForFile(name, target) {
if (target.platform === 'windows') {
if (/-setup\.exe$/u.test(name)) {
return 'nsis'
}
if (/-portable\.exe$/u.test(name)) {
return 'portable'
}
return undefined
}
return target.formats.find((format) =>
name.endsWith(allowedExtensions[format])
)
}
function listTargetDirectories(inputDirectory) {
if (lstatSync(inputDirectory).isSymbolicLink()) {
throw new Error(`拒绝符号链接:${inputDirectory}`)
}
const inputRoot = realpathSync(inputDirectory)
return readdirSync(inputRoot, { withFileTypes: true })
.map((entry) => {
if (entry.isSymbolicLink()) {
throw new Error(`拒绝符号链接:${join(inputRoot, entry.name)}`)
}
if (!entry.isDirectory()) {
throw new Error(`下载目录只能包含目标目录:${entry.name}`)
}
const directory = realpathSync(join(inputRoot, entry.name))
const pathFromRoot = relative(inputRoot, directory)
if (
pathFromRoot.startsWith('..') ||
isAbsolute(pathFromRoot)
) {
throw new Error(`目标目录越出输入目录:${directory}`)
}
return directory
})
}
async function aggregateRelease(inputDirectory, outputDirectory) {
const resolvedInput = resolve(inputDirectory)
const resolvedOutput = resolve(outputDirectory)
const outputFromInput = relative(resolvedInput, resolvedOutput)
const inputFromOutput = relative(resolvedOutput, resolvedInput)
if (
outputFromInput === '' ||
(!outputFromInput.startsWith('..') &&
!isAbsolute(outputFromInput)) ||
(!inputFromOutput.startsWith('..') &&
!isAbsolute(inputFromOutput))
) {
throw new Error('输入目录和上传目录必须相互独立')
}
const directories = listTargetDirectories(inputDirectory)
if (directories.length !== targetDefinitions.length) {
throw new Error(
`发布目标数量错误:期望 ${targetDefinitions.length},实际 ${directories.length}`
)
}
const manifests = directories.map(readManifest)
const byTarget = new Map()
for (const item of manifests) {
const key = `${item.manifest.platform}-${item.manifest.arch}`
if (byTarget.has(key)) {
throw new Error(`发布目标重复:${key}`)
}
byTarget.set(key, item)
}
const fileNames = new Set()
const targets = []
mkdirSync(outputDirectory, { recursive: false })
for (const expected of targetDefinitions) {
const key = `${expected.platform}-${expected.arch}`
const item = byTarget.get(key)
if (!item) {
throw new Error(`缺少发布目标:${key}`)
}
assertManifest(item.manifest, expected)
const directory = dirname(item.filePath)
const entries = readdirSync(directory, { withFileTypes: true })
for (const entry of entries) {
if (entry.isSymbolicLink()) {
throw new Error(`拒绝符号链接:${join(directory, entry.name)}`)
}
if (!entry.isFile()) {
throw new Error(`目标目录只能包含普通文件:${entry.name}`)
}
}
if (entries.length !== item.manifest.files.length + 1) {
throw new Error(`目标目录包含 manifest 未声明的文件:${key}`)
}
const seenFormats = new Set()
const files = []
for (const file of item.manifest.files) {
assertSafeName(file?.name, '发布文件名')
if (
!Number.isSafeInteger(file.size) ||
file.size < 1 ||
typeof file.sha256 !== 'string' ||
!/^[a-f0-9]{64}$/u.test(file.sha256)
) {
throw new Error(`发布文件元数据错误:${file?.name ?? key}`)
}
if (fileNames.has(file.name)) {
throw new Error(`发布文件名全局重复:${file.name}`)
}
const format = expectedFormatForFile(file.name, expected)
if (!format || seenFormats.has(format)) {
throw new Error(`发布文件格式或数量错误:${file.name}`)
}
const source = join(directory, file.name)
assertPlainFile(source, '发布文件')
const actualSize = lstatSync(source).size
const actualHash = await sha256File(source)
if (actualSize !== file.size || actualHash !== file.sha256) {
throw new Error(`发布文件完整性校验失败:${file.name}`)
}
assertPlainFile(source, '发布文件')
copyFileSync(source, join(outputDirectory, file.name))
fileNames.add(file.name)
seenFormats.add(format)
files.push({
name: file.name,
size: file.size,
sha256: file.sha256
})
}
if (
expected.formats.some((format) => !seenFormats.has(format))
) {
throw new Error(`发布目标格式不完整:${key}`)
}
const renamedManifest = `release-manifest-${key}.json`
writeFileSync(
join(outputDirectory, renamedManifest),
`${JSON.stringify(item.manifest, null, 2)}\n`,
'utf8'
)
targets.push({
platform: expected.platform,
arch: expected.arch,
formats: [...expected.formats],
manifest: renamedManifest,
files
})
}
if (byTarget.size !== targetDefinitions.length) {
throw new Error('包含未知发布目标')
}
const aggregateManifest = {
formatVersion: 1,
productName,
version: packageJson.version,
targets,
files: targets.flatMap((target) =>
target.files.map((file) => ({
platform: target.platform,
arch: target.arch,
...file
}))
)
}
writeFileSync(
join(outputDirectory, manifestName),
`${JSON.stringify(aggregateManifest, null, 2)}\n`,
'utf8'
)
const checksumNames = readdirSync(outputDirectory)
.sort((left, right) => left.localeCompare(right))
const checksums = []
for (const name of checksumNames) {
assertSafeName(name, '上传文件名')
const filePath = join(outputDirectory, name)
assertPlainFile(filePath, '上传文件')
checksums.push(`${await sha256File(filePath)} ${name}`)
}
writeFileSync(
join(outputDirectory, 'SHA256SUMS'),
`${checksums.join('\n')}\n`,
'utf8'
)
return aggregateManifest
}
async function main(argv = process.argv.slice(2)) {
const options = parseArguments(argv)
await aggregateRelease(options.input, options.output)
console.log(`发布资产聚合完成:${options.output}`)
}
module.exports = {
aggregateRelease,
assertSafeName,
parseArguments,
targetDefinitions
}
if (require.main === module) {
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
}