feat:移除了弹窗,服务器添加sls
This commit is contained in:
75
scripts/axolotl/brand-guard.mjs
Normal file
75
scripts/axolotl/brand-guard.mjs
Normal file
@ -0,0 +1,75 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const roots = ['apps/app', 'apps/app-frontend', 'packages/app-lib']
|
||||
const ignoredNames = new Set(['LICENSE', 'COPYING.md'])
|
||||
const ignoredDirectories = new Set(['dist', 'build', 'node_modules', 'target', '.gradle', '.sqlx', 'bin'])
|
||||
const forbiddenPatterns = [
|
||||
['official product name', /Modrinth App/g],
|
||||
['official API identity', /modrinth\/theseus/gi],
|
||||
['official support identity', /support@modrinth\.com/gi],
|
||||
['official deep link', /modrinth:\/\//gi],
|
||||
[
|
||||
'official telemetry',
|
||||
/phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ|posthog\.modrinth\.com|ingest\.us\.sentry\.io/gi,
|
||||
],
|
||||
['advertising bridge', /plugin:ads|api::ads|Aditude/gi],
|
||||
['official update feed', /launcher-files\.modrinth\.com\/updates\.json/gi],
|
||||
['official signing service', /DIGICERT_ONE_SIGNER_CREDENTIALS/gi],
|
||||
]
|
||||
|
||||
async function* files(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (ignoredDirectories.has(entry.name)) continue
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) yield* files(entryPath)
|
||||
else if (!ignoredNames.has(entry.name)) yield entryPath
|
||||
}
|
||||
}
|
||||
|
||||
const failures = []
|
||||
for (const root of roots) {
|
||||
for await (const file of files(root)) {
|
||||
let contents
|
||||
try {
|
||||
contents = await readFile(file, 'utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [label, pattern] of forbiddenPatterns) {
|
||||
pattern.lastIndex = 0
|
||||
if (pattern.test(contents)) failures.push(`${file}: ${label}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tauriConfig = JSON.parse(await readFile('apps/app/tauri.conf.json', 'utf8'))
|
||||
const frontendConfig = await readFile('apps/app-frontend/src/config.ts', 'utf8')
|
||||
const requiredInvariants = [
|
||||
['product name', tauriConfig.productName === 'Axolotl Launcher'],
|
||||
['bundle identifier', tauriConfig.identifier === 'red.ghs.axolotl'],
|
||||
[
|
||||
'deep-link scheme',
|
||||
tauriConfig.plugins?.['deep-link']?.desktop?.schemes?.includes('axolotl') === true,
|
||||
],
|
||||
[
|
||||
'User-Agent format',
|
||||
frontendConfig.includes('garbage-human-studio/axolotl/${version} (${os})'),
|
||||
],
|
||||
[
|
||||
'private Modrinth services disabled',
|
||||
frontendConfig.includes('privateModrinthServices: false'),
|
||||
],
|
||||
['GHS telemetry disabled', frontendConfig.includes('ghsTelemetry: false')],
|
||||
]
|
||||
for (const [label, valid] of requiredInvariants) {
|
||||
if (!valid) failures.push(`configuration: missing ${label}`)
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Axolotl brand guard failed:\n${failures.join('\n')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Axolotl brand guard passed.')
|
||||
276
scripts/axolotl/check-migrations.mjs
Normal file
276
scripts/axolotl/check-migrations.mjs
Normal file
@ -0,0 +1,276 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const migrationsDirectory = 'packages/app-lib/migrations'
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? 'Mystic-Stars/Axolotl'
|
||||
// v1.7.5 is the canonical snapshot immediately before the v1.7.6 incident.
|
||||
const canonicalBootstrap = {
|
||||
ref: '7ddbfb8e57db4b0044a04cf28f25fb29e08c3279',
|
||||
tag: 'v1.7.5',
|
||||
}
|
||||
// Published divergences remain evidence of an incident and never replace the canonical bytes.
|
||||
const knownPublishedDivergences = new Set([
|
||||
[
|
||||
'v1.7.6',
|
||||
'packages/app-lib/migrations/20260714120000_translation.sql',
|
||||
'c6fdf52790db7e67905003216ee7c099ec9ac29df1ee1b62602eb791881f321470f7e2b965c39fc8733b10ad114eace5',
|
||||
].join('\0'),
|
||||
])
|
||||
|
||||
function git(args, encoding = 'utf8') {
|
||||
return execFileSync('git', args, {
|
||||
encoding,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
|
||||
function checksum(contents) {
|
||||
return createHash('sha384').update(contents).digest('hex')
|
||||
}
|
||||
|
||||
function migrationChecksum(migration) {
|
||||
migration.checksum ??= checksum(git(['cat-file', 'blob', migration.blob], null))
|
||||
return migration.checksum
|
||||
}
|
||||
|
||||
function migrationVersion(file) {
|
||||
const name = file.slice(file.lastIndexOf('/') + 1)
|
||||
const match = /^(\d+)_([a-z0-9][a-z0-9_-]*)\.sql$/.exec(name)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function migrationMapAt(ref) {
|
||||
const migrations = new Map()
|
||||
const output = git(['ls-tree', '-r', '-z', ref, '--', migrationsDirectory])
|
||||
for (const record of output.split('\0')) {
|
||||
if (!record) continue
|
||||
const tabIndex = record.indexOf('\t')
|
||||
const metadata = record.slice(0, tabIndex).split(' ')
|
||||
const file = record.slice(tabIndex + 1)
|
||||
if (!file.endsWith('.sql')) continue
|
||||
migrations.set(file, {
|
||||
blob: metadata[2],
|
||||
checksum: null,
|
||||
version: migrationVersion(file),
|
||||
})
|
||||
}
|
||||
return new Map([...migrations].sort(([left], [right]) => left.localeCompare(right)))
|
||||
}
|
||||
|
||||
function validateMigrationSet(migrations, failures) {
|
||||
const versions = new Map()
|
||||
|
||||
for (const [file, migration] of migrations) {
|
||||
if (migration.version === null) {
|
||||
failures.push(`INVALID NAME ${file}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = versions.get(migration.version)
|
||||
if (existing) {
|
||||
failures.push(`DUPLICATE VERSION ${migration.version}: ${existing}, ${file}`)
|
||||
} else {
|
||||
versions.set(migration.version, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compareCurrentWithCanonical(canonical, currentRef, failures) {
|
||||
const current = migrationMapAt(currentRef)
|
||||
validateMigrationSet(current, failures)
|
||||
|
||||
for (const [file, expected] of canonical) {
|
||||
const actual = current.get(file)
|
||||
if (!actual) {
|
||||
failures.push(`DELETED ${file}`)
|
||||
continue
|
||||
}
|
||||
if (actual.blob !== expected.blob) {
|
||||
failures.push(
|
||||
`MODIFIED ${file}\n Expected SHA-384: ${migrationChecksum(expected)}\n Actual SHA-384: ${migrationChecksum(actual)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const maximumCanonicalVersion = Math.max(
|
||||
...Array.from(canonical.values(), (migration) => migration.version ?? 0),
|
||||
)
|
||||
for (const [file, migration] of current) {
|
||||
if (
|
||||
!canonical.has(file) &&
|
||||
migration.version !== null &&
|
||||
migration.version <= maximumCanonicalVersion
|
||||
) {
|
||||
failures.push(
|
||||
`OUT-OF-ORDER ${file}: new migration version must be greater than ${maximumCanonicalVersion}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseVersion(tag) {
|
||||
const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(tag)
|
||||
if (!match) return null
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
for (const key of ['major', 'minor', 'patch']) {
|
||||
if (left[key] !== right[key]) return left[key] - right[key]
|
||||
}
|
||||
if (left.prerelease === right.prerelease) return 0
|
||||
if (left.prerelease === null) return 1
|
||||
if (right.prerelease === null) return -1
|
||||
return left.prerelease.localeCompare(right.prerelease, 'en', { numeric: true })
|
||||
}
|
||||
|
||||
function publishedReleases() {
|
||||
const args = [
|
||||
'release',
|
||||
'list',
|
||||
'--limit',
|
||||
'1000',
|
||||
'--json',
|
||||
'tagName,isDraft,publishedAt',
|
||||
'--repo',
|
||||
repository,
|
||||
]
|
||||
const releases = JSON.parse(execFileSync('gh', args, { encoding: 'utf8' }))
|
||||
const bootstrapVersion = parseVersion(canonicalBootstrap.tag)
|
||||
|
||||
return releases
|
||||
.filter((release) => !release.isDraft)
|
||||
.map((release) => ({ ...release, version: parseVersion(release.tagName) }))
|
||||
.filter((release) => release.version && compareVersions(release.version, bootstrapVersion) >= 0)
|
||||
.sort((left, right) => Date.parse(left.publishedAt) - Date.parse(right.publishedAt))
|
||||
}
|
||||
|
||||
function auditPublishedReleases(currentRef) {
|
||||
const failures = []
|
||||
const warnings = []
|
||||
const canonical = migrationMapAt(canonicalBootstrap.ref)
|
||||
validateMigrationSet(canonical, failures)
|
||||
|
||||
for (const release of publishedReleases()) {
|
||||
if (release.tagName === canonicalBootstrap.tag) continue
|
||||
|
||||
const released = migrationMapAt(release.tagName)
|
||||
for (const file of canonical.keys()) {
|
||||
if (!released.has(file)) {
|
||||
failures.push(`PUBLISHED DELETE ${release.tagName}: ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, migration] of released) {
|
||||
const expected = canonical.get(file)
|
||||
if (!expected) {
|
||||
canonical.set(file, migration)
|
||||
continue
|
||||
}
|
||||
if (migration.blob === expected.blob) continue
|
||||
|
||||
const releasedChecksum = migrationChecksum(migration)
|
||||
const divergence = [release.tagName, file, releasedChecksum].join('\0')
|
||||
if (knownPublishedDivergences.has(divergence)) {
|
||||
warnings.push(
|
||||
`${release.tagName} contains the known historical migration divergence in ${file}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
failures.push(
|
||||
`UNRECOGNIZED PUBLISHED DIVERGENCE ${release.tagName}: ${file}\n` +
|
||||
` Canonical SHA-384: ${migrationChecksum(expected)}\n` +
|
||||
` Released SHA-384: ${releasedChecksum}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compareCurrentWithCanonical(canonical, currentRef, failures)
|
||||
finish(failures, warnings, `published release history from ${canonicalBootstrap.tag}`)
|
||||
}
|
||||
|
||||
function resolveBaseRef(baseRef) {
|
||||
const isAvailable = () => {
|
||||
try {
|
||||
git(['rev-parse', '--verify', `${baseRef}^{commit}`])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (isAvailable()) return baseRef
|
||||
|
||||
for (const remote of ['origin', 'AXL']) {
|
||||
try {
|
||||
git(['fetch', remote, baseRef])
|
||||
if (isAvailable()) return baseRef
|
||||
} catch {
|
||||
// Try the next remote.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = git([
|
||||
'rev-parse',
|
||||
'--abbrev-ref',
|
||||
'--symbolic-full-name',
|
||||
'@{upstream}',
|
||||
]).trim()
|
||||
const upstreamCommit = git(['rev-parse', `${upstream}^{commit}`]).trim()
|
||||
const headCommit = git(['rev-parse', 'HEAD^{commit}']).trim()
|
||||
if (upstream && upstreamCommit !== headCommit) return upstream
|
||||
} catch {
|
||||
// Fall through to HEAD^.
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Migration guard: base ref ${baseRef} is not available locally; falling back to HEAD^`,
|
||||
)
|
||||
return 'HEAD^'
|
||||
}
|
||||
|
||||
function compareWithBase(baseRef, currentRef) {
|
||||
const failures = []
|
||||
const canonical = migrationMapAt(baseRef)
|
||||
validateMigrationSet(canonical, failures)
|
||||
compareCurrentWithCanonical(canonical, currentRef, failures)
|
||||
finish(failures, [], baseRef)
|
||||
}
|
||||
|
||||
function finish(failures, warnings, baseline) {
|
||||
for (const warning of warnings) console.warn(`Migration guard notice: ${warning}`)
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`Migration guard failed against ${baseline}:\n\n${failures.join('\n\n')}\n\n` +
|
||||
'Historical migrations are immutable. Add a new forward migration instead.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Migration guard passed against ${baseline}.`)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const currentIndex = args.indexOf('--current')
|
||||
const currentRef = currentIndex === -1 ? 'HEAD' : args[currentIndex + 1]
|
||||
const baseIndex = args.indexOf('--base')
|
||||
|
||||
if (args.includes('--release')) {
|
||||
auditPublishedReleases(currentRef)
|
||||
} else if (baseIndex !== -1 && args[baseIndex + 1]) {
|
||||
compareWithBase(resolveBaseRef(args[baseIndex + 1]), currentRef)
|
||||
} else {
|
||||
console.error(
|
||||
'Usage: node check-migrations.mjs (--release | --base <git-ref>) [--current <git-ref>]',
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
327
scripts/axolotl/cnb-release.mjs
Normal file
327
scripts/axolotl/cnb-release.mjs
Normal file
@ -0,0 +1,327 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const [command, tag, outputDirectory] = process.argv.slice(2)
|
||||
const apiEndpoint = (process.env.CNB_API_ENDPOINT || 'https://api.cnb.cool').replace(/\/$/, '')
|
||||
const repo = process.env.CNB_REPO_SLUG || 'axlmc/Axolotl'
|
||||
const token = process.env.CNB_TOKEN
|
||||
const tokenUser = process.env.CNB_TOKEN_USER_NAME || 'cnb'
|
||||
const repoUrl = process.env.CNB_REPO_URL_HTTPS || `https://cnb.cool/${repo}.git`
|
||||
const githubReleaseBaseUrl = (
|
||||
process.env.GITHUB_RELEASE_BASE_URL || 'https://github.com/Mystic-Stars/Axolotl/releases/download'
|
||||
).replace(/\/$/, '')
|
||||
const githubApiBaseUrl = (
|
||||
process.env.GITHUB_API_BASE_URL || 'https://api.github.com/repos/Mystic-Stars/Axolotl'
|
||||
).replace(/\/$/, '')
|
||||
const configuredAssetMirrorConcurrency = Number(process.env.ASSET_MIRROR_CONCURRENCY || 4)
|
||||
const assetMirrorConcurrency =
|
||||
Number.isSafeInteger(configuredAssetMirrorConcurrency) && configuredAssetMirrorConcurrency > 0
|
||||
? configuredAssetMirrorConcurrency
|
||||
: 4
|
||||
|
||||
if (command !== 'finalize' || !tag || !outputDirectory || !token) {
|
||||
throw new Error('Usage: node cnb-release.mjs finalize <tag> <output-dir>; CNB_TOKEN is required')
|
||||
}
|
||||
|
||||
const apiHeaders = {
|
||||
Accept: 'application/vnd.cnb.api+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
const githubApiHeaders = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'Axolotl-CNB-Release',
|
||||
...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}),
|
||||
}
|
||||
|
||||
async function apiRequest(url, options = {}, allowedStatuses = []) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...apiHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (!response.ok && !allowedStatuses.includes(response.status)) {
|
||||
throw new Error(
|
||||
`${options.method || 'GET'} ${url} failed (${response.status}): ${await response.text()}`,
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function getRelease() {
|
||||
const response = await apiRequest(
|
||||
`${apiEndpoint}/${repo}/-/releases/tags/${encodeURIComponent(tag)}`,
|
||||
{},
|
||||
[404],
|
||||
)
|
||||
return response.status === 404 ? null : await response.json()
|
||||
}
|
||||
|
||||
async function ensureRelease(githubRelease) {
|
||||
const existing = await getRelease()
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const prerelease = tag.includes('-')
|
||||
const response = await apiRequest(
|
||||
`${apiEndpoint}/${repo}/-/releases`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
target_commitish: process.env.CNB_COMMIT || tag,
|
||||
name:
|
||||
process.env.CNB_TAG_RELEASE_TITLE ||
|
||||
githubRelease.name ||
|
||||
`Axolotl Launcher ${tag}`,
|
||||
body: process.env.CNB_TAG_RELEASE_DESC || githubRelease.body || '',
|
||||
draft: true,
|
||||
prerelease,
|
||||
make_latest: 'false',
|
||||
}),
|
||||
},
|
||||
[409],
|
||||
)
|
||||
if (response.status !== 409) {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
const release = await getRelease()
|
||||
if (release) {
|
||||
return release
|
||||
}
|
||||
}
|
||||
throw new Error(`Release ${tag} was created concurrently but could not be loaded`)
|
||||
}
|
||||
|
||||
async function createAssetUpload(release, assetName, size) {
|
||||
const uploadResponse = await apiRequest(
|
||||
`${apiEndpoint}/${repo}/-/releases/${release.id}/asset-upload-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_name: assetName, size, overwrite: true, ttl: 0 }),
|
||||
},
|
||||
)
|
||||
return await uploadResponse.json()
|
||||
}
|
||||
|
||||
async function verifyAssetUpload(upload) {
|
||||
const verifyUrl = new URL(upload.verify_url, apiEndpoint).toString()
|
||||
await apiRequest(`${verifyUrl}${verifyUrl.includes('?') ? '&' : '?'}ttl=0`, { method: 'POST' })
|
||||
}
|
||||
|
||||
async function uploadAsset(release, filePath) {
|
||||
const assetName = path.basename(filePath)
|
||||
const size = fs.statSync(filePath).size
|
||||
const upload = await createAssetUpload(release, assetName, size)
|
||||
const fileResponse = await fetch(upload.upload_url, {
|
||||
method: 'PUT',
|
||||
body: fs.createReadStream(filePath),
|
||||
duplex: 'half',
|
||||
headers: {
|
||||
'Content-Length': String(size),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
})
|
||||
if (!fileResponse.ok) {
|
||||
throw new Error(
|
||||
`Uploading ${assetName} failed (${fileResponse.status}): ${await fileResponse.text()}`,
|
||||
)
|
||||
}
|
||||
await verifyAssetUpload(upload)
|
||||
console.log(`Uploaded ${assetName}`)
|
||||
}
|
||||
|
||||
async function loadGithubManifest() {
|
||||
const manifestUrl = `${githubReleaseBaseUrl}/${encodeURIComponent(tag)}/latest.json`
|
||||
const response = await fetch(manifestUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Downloading GitHub manifest failed (${response.status}): ${await response.text()}`,
|
||||
)
|
||||
}
|
||||
const manifest = await response.json()
|
||||
if (manifest.version !== tag.replace(/^v/, '')) {
|
||||
throw new Error(`GitHub manifest version ${manifest.version} does not match ${tag}`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function loadGithubRelease(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: githubApiHeaders,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Loading GitHub release failed (${response.status}): ${await response.text()}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function getGithubRelease() {
|
||||
return await loadGithubRelease(
|
||||
`${githubApiBaseUrl}/releases/tags/${encodeURIComponent(tag)}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getLatestGithubRelease() {
|
||||
return await loadGithubRelease(`${githubApiBaseUrl}/releases/latest`)
|
||||
}
|
||||
|
||||
async function mirrorGithubAsset(release, asset) {
|
||||
const upload = await createAssetUpload(release, asset.name, asset.size)
|
||||
const response = await fetch(asset.browser_download_url)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(
|
||||
`Downloading ${asset.name} failed (${response.status}): ${await response.text()}`,
|
||||
)
|
||||
}
|
||||
const uploadResponse = await fetch(upload.upload_url, {
|
||||
method: 'PUT',
|
||||
body: response.body,
|
||||
duplex: 'half',
|
||||
headers: {
|
||||
'Content-Length': String(asset.size),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
})
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(
|
||||
`Uploading ${asset.name} failed (${uploadResponse.status}): ${await uploadResponse.text()}`,
|
||||
)
|
||||
}
|
||||
await verifyAssetUpload(upload)
|
||||
console.log(`Mirrored ${asset.name}`)
|
||||
}
|
||||
|
||||
async function mirrorGithubAssets(release, githubRelease) {
|
||||
const assets = (githubRelease.assets || []).filter((asset) => asset.name !== 'latest.json')
|
||||
const mirroredNames = new Set(assets.map((asset) => asset.name))
|
||||
if (mirroredNames.size !== assets.length) {
|
||||
throw new Error('GitHub release contains duplicate asset names')
|
||||
}
|
||||
for (const asset of assets) {
|
||||
if (
|
||||
!asset.name ||
|
||||
!asset.browser_download_url ||
|
||||
!Number.isSafeInteger(asset.size) ||
|
||||
asset.size < 0
|
||||
) {
|
||||
throw new Error(`Invalid or duplicate GitHub release asset: ${asset.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
let nextAsset = 0
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(assetMirrorConcurrency, assets.length) },
|
||||
async () => {
|
||||
while (nextAsset < assets.length) {
|
||||
const asset = assets[nextAsset++]
|
||||
await mirrorGithubAsset(release, asset)
|
||||
}
|
||||
},
|
||||
)
|
||||
await Promise.all(workers)
|
||||
return mirroredNames
|
||||
}
|
||||
|
||||
function createCnbManifest(githubManifest, mirroredNames) {
|
||||
const requiredPlatforms = [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
'linux-aarch64',
|
||||
'linux-x86_64',
|
||||
'windows-x86_64',
|
||||
]
|
||||
const platforms = {}
|
||||
for (const platform of requiredPlatforms) {
|
||||
const update = githubManifest.platforms?.[platform]
|
||||
if (!update || typeof update.signature !== 'string' || update.signature.trim().length < 32) {
|
||||
throw new Error(`Missing signed GitHub update for ${platform}`)
|
||||
}
|
||||
const filename = decodeURIComponent(path.posix.basename(new URL(update.url).pathname))
|
||||
if (!mirroredNames.has(filename)) {
|
||||
throw new Error(`GitHub release is missing updater artifact ${filename} for ${platform}`)
|
||||
}
|
||||
platforms[platform] = {
|
||||
...update,
|
||||
url: `https://cnb.cool/${repo}/-/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(filename)}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...githubManifest,
|
||||
version: tag.replace(/^v/, ''),
|
||||
platforms,
|
||||
}
|
||||
}
|
||||
|
||||
function publishUpdateBranch(manifestPath) {
|
||||
if (tag.includes('-')) {
|
||||
return
|
||||
}
|
||||
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'axolotl-cnb-update-'))
|
||||
const auth = Buffer.from(`${tokenUser}:${token}`).toString('base64')
|
||||
const git = (...args) => execFileSync('git', args, { cwd: directory, stdio: 'inherit' })
|
||||
git('init')
|
||||
git('config', 'user.name', 'Axolotl CNB Release')
|
||||
git('config', 'user.email', 'build@cnb.cool')
|
||||
git('checkout', '--orphan', 'update')
|
||||
fs.copyFileSync(manifestPath, path.join(directory, 'latest.json'))
|
||||
git('add', 'latest.json')
|
||||
git('commit', '-m', `Publish ${tag}`)
|
||||
git('remote', 'add', 'origin', repoUrl)
|
||||
git(
|
||||
'-c',
|
||||
`http.extraHeader=Authorization: Basic ${auth}`,
|
||||
'push',
|
||||
'--force',
|
||||
'origin',
|
||||
'HEAD:update',
|
||||
)
|
||||
}
|
||||
|
||||
async function finalizeRelease() {
|
||||
fs.mkdirSync(outputDirectory, { recursive: true })
|
||||
const [githubManifest, githubRelease, latestGithubRelease] = await Promise.all([
|
||||
loadGithubManifest(),
|
||||
getGithubRelease(),
|
||||
getLatestGithubRelease(),
|
||||
])
|
||||
const isLatestRelease = githubRelease.tag_name === latestGithubRelease.tag_name
|
||||
const release = await ensureRelease(githubRelease)
|
||||
const mirroredNames = await mirrorGithubAssets(release, githubRelease)
|
||||
const manifest = createCnbManifest(githubManifest, mirroredNames)
|
||||
const manifestPath = path.join(outputDirectory, 'latest.json')
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
await uploadAsset(release, manifestPath)
|
||||
|
||||
const prerelease = tag.includes('-')
|
||||
await apiRequest(`${apiEndpoint}/${repo}/-/releases/${release.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: githubRelease.name || `Axolotl Launcher ${tag}`,
|
||||
body: githubRelease.body || '',
|
||||
draft: false,
|
||||
prerelease,
|
||||
make_latest: isLatestRelease ? 'true' : 'false',
|
||||
}),
|
||||
})
|
||||
if (isLatestRelease) {
|
||||
publishUpdateBranch(manifestPath)
|
||||
} else {
|
||||
console.log(`Skipped update branch for non-latest GitHub release ${tag}`)
|
||||
}
|
||||
console.log(`Published CNB release ${tag}`)
|
||||
}
|
||||
|
||||
await finalizeRelease()
|
||||
74
scripts/axolotl/create-release-notes.mjs
Normal file
74
scripts/axolotl/create-release-notes.mjs
Normal file
@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(new URL('../../apps/app-frontend/package.json', import.meta.url))
|
||||
const ts = require('typescript')
|
||||
|
||||
const [tag, outputPath] = process.argv.slice(2)
|
||||
const version = tag?.replace(/^v/, '')
|
||||
|
||||
if (!version || !outputPath) {
|
||||
throw new Error(
|
||||
'Usage: node scripts/axolotl/create-release-notes.mjs <version-tag> <output-path>',
|
||||
)
|
||||
}
|
||||
|
||||
const catalogSource = await fs.readFile('apps/app-frontend/src/announcements/catalog.ts', 'utf8')
|
||||
const catalogModule = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(
|
||||
ts.transpileModule(catalogSource, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText,
|
||||
).toString('base64')}`
|
||||
)
|
||||
|
||||
const announcement = catalogModule.getAnnouncementByVersion(version)
|
||||
if (!announcement) {
|
||||
throw new Error(`No bundled announcement found for release ${version}`)
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
added: { en: 'Added', zh: '新增' },
|
||||
changed: { en: 'Changed', zh: '变更' },
|
||||
deprecated: { en: 'Deprecated', zh: '弃用' },
|
||||
removed: { en: 'Removed', zh: '移除' },
|
||||
fixed: { en: 'Fixed', zh: 'Bug 修复' },
|
||||
security: { en: 'Security', zh: '安全修复' },
|
||||
}
|
||||
|
||||
function renderLanguage(language) {
|
||||
const locale = language === 'zh' ? 'zh-CN' : 'en-US'
|
||||
const lines = [`## ${language === 'zh' ? '中文' : 'English'}`, '']
|
||||
|
||||
for (const type of catalogModule.ANNOUNCEMENT_CHANGE_TYPES) {
|
||||
const changes = announcement.changes[type]
|
||||
if (!changes?.length) continue
|
||||
|
||||
lines.push(`### ${categoryLabels[type][language]}`, '')
|
||||
for (const change of changes) {
|
||||
lines.push(`- ${change[locale]}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (announcement.notes) {
|
||||
lines.push(`### ${language === 'zh' ? '说明' : 'Notes'}`, '', announcement.notes[locale], '')
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`# ${announcement.title['zh-CN']}`,
|
||||
'',
|
||||
`发布日期 / Published: ${announcement.publishedAt}`,
|
||||
'',
|
||||
...renderLanguage('zh'),
|
||||
...renderLanguage('en'),
|
||||
]
|
||||
|
||||
await fs.writeFile(outputPath, `${lines.join('\n').replace(/\n+$/, '')}\n`)
|
||||
console.log(`Generated release notes for ${version} from the launcher announcement catalog.`)
|
||||
72
scripts/axolotl/create-update-manifest.mjs
Normal file
72
scripts/axolotl/create-update-manifest.mjs
Normal file
@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const [releasePath, signaturesPath, tag, outputPath] = process.argv.slice(2)
|
||||
|
||||
if (!releasePath || !signaturesPath || !tag || !outputPath) {
|
||||
throw new Error(
|
||||
'Usage: node create-update-manifest.mjs <release.json> <signatures-dir> <version-tag> <output.json>',
|
||||
)
|
||||
}
|
||||
|
||||
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'))
|
||||
const assets = release.assets
|
||||
|
||||
if (!Array.isArray(assets)) {
|
||||
throw new Error('Release metadata does not contain an assets array')
|
||||
}
|
||||
|
||||
const targets = [
|
||||
{
|
||||
platforms: ['darwin-aarch64', 'darwin-x86_64'],
|
||||
assetSuffix: '_universal.app.tar.gz',
|
||||
},
|
||||
{
|
||||
platforms: ['linux-aarch64'],
|
||||
assetSuffix: '_aarch64.AppImage.tar.gz',
|
||||
},
|
||||
{
|
||||
platforms: ['linux-x86_64'],
|
||||
assetSuffix: '_amd64.AppImage.tar.gz',
|
||||
},
|
||||
{
|
||||
platforms: ['windows-x86_64'],
|
||||
assetSuffix: '_x64-setup.nsis.zip',
|
||||
},
|
||||
]
|
||||
|
||||
const platforms = {}
|
||||
|
||||
for (const target of targets) {
|
||||
const matches = assets.filter((asset) => asset.name?.endsWith(target.assetSuffix))
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected one release asset ending in ${target.assetSuffix}, found ${matches.length}`,
|
||||
)
|
||||
}
|
||||
|
||||
const asset = matches[0]
|
||||
const signaturePath = path.join(signaturesPath, `${asset.name}.sig`)
|
||||
if (!fs.existsSync(signaturePath)) {
|
||||
throw new Error(`Missing updater signature ${path.basename(signaturePath)}`)
|
||||
}
|
||||
|
||||
const signature = fs.readFileSync(signaturePath, 'utf8')
|
||||
const url = asset.browser_download_url ?? asset.url
|
||||
if (!url) {
|
||||
throw new Error(`Release asset ${asset.name} does not contain a download URL`)
|
||||
}
|
||||
|
||||
for (const platform of target.platforms) {
|
||||
platforms[platform] = { signature, url }
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: tag.replace(/^v/, ''),
|
||||
notes: release.body ?? '',
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms,
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
113
scripts/axolotl/create-update-server-catalog.mjs
Normal file
113
scripts/axolotl/create-update-server-catalog.mjs
Normal file
@ -0,0 +1,113 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const [releasePath, tag, outputPath] = process.argv.slice(2)
|
||||
|
||||
if (!releasePath || !tag || !outputPath) {
|
||||
throw new Error(
|
||||
'Usage: node create-update-server-catalog.mjs <release.json> <version-tag> <output.json>',
|
||||
)
|
||||
}
|
||||
|
||||
const version = tag.replace(/^v/, '')
|
||||
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'))
|
||||
if (!Array.isArray(release.assets)) throw new Error('Release metadata does not contain an assets array')
|
||||
|
||||
function updaterTargets(filename) {
|
||||
if (filename.endsWith('_universal.app.tar.gz')) return ['darwin-aarch64', 'darwin-x86_64']
|
||||
if (filename.endsWith('_aarch64.AppImage.tar.gz')) return ['linux-aarch64']
|
||||
if (filename.endsWith('_amd64.AppImage.tar.gz')) return ['linux-x86_64']
|
||||
if (filename.endsWith('_x64-setup.nsis.zip')) return ['windows-x86_64']
|
||||
return null
|
||||
}
|
||||
|
||||
function describe(filename) {
|
||||
const targets = updaterTargets(filename)
|
||||
if (targets) {
|
||||
return { kind: 'updater', platform: targets[0], targetPlatforms: targets, variant: 'tauri' }
|
||||
}
|
||||
if (filename.endsWith('.sig')) return { kind: 'signature', platform: null, targetPlatforms: [] }
|
||||
if (filename.endsWith('_x64_modern-setup.exe')) {
|
||||
return { kind: 'installer', platform: 'windows', targetPlatforms: [], variant: 'modern' }
|
||||
}
|
||||
if (filename.endsWith('_x64_nsis-setup.exe')) {
|
||||
return { kind: 'installer', platform: 'windows', targetPlatforms: [], variant: 'native' }
|
||||
}
|
||||
if (filename.endsWith('_x64-setup.exe')) {
|
||||
return { kind: 'installer', platform: 'windows', targetPlatforms: [], variant: 'legacy' }
|
||||
}
|
||||
if (filename.endsWith('_x64_portable.zip')) {
|
||||
return { kind: 'portable', platform: 'windows', targetPlatforms: [], variant: 'portable' }
|
||||
}
|
||||
if (filename.endsWith('.dmg')) {
|
||||
return { kind: 'installer', platform: 'macos', targetPlatforms: [], variant: 'dmg' }
|
||||
}
|
||||
if (filename.endsWith('.AppImage')) {
|
||||
return { kind: 'installer', platform: 'linux', targetPlatforms: [], variant: 'appimage' }
|
||||
}
|
||||
if (filename.endsWith('.deb')) {
|
||||
return { kind: 'installer', platform: 'linux', targetPlatforms: [], variant: 'deb' }
|
||||
}
|
||||
if (filename.endsWith('.rpm')) {
|
||||
return { kind: 'installer', platform: 'linux', targetPlatforms: [], variant: 'rpm' }
|
||||
}
|
||||
throw new Error(`Unrecognized release artifact ${filename}`)
|
||||
}
|
||||
|
||||
function architecture(filename) {
|
||||
if (/universal/i.test(filename)) return 'universal'
|
||||
if (/(aarch64|arm64)/i.test(filename)) return 'aarch64'
|
||||
if (/(amd64|x86_64|x64)/i.test(filename)) return 'x86_64'
|
||||
return null
|
||||
}
|
||||
|
||||
function digest(asset) {
|
||||
if (typeof asset.digest !== 'string' || !asset.digest.startsWith('sha256:')) {
|
||||
throw new Error(`Release asset ${asset.name} has no SHA-256 digest`)
|
||||
}
|
||||
return asset.digest.slice('sha256:'.length)
|
||||
}
|
||||
|
||||
const assets = release.assets
|
||||
.filter((asset) => asset.name !== 'latest.json')
|
||||
.map((asset) => ({
|
||||
filename: asset.name,
|
||||
size: asset.size,
|
||||
sha256: digest(asset),
|
||||
downloadUrl: asset.browser_download_url ?? asset.url,
|
||||
architecture: architecture(asset.name),
|
||||
...describe(asset.name),
|
||||
}))
|
||||
|
||||
const primaryArtifacts = assets.filter((artifact) => artifact.kind !== 'signature')
|
||||
const artifactKeys = new Set()
|
||||
for (const artifact of assets.filter((candidate) => candidate.kind === 'signature')) {
|
||||
const primary = primaryArtifacts.find(
|
||||
(candidate) => candidate.filename === artifact.filename.slice(0, -'.sig'.length),
|
||||
)
|
||||
if (!primary) throw new Error(`Signature has no matching release artifact ${artifact.filename}`)
|
||||
Object.assign(artifact, {
|
||||
platform: primary.platform,
|
||||
architecture: primary.architecture,
|
||||
targetPlatforms: primary.targetPlatforms,
|
||||
variant: primary.variant,
|
||||
})
|
||||
}
|
||||
|
||||
for (const artifact of primaryArtifacts) {
|
||||
const signature = assets.find(
|
||||
(candidate) => candidate.kind === 'signature' && candidate.filename === `${artifact.filename}.sig`,
|
||||
)
|
||||
artifact.signatureFilename = signature?.filename ?? null
|
||||
if (artifact.kind === 'updater' && !signature) {
|
||||
throw new Error(`Missing Tauri updater signature for ${artifact.filename}`)
|
||||
}
|
||||
const key = [artifact.kind, artifact.platform, artifact.architecture, artifact.variant].join(':')
|
||||
if (artifactKeys.has(key)) throw new Error(`Duplicate release artifact classification ${key}`)
|
||||
artifactKeys.add(key)
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
outputPath,
|
||||
`${JSON.stringify({ version, artifacts: primaryArtifacts, files: assets }, null, 2)}\n`,
|
||||
)
|
||||
83
scripts/axolotl/create-update-server-catalog.test.mjs
Normal file
83
scripts/axolotl/create-update-server-catalog.test.mjs
Normal file
@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'axolotl-update-catalog-'))
|
||||
const releasePath = path.join(directory, 'release.json')
|
||||
const output = path.join(directory, 'catalog.json')
|
||||
const files = [
|
||||
'Axolotl.Launcher-1.9.5-beta.1-1.aarch64.rpm',
|
||||
'Axolotl.Launcher-1.9.5-beta.1-1.aarch64.rpm.sig',
|
||||
'Axolotl.Launcher-1.9.5-beta.1-1.x86_64.rpm',
|
||||
'Axolotl.Launcher-1.9.5-beta.1-1.x86_64.rpm.sig',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_aarch64.AppImage',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_aarch64.AppImage.sig',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_aarch64.AppImage.tar.gz',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_aarch64.AppImage.tar.gz.sig',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_amd64.AppImage',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_amd64.AppImage.sig',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_amd64.AppImage.tar.gz',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_amd64.AppImage.tar.gz.sig',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_amd64.deb',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_amd64.deb.sig',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_arm64.deb',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_arm64.deb.sig',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_universal.dmg',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_x64-setup.exe',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_x64-setup.exe.sig',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_x64-setup.nsis.zip',
|
||||
'Axolotl.Launcher_1.9.5-beta.1_x64-setup.nsis.zip.sig',
|
||||
'Axolotl.Launcher_universal.app.tar.gz',
|
||||
'Axolotl.Launcher_universal.app.tar.gz.sig',
|
||||
'Axolotl_Launcher_1.9.5-beta.1_x64_modern-setup.exe',
|
||||
'Axolotl_Launcher_1.9.5-beta.1_x64_modern-setup.exe.sig',
|
||||
'Axolotl_Launcher_1.9.5-beta.1_x64_nsis-setup.exe',
|
||||
'Axolotl_Launcher_1.9.5-beta.1_x64_nsis-setup.exe.sig',
|
||||
'Axolotl_Launcher_1.9.5-beta.1_x64_portable.zip',
|
||||
]
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
releasePath,
|
||||
JSON.stringify({
|
||||
assets: files.map((name, index) => ({
|
||||
name,
|
||||
size: index + 1,
|
||||
digest: `sha256:${crypto.createHash('sha256').update(name).digest('hex')}`,
|
||||
browser_download_url: `https://github.com/Mystic-Stars/Axolotl/releases/download/v1.9.5-beta.1/${name}`,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/axolotl/create-update-server-catalog.mjs', releasePath, 'v1.9.5-beta.1', output],
|
||||
{ cwd: path.resolve(import.meta.dirname, '..', '..'), encoding: 'utf8' },
|
||||
)
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const catalog = JSON.parse(fs.readFileSync(output, 'utf8'))
|
||||
assert.equal(catalog.version, '1.9.5-beta.1')
|
||||
assert.equal(catalog.files.length, files.length)
|
||||
assert.deepEqual(
|
||||
catalog.artifacts.find((artifact) => artifact.filename.endsWith('_amd64.AppImage.tar.gz'))
|
||||
.targetPlatforms,
|
||||
['linux-x86_64'],
|
||||
)
|
||||
assert.equal(
|
||||
catalog.artifacts.find((artifact) => artifact.filename.endsWith('modern-setup.exe')).variant,
|
||||
'modern',
|
||||
)
|
||||
assert.equal(
|
||||
catalog.artifacts.find((artifact) => artifact.filename.endsWith('portable.zip')).kind,
|
||||
'portable',
|
||||
)
|
||||
assert.deepEqual(
|
||||
catalog.artifacts.find((artifact) => artifact.filename.endsWith('_universal.app.tar.gz'))
|
||||
.targetPlatforms,
|
||||
['darwin-aarch64', 'darwin-x86_64'],
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
38
scripts/axolotl/create-website-release-catalog.mjs
Normal file
38
scripts/axolotl/create-website-release-catalog.mjs
Normal file
@ -0,0 +1,38 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
// 从 app 前端的公告 catalog(apps/app-frontend/src/announcements/catalog.ts,
|
||||
// 发布时人工维护的唯一数据源)导出网站 changelog 数据。
|
||||
// 该文件提交到仓库 main 分支后由 CNB 镜像同步,网站 changelog 页面
|
||||
// 每次用户访问时从 CNB 拉取,国内用户无需访问 GitHub API。
|
||||
// catalog.ts 只使用可擦除语法(无 enum/namespace),直接由 Node 原生类型剥离导入,
|
||||
// 无需 typescript 依赖(verify-and-publish job 不安装依赖)。
|
||||
const [outputPath] = process.argv.slice(2)
|
||||
|
||||
if (!outputPath) {
|
||||
throw new Error('Usage: node create-website-release-catalog.mjs <output.json>')
|
||||
}
|
||||
|
||||
const catalogModule = await import(
|
||||
new URL('../../apps/app-frontend/src/announcements/catalog.ts', import.meta.url)
|
||||
)
|
||||
|
||||
const announcements = catalogModule.launcherAnnouncements
|
||||
if (!Array.isArray(announcements) || announcements.length === 0) {
|
||||
throw new Error('Announcement catalog does not contain any announcements')
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
updated_at: new Date().toISOString(),
|
||||
announcements: announcements.map(({ id, version, publishedAt, title, changes, notes, externalUrl }) => ({
|
||||
id,
|
||||
version,
|
||||
publishedAt,
|
||||
title,
|
||||
changes,
|
||||
notes,
|
||||
externalUrl,
|
||||
})),
|
||||
}
|
||||
|
||||
await fs.writeFile(outputPath, `${JSON.stringify(catalog, null, 2)}\n`)
|
||||
console.log(`Wrote ${outputPath} with ${catalog.announcements.length} announcements`)
|
||||
28
scripts/axolotl/create-website-release-metadata.mjs
Normal file
28
scripts/axolotl/create-website-release-metadata.mjs
Normal file
@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
// 从 GitHub release 元数据生成网站下载元数据(tag + asset 文件名列表)。
|
||||
// 该文件提交到仓库 main 分支后由 CNB 镜像同步,网站从 CNB 拉取,
|
||||
// 使国内用户无需访问 GitHub API 即可获取下载链接。
|
||||
const [releasePath, outputPath] = process.argv.slice(2)
|
||||
|
||||
if (!releasePath || !outputPath) {
|
||||
throw new Error('Usage: node create-website-release-metadata.mjs <release.json> <output.json>')
|
||||
}
|
||||
|
||||
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'))
|
||||
|
||||
if (typeof release.tag_name !== 'string' || !Array.isArray(release.assets)) {
|
||||
throw new Error('Release metadata does not contain tag_name and an assets array')
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
tag_name: release.tag_name,
|
||||
assets: release.assets
|
||||
.map((asset) => asset.name)
|
||||
.filter((name) => typeof name === 'string' && name.length > 0),
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`)
|
||||
console.log(
|
||||
`Wrote ${outputPath} for ${metadata.tag_name} (${metadata.assets.length} assets)`,
|
||||
)
|
||||
40
scripts/axolotl/decode-download-log.mjs
Normal file
40
scripts/axolotl/decode-download-log.mjs
Normal file
@ -0,0 +1,40 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
const [file] = process.argv.slice(2)
|
||||
|
||||
const input = file
|
||||
? await fs.readFile(file, 'utf8')
|
||||
: await new Promise((resolve, reject) => {
|
||||
let data = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
process.stdin.on('data', (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
process.stdin.on('end', () => resolve(data))
|
||||
process.stdin.on('error', reject)
|
||||
})
|
||||
|
||||
const engines = { 0: 'legacy', 1: 'xmcl' }
|
||||
const rules = {
|
||||
1: 'R1NoProgress',
|
||||
2: 'R2BelowExpectation',
|
||||
3: 'R3SegmentWaste',
|
||||
4: 'R4FrequentSwitches',
|
||||
}
|
||||
const sources = {
|
||||
0: 'official',
|
||||
1: 'bmclapi',
|
||||
2: 'mcim',
|
||||
3: 'alternate',
|
||||
4: 'unknown',
|
||||
5: 'tianpao',
|
||||
}
|
||||
|
||||
for (const line of input.split('\n')) {
|
||||
if (!line) continue
|
||||
const [timestamp, engine, rule, source, ...detail] = line.split('|')
|
||||
const date = new Date(Number(timestamp) * 1000).toISOString()
|
||||
console.log(
|
||||
`${date} engine=${engines[engine] ?? engine} rule=${rules[rule] ?? rule} source=${sources[source] ?? source} ${detail.join('|')}`,
|
||||
)
|
||||
}
|
||||
140
scripts/axolotl/i18n-check.mjs
Normal file
140
scripts/axolotl/i18n-check.mjs
Normal file
@ -0,0 +1,140 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { parse, TYPE } from '@formatjs/icu-messageformat-parser'
|
||||
|
||||
const localePairs = [
|
||||
[
|
||||
'apps/app-frontend/src/locales/en-US/index.json',
|
||||
'apps/app-frontend/src/locales/zh-CN/index.json',
|
||||
],
|
||||
['packages/ui/src/locales/en-US/index.json', 'packages/ui/src/locales/zh-CN/index.json'],
|
||||
]
|
||||
|
||||
const failures = []
|
||||
const allowedUntranslatedMessages = new Set([
|
||||
'Chaos Cubed',
|
||||
'MINECON Earth 2017',
|
||||
'Modrinth',
|
||||
'.minecraft',
|
||||
'Striding Hero',
|
||||
'Axolotl Launcher',
|
||||
'Explore high-quality Minecraft content on Modrinth.',
|
||||
'example.modrinth.gg',
|
||||
'{title} - {count}',
|
||||
'Hooks',
|
||||
'/path/to/java',
|
||||
'https://example.com/api/yggdrasil',
|
||||
'Fabric',
|
||||
'Forge',
|
||||
'Paper',
|
||||
'Cleanroom',
|
||||
'LiteLoader',
|
||||
'LogShare.CN',
|
||||
'NeoForge',
|
||||
'OptiFine',
|
||||
'Quilt',
|
||||
'Mr Pack',
|
||||
'TNT',
|
||||
'CurseForge',
|
||||
'BBCode',
|
||||
'mclo.gs',
|
||||
'CMI',
|
||||
'CSV',
|
||||
'AI',
|
||||
'HTML',
|
||||
'Mojang G1GC',
|
||||
'PCL G1GC',
|
||||
'PCL',
|
||||
'Shenandoah',
|
||||
'ZGC',
|
||||
'MineDown',
|
||||
'MiniMessage',
|
||||
'TabooLib',
|
||||
'TrChat',
|
||||
'TNT',
|
||||
'Java {version}',
|
||||
'Studio',
|
||||
'https://api-free.deepl.com/v2/translate',
|
||||
'Java',
|
||||
'Minecraft EULA',
|
||||
'server.properties',
|
||||
'{type} · {version}',
|
||||
'{value} MB',
|
||||
'> {command}',
|
||||
'Beta',
|
||||
'Release',
|
||||
])
|
||||
|
||||
function messageText(value) {
|
||||
return typeof value === 'string' ? value : (value?.message ?? value?.defaultMessage ?? '')
|
||||
}
|
||||
|
||||
function hasExplicitTranslation(value) {
|
||||
return typeof value === 'string' || typeof value?.message === 'string'
|
||||
}
|
||||
|
||||
function argumentNames(message) {
|
||||
const names = new Set()
|
||||
const argumentTypes = new Set([
|
||||
TYPE.argument,
|
||||
TYPE.number,
|
||||
TYPE.date,
|
||||
TYPE.time,
|
||||
TYPE.select,
|
||||
TYPE.plural,
|
||||
])
|
||||
|
||||
function visit(elements) {
|
||||
for (const element of elements) {
|
||||
if (argumentTypes.has(element.type)) names.add(element.value)
|
||||
if (element.options) {
|
||||
for (const option of Object.values(element.options)) visit(option.value)
|
||||
}
|
||||
if (element.children) visit(element.children)
|
||||
}
|
||||
}
|
||||
|
||||
visit(parse(message))
|
||||
return [...names].sort()
|
||||
}
|
||||
|
||||
for (const [sourcePath, translationPath] of localePairs) {
|
||||
const source = JSON.parse(await readFile(sourcePath, 'utf8'))
|
||||
const translation = JSON.parse(await readFile(translationPath, 'utf8'))
|
||||
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!(key in translation)) {
|
||||
failures.push(`${translationPath}: missing ${key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceMessage = messageText(source[key])
|
||||
const translationMessage = messageText(translation[key])
|
||||
const sourceArguments = argumentNames(sourceMessage)
|
||||
const translationArguments = argumentNames(translationMessage)
|
||||
if (sourceArguments.join('\0') !== translationArguments.join('\0')) {
|
||||
failures.push(
|
||||
`${translationPath}: ICU arguments for ${key} are [${translationArguments.join(', ')}], expected [${sourceArguments.join(', ')}]`,
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
sourceMessage === translationMessage &&
|
||||
hasExplicitTranslation(translation[key]) &&
|
||||
/[A-Za-z]{2}/.test(sourceMessage) &&
|
||||
!allowedUntranslatedMessages.has(sourceMessage)
|
||||
) {
|
||||
failures.push(`${translationPath}: untranslated ${key}`)
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`${translationPath}: invalid ICU message ${key}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Simplified Chinese coverage check failed:\n${failures.join('\n')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Simplified Chinese key coverage and ICU argument checks passed.')
|
||||
56
scripts/axolotl/publish-update-server.mjs
Normal file
56
scripts/axolotl/publish-update-server.mjs
Normal file
@ -0,0 +1,56 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const [catalogPath, releasePath, tag] = process.argv.slice(2)
|
||||
const serverUrl = process.env.UPDATE_SERVER_URL?.replace(/\/$/, '')
|
||||
const webhookSecret = process.env.UPDATE_SERVER_WEBHOOK_SECRET
|
||||
|
||||
if (!catalogPath || !releasePath || !tag || !serverUrl || !webhookSecret) {
|
||||
throw new Error(
|
||||
'Usage: UPDATE_SERVER_URL=... UPDATE_SERVER_WEBHOOK_SECRET=... node scripts/axolotl/publish-update-server.mjs <catalog.json> <release.json> <version-tag>',
|
||||
)
|
||||
}
|
||||
|
||||
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'))
|
||||
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'))
|
||||
const version = tag.replace(/^v/, '')
|
||||
if (catalog.version !== version || release.tag_name !== tag) {
|
||||
throw new Error('Release catalog metadata does not match the release tag')
|
||||
}
|
||||
if (!release.published_at) {
|
||||
throw new Error(`GitHub release ${tag} must be published before notifying the Update Server`)
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
event_id: `github-${tag}-${release.id ?? release.node_id ?? version}`,
|
||||
tag,
|
||||
version,
|
||||
channel: version.includes('-') ? 'beta' : 'release',
|
||||
release: {
|
||||
id: release.id,
|
||||
tag_name: release.tag_name,
|
||||
draft: release.draft,
|
||||
body: release.body ?? '',
|
||||
published_at: release.published_at,
|
||||
assets: release.assets,
|
||||
},
|
||||
catalog,
|
||||
force_update: process.env.UPDATE_SERVER_FORCE_UPDATE === 'true',
|
||||
})
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const signature = crypto
|
||||
.createHmac('sha256', webhookSecret)
|
||||
.update(`${timestamp}.${payload}`)
|
||||
.digest('hex')
|
||||
const response = await fetch(`${serverUrl}/api/webhook/release`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Timestamp': timestamp,
|
||||
'X-Webhook-Signature': `sha256=${signature}`,
|
||||
},
|
||||
body: payload,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Update Server publish failed: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
32
scripts/axolotl/set-version.mjs
Normal file
32
scripts/axolotl/set-version.mjs
Normal file
@ -0,0 +1,32 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
const tag = process.argv[2]
|
||||
const version = tag?.replace(/^v/, '')
|
||||
|
||||
if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error(`Expected a semantic version tag such as v1.2.3, received: ${tag ?? '<none>'}`)
|
||||
}
|
||||
|
||||
const packagePath = 'apps/app-frontend/package.json'
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'))
|
||||
packageJson.version = version
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, '\t')}\n`)
|
||||
|
||||
for (const cargoPath of ['apps/app/Cargo.toml', 'packages/app-lib/Cargo.toml']) {
|
||||
const cargoToml = fs.readFileSync(cargoPath, 'utf8')
|
||||
const packageVersionPattern = /^(\[package\][\s\S]*?^version\s*=\s*)"([^"]+)"/m
|
||||
const match = cargoToml.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Could not find package version in ${cargoPath}`)
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
continue
|
||||
}
|
||||
|
||||
const updated = cargoToml.replace(packageVersionPattern, `$1"${version}"`)
|
||||
fs.writeFileSync(cargoPath, updated)
|
||||
}
|
||||
|
||||
console.log(`Configured Axolotl Launcher ${version}`)
|
||||
71
scripts/axolotl/sync-blockbench-skin-editor.mjs
Normal file
71
scripts/axolotl/sync-blockbench-skin-editor.mjs
Normal file
@ -0,0 +1,71 @@
|
||||
import { access, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { constants } from 'node:fs'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const repositoryRoot = resolve(scriptDirectory, '../..')
|
||||
const blockbenchRoot = resolve(repositoryRoot, 'third-party/blockbench')
|
||||
const destination = resolve(repositoryRoot, 'apps/app/resources/blockbench-skin')
|
||||
const bundle = resolve(blockbenchRoot, 'dist/skin.bundle.js')
|
||||
|
||||
try {
|
||||
await access(bundle, constants.R_OK)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Missing ${bundle}. Run npm run build-skin in ${blockbenchRoot} before syncing.`,
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true })
|
||||
|
||||
const expectedFiles = new Set()
|
||||
await Promise.all([
|
||||
syncTree(resolve(blockbenchRoot, 'assets')),
|
||||
syncTree(resolve(blockbenchRoot, 'css')),
|
||||
syncTree(resolve(blockbenchRoot, 'font')),
|
||||
syncFile(resolve(blockbenchRoot, 'index.html')),
|
||||
])
|
||||
|
||||
const bundleContents = await readFile(bundle)
|
||||
await writeIfChanged(resolve(destination, 'dist/skin.bundle.js.gz'), gzipSync(bundleContents, { level: 9 }))
|
||||
expectedFiles.add('dist/skin.bundle.js.gz')
|
||||
await removeStaleFiles(destination)
|
||||
|
||||
async function syncTree(sourceDirectory) {
|
||||
for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
|
||||
const sourcePath = resolve(sourceDirectory, entry.name)
|
||||
if (entry.isDirectory()) await syncTree(sourcePath)
|
||||
else if (entry.isFile()) await syncFile(sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFile(sourcePath) {
|
||||
const relativePath = relative(blockbenchRoot, sourcePath).replaceAll('\\', '/')
|
||||
expectedFiles.add(relativePath)
|
||||
await writeIfChanged(resolve(destination, relativePath), await readFile(sourcePath))
|
||||
}
|
||||
|
||||
async function writeIfChanged(destinationPath, contents) {
|
||||
try {
|
||||
const existing = await readFile(destinationPath)
|
||||
if (existing.equals(contents)) return
|
||||
} catch {}
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
await writeFile(destinationPath, contents)
|
||||
}
|
||||
|
||||
async function removeStaleFiles(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = resolve(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await removeStaleFiles(path)
|
||||
if ((await readdir(path)).length === 0) await rm(path, { recursive: true })
|
||||
continue
|
||||
}
|
||||
if (entry.isFile() && !expectedFiles.has(relative(destination, path).replaceAll('\\', '/'))) {
|
||||
await rm(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
114
scripts/axolotl/sync-contributors.mjs
Normal file
114
scripts/axolotl/sync-contributors.mjs
Normal file
@ -0,0 +1,114 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPOSITORY = 'Mystic-Stars/Axolotl'
|
||||
const PER_PAGE = 100
|
||||
const MAX_PAGES = 10
|
||||
const OUTPUT_PATH = fileURLToPath(
|
||||
new URL('../../apps/app-frontend/src/data/about/contributors.json', import.meta.url),
|
||||
)
|
||||
|
||||
function requestHeaders() {
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'Axolotl-Launcher-Contributors-Sync',
|
||||
}
|
||||
const token = process.env.AXOLOTL_GITHUB_TOKEN || process.env.GITHUB_TOKEN
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
async function fetchPage(page) {
|
||||
const url = new URL(`https://api.github.com/repos/${REPOSITORY}/contributors`)
|
||||
url.searchParams.set('per_page', String(PER_PAGE))
|
||||
url.searchParams.set('page', String(page))
|
||||
|
||||
let failure
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: requestHeaders(),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
failure = error
|
||||
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, attempt * 1_000))
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to fetch contributors from ${url}`, { cause: failure })
|
||||
}
|
||||
|
||||
function normalizeContributor(contributor) {
|
||||
if (!contributor || typeof contributor !== 'object' || Array.isArray(contributor))
|
||||
return undefined
|
||||
if (typeof contributor.login !== 'string' || !contributor.login) return undefined
|
||||
if (typeof contributor.html_url !== 'string' || !contributor.html_url) return undefined
|
||||
if (typeof contributor.avatar_url !== 'string' || !contributor.avatar_url) return undefined
|
||||
if (!Number.isInteger(contributor.contributions) || contributor.contributions < 1)
|
||||
return undefined
|
||||
|
||||
const avatarUrl = new URL(contributor.avatar_url)
|
||||
avatarUrl.searchParams.set('s', '96')
|
||||
|
||||
return {
|
||||
name: contributor.login,
|
||||
avatarUrl: avatarUrl.toString(),
|
||||
url: contributor.html_url,
|
||||
contributions: contributor.contributions,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContributors() {
|
||||
const pages = []
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const contributors = await fetchPage(page)
|
||||
if (!Array.isArray(contributors)) throw new Error(`Page ${page} did not contain an array`)
|
||||
pages.push(contributors)
|
||||
if (contributors.length < PER_PAGE) break
|
||||
}
|
||||
|
||||
const contributors = pages
|
||||
.flat()
|
||||
.map(normalizeContributor)
|
||||
.filter((contributor) => contributor !== undefined)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.contributions - left.contributions || left.name.localeCompare(right.name),
|
||||
)
|
||||
|
||||
if (contributors.length === 0)
|
||||
throw new Error('The contributors response did not contain any people')
|
||||
return contributors
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let contributors
|
||||
try {
|
||||
contributors = await fetchContributors()
|
||||
} catch (error) {
|
||||
if (existsSync(OUTPUT_PATH)) {
|
||||
console.warn(
|
||||
`Unable to refresh contributors, keeping the existing snapshot: ${error.message}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const nextText = `${JSON.stringify(contributors, null, '\t')}\n`
|
||||
const currentText = existsSync(OUTPUT_PATH) ? await fs.readFile(OUTPUT_PATH, 'utf8') : ''
|
||||
|
||||
if (currentText === nextText) {
|
||||
console.log(`Contributors are up to date (${contributors.length} people).`)
|
||||
return
|
||||
}
|
||||
|
||||
await fs.writeFile(OUTPUT_PATH, nextText)
|
||||
console.log(`Synchronized ${contributors.length} contributors from ${REPOSITORY}.`)
|
||||
}
|
||||
await main()
|
||||
310
scripts/axolotl/sync-lobehub-models.mjs
Normal file
310
scripts/axolotl/sync-lobehub-models.mjs
Normal file
@ -0,0 +1,310 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { stripTypeScriptTypes } from 'node:module'
|
||||
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const REPOSITORY_ROOT = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const CATALOG_PATH = resolve(REPOSITORY_ROOT, 'packages/app-lib/src/api/lobehub_text_models.json')
|
||||
const RUST_API_PATH = resolve(REPOSITORY_ROOT, 'packages/app-lib/src/api/ai.rs')
|
||||
const SOURCE_PATTERN = /^LobeHub ([0-9a-f]{40}) model-bank chat models$/
|
||||
const RUST_SOURCE_PATTERN = /const CATALOG_SOURCE: &str = "LobeHub ([0-9a-f]{40})";/g
|
||||
|
||||
// Sponsored providers are paid partner listings that must survive every
|
||||
// synchronization, even if a future rewrite of ai.rs drops upstream providers.
|
||||
// Keep this list in sync with SPONSORED_PROVIDERS in packages/app-lib/src/api/ai.rs.
|
||||
const SPONSORED_PROVIDER_IDS = ['codeflow']
|
||||
|
||||
function assertSponsoredProvidersPreserved(rustSource) {
|
||||
for (const providerId of SPONSORED_PROVIDER_IDS) {
|
||||
const pattern = new RegExp(`provider!\\s*\\(\\s*"${providerId}"`)
|
||||
if (!pattern.test(rustSource)) {
|
||||
throw new Error(
|
||||
`Sponsored provider ${providerId} is missing from ai.rs; refusing to rewrite it`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: node --experimental-vm-modules scripts/axolotl/sync-lobehub-models.mjs \\
|
||||
--upstream <lobehub checkout> [--commit <40-character SHA>] [--check]`)
|
||||
}
|
||||
|
||||
function parseArguments(arguments_) {
|
||||
const options = { check: false, commit: undefined, upstream: undefined }
|
||||
for (let index = 0; index < arguments_.length; index++) {
|
||||
const argument = arguments_[index]
|
||||
switch (argument) {
|
||||
case '--check':
|
||||
options.check = true
|
||||
break
|
||||
case '--commit':
|
||||
case '--upstream': {
|
||||
const value = arguments_[++index]
|
||||
if (!value) throw new Error(`${argument} requires a value`)
|
||||
options[argument.slice(2)] = value
|
||||
break
|
||||
}
|
||||
case '--help':
|
||||
case '-h':
|
||||
usage()
|
||||
process.exit(0)
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${argument}`)
|
||||
}
|
||||
}
|
||||
if (!options.upstream) throw new Error('--upstream is required')
|
||||
return options
|
||||
}
|
||||
|
||||
function requireObject(value, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function countModels(providers) {
|
||||
return Object.values(providers).reduce((total, models) => total + models.length, 0)
|
||||
}
|
||||
|
||||
function resolveCommit(upstreamRoot, requestedCommit) {
|
||||
const commit =
|
||||
requestedCommit ??
|
||||
execFileSync('git', ['-C', upstreamRoot, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
if (!/^[0-9a-f]{40}$/.test(commit)) {
|
||||
throw new Error(`Invalid LobeHub commit SHA: ${commit}`)
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
function createModelBankLoader(upstreamRoot) {
|
||||
if (typeof vm.SourceTextModule !== 'function') {
|
||||
throw new Error('Node.js must be run with --experimental-vm-modules')
|
||||
}
|
||||
|
||||
const sourceRoot = realpathSync(resolve(upstreamRoot, 'packages/model-bank/src'))
|
||||
const context = vm.createContext(Object.create(null), {
|
||||
codeGeneration: { strings: false, wasm: false },
|
||||
name: 'lobehub-model-bank',
|
||||
})
|
||||
const moduleCache = new Map()
|
||||
const schema = vm.runInContext(
|
||||
`(() => {
|
||||
let schema
|
||||
schema = new Proxy(function () { return schema }, { get() { return schema } })
|
||||
return schema
|
||||
})()`,
|
||||
context,
|
||||
)
|
||||
const zodModule = new vm.SyntheticModule(
|
||||
['z'],
|
||||
function () {
|
||||
this.setExport('z', schema)
|
||||
},
|
||||
{ context, identifier: 'sandbox:zod' },
|
||||
)
|
||||
const typeFestModule = new vm.SyntheticModule([], function () {}, {
|
||||
context,
|
||||
identifier: 'sandbox:type-fest',
|
||||
})
|
||||
|
||||
function resolveLocalImport(specifier, parentPath) {
|
||||
const basePath = resolve(dirname(parentPath), specifier)
|
||||
const candidates = extname(basePath)
|
||||
? [basePath]
|
||||
: [`${basePath}.ts`, join(basePath, 'index.ts')]
|
||||
for (const candidate of candidates) {
|
||||
if (!existsSync(candidate) || !statSync(candidate).isFile()) continue
|
||||
const canonicalPath = realpathSync(candidate)
|
||||
const relativePath = relative(sourceRoot, canonicalPath)
|
||||
if (relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
|
||||
throw new Error(`Import escapes model-bank/src: ${specifier}`)
|
||||
}
|
||||
return canonicalPath
|
||||
}
|
||||
throw new Error(`Unable to resolve ${specifier} from ${parentPath}`)
|
||||
}
|
||||
|
||||
function getModule(filePath) {
|
||||
const cached = moduleCache.get(filePath)
|
||||
if (cached) return cached
|
||||
const code = stripTypeScriptTypes(readFileSync(filePath, 'utf8'), {
|
||||
mode: 'transform',
|
||||
sourceUrl: pathToFileURL(filePath).href,
|
||||
})
|
||||
const module = new vm.SourceTextModule(code, {
|
||||
context,
|
||||
identifier: pathToFileURL(filePath).href,
|
||||
importModuleDynamically() {
|
||||
throw new Error('Dynamic imports are not allowed in the LobeHub model bank')
|
||||
},
|
||||
})
|
||||
moduleCache.set(filePath, module)
|
||||
return module
|
||||
}
|
||||
|
||||
function linker(specifier, referencingModule) {
|
||||
if (specifier === 'zod') return zodModule
|
||||
if (specifier === 'type-fest') return typeFestModule
|
||||
if (!specifier.startsWith('.')) {
|
||||
throw new Error(`External import is not allowed in the model bank: ${specifier}`)
|
||||
}
|
||||
return getModule(resolveLocalImport(specifier, fileURLToPath(referencingModule.identifier)))
|
||||
}
|
||||
|
||||
return async function loadModels() {
|
||||
const entryPath = realpathSync(join(sourceRoot, 'aiModels/index.ts'))
|
||||
const entry = getModule(entryPath)
|
||||
await entry.link(linker)
|
||||
await entry.evaluate({ timeout: 20_000 })
|
||||
return entry.namespace.LOBE_DEFAULT_MODEL_LIST
|
||||
}
|
||||
}
|
||||
|
||||
function validateCurrentCatalog(catalog, rustSource) {
|
||||
requireObject(catalog, 'Current model catalog')
|
||||
const providers = requireObject(catalog.providers, 'Current model catalog providers')
|
||||
const sourceMatch = SOURCE_PATTERN.exec(catalog.source)
|
||||
if (!sourceMatch) throw new Error('Current model catalog has an invalid source')
|
||||
|
||||
const rustMatches = [...rustSource.matchAll(RUST_SOURCE_PATTERN)]
|
||||
if (rustMatches.length !== 1) {
|
||||
throw new Error('Expected exactly one CATALOG_SOURCE constant in ai.rs')
|
||||
}
|
||||
if (rustMatches[0][1] !== sourceMatch[1]) {
|
||||
throw new Error('The JSON and Rust catalog source commits do not match')
|
||||
}
|
||||
|
||||
for (const [providerId, models] of Object.entries(providers)) {
|
||||
if (!/^[a-z0-9]+$/.test(providerId) || !Array.isArray(models)) {
|
||||
throw new Error(`Invalid current catalog provider: ${providerId}`)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
function buildCatalog(models, currentProviders) {
|
||||
if (!Array.isArray(models)) {
|
||||
throw new Error('LOBE_DEFAULT_MODEL_LIST must be an array')
|
||||
}
|
||||
|
||||
const providers = Object.fromEntries(
|
||||
Object.keys(currentProviders).map((providerId) => [providerId, []]),
|
||||
)
|
||||
const seen = new Set()
|
||||
let upstreamChatCount = 0
|
||||
|
||||
for (const model of models) {
|
||||
requireObject(model, 'LobeHub model')
|
||||
if (model.type !== 'chat') continue
|
||||
upstreamChatCount++
|
||||
if (typeof model.providerId !== 'string' || !model.providerId) {
|
||||
throw new Error('LobeHub chat model has no providerId')
|
||||
}
|
||||
if (typeof model.id !== 'string' || !model.id.trim()) {
|
||||
throw new Error(`LobeHub ${model.providerId} chat model has no id`)
|
||||
}
|
||||
if (typeof model.enabled !== 'boolean') {
|
||||
throw new Error(`LobeHub model ${model.providerId}/${model.id} has invalid enabled state`)
|
||||
}
|
||||
if (!Object.hasOwn(providers, model.providerId)) continue
|
||||
|
||||
const key = `${model.providerId}\0${model.id}`
|
||||
if (seen.has(key)) throw new Error(`Duplicate LobeHub model: ${model.providerId}/${model.id}`)
|
||||
seen.add(key)
|
||||
const displayName = model.displayName || model.id
|
||||
if (typeof displayName !== 'string' || !displayName.trim()) {
|
||||
throw new Error(`LobeHub model ${model.providerId}/${model.id} has no display name`)
|
||||
}
|
||||
providers[model.providerId].push({
|
||||
id: model.id,
|
||||
name: displayName,
|
||||
enabled: model.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
for (const providerId of SPONSORED_PROVIDER_IDS) {
|
||||
const sponsorModels = currentProviders[providerId]
|
||||
if (!sponsorModels?.length) continue
|
||||
providers[providerId] = [...sponsorModels]
|
||||
console.log(
|
||||
`Preserved ${sponsorModels.length} sponsored model(s) for ${providerId}`,
|
||||
)
|
||||
}
|
||||
|
||||
const previousCount = countModels(currentProviders)
|
||||
const synchronizedCount = countModels(providers)
|
||||
const minimumCount = Math.max(100, Math.floor(previousCount * 0.7))
|
||||
const maximumCount = Math.max(500, Math.ceil(previousCount * 2))
|
||||
if (synchronizedCount < minimumCount || synchronizedCount > maximumCount) {
|
||||
throw new Error(
|
||||
`Refusing suspicious model count change: ${previousCount} -> ${synchronizedCount}`,
|
||||
)
|
||||
}
|
||||
for (const [providerId, previousModels] of Object.entries(currentProviders)) {
|
||||
if (previousModels.length > 0 && providers[providerId].length === 0) {
|
||||
throw new Error(`Refusing to empty existing provider ${providerId}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { providers, synchronizedCount, upstreamChatCount }
|
||||
}
|
||||
|
||||
const options = parseArguments(process.argv.slice(2))
|
||||
const upstreamRoot = realpathSync(resolve(options.upstream))
|
||||
const commit = resolveCommit(upstreamRoot, options.commit)
|
||||
const [catalogText, rustSource] = await Promise.all([
|
||||
fs.readFile(CATALOG_PATH, 'utf8'),
|
||||
fs.readFile(RUST_API_PATH, 'utf8'),
|
||||
])
|
||||
assertSponsoredProvidersPreserved(rustSource)
|
||||
const currentCatalog = JSON.parse(catalogText)
|
||||
const currentProviders = validateCurrentCatalog(currentCatalog, rustSource)
|
||||
const loadModels = createModelBankLoader(upstreamRoot)
|
||||
const { providers, synchronizedCount, upstreamChatCount } = buildCatalog(
|
||||
await loadModels(),
|
||||
currentProviders,
|
||||
)
|
||||
const providersChanged = JSON.stringify(providers) !== JSON.stringify(currentProviders)
|
||||
|
||||
if (!providersChanged) {
|
||||
console.log(
|
||||
`LobeHub ${commit} has no model changes for ${Object.keys(providers).length} supported providers (${synchronizedCount}/${upstreamChatCount} chat models).`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const nextCatalogText = `${JSON.stringify(
|
||||
{
|
||||
source: `LobeHub ${commit} model-bank chat models`,
|
||||
providers,
|
||||
},
|
||||
null,
|
||||
'\t',
|
||||
)}\n`
|
||||
const nextRustSource = rustSource.replace(
|
||||
RUST_SOURCE_PATTERN,
|
||||
`const CATALOG_SOURCE: &str = "LobeHub ${commit}";`,
|
||||
)
|
||||
assertSponsoredProvidersPreserved(nextRustSource)
|
||||
|
||||
if (options.check) {
|
||||
console.error(
|
||||
`Bundled model catalog is stale: LobeHub ${commit} has ${synchronizedCount}/${upstreamChatCount} applicable chat models.`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fs.writeFile(CATALOG_PATH, nextCatalogText),
|
||||
fs.writeFile(RUST_API_PATH, nextRustSource),
|
||||
])
|
||||
console.log(
|
||||
`Synchronized ${synchronizedCount}/${upstreamChatCount} LobeHub chat models from ${commit} across ${Object.keys(providers).length} supported providers.`,
|
||||
)
|
||||
67
scripts/axolotl/sync-recipe-item-names.mjs
Normal file
67
scripts/axolotl/sync-recipe-item-names.mjs
Normal file
@ -0,0 +1,67 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
const MINECRAFT_ASSETS_VERSION = '26.2'
|
||||
const ITEMS_ROOT = new URL(
|
||||
'../../apps/app-frontend/src/lab/recipe-generator/assets/items/',
|
||||
import.meta.url,
|
||||
)
|
||||
const OUTPUT_FILE = new URL(
|
||||
'../../apps/app-frontend/src/lab/recipe-generator/assets/vanilla/item-name-index.json',
|
||||
import.meta.url,
|
||||
)
|
||||
|
||||
function languageUrl(locale) {
|
||||
return `https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@${MINECRAFT_ASSETS_VERSION}/assets/minecraft/lang/${locale}.json`
|
||||
}
|
||||
|
||||
async function downloadJson(url) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`Unable to download ${url}: HTTP ${response.status}`)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
function itemTranslationKey(id, enUs) {
|
||||
if (!id.startsWith('minecraft:') || /:\d+$/.test(id)) return undefined
|
||||
const path = id.slice('minecraft:'.length)
|
||||
const blockKey = `block.minecraft.${path}`
|
||||
const itemKey = `item.minecraft.${path}`
|
||||
if (typeof enUs[blockKey] === 'string') return blockKey
|
||||
if (typeof enUs[itemKey] === 'string') return itemKey
|
||||
return undefined
|
||||
}
|
||||
|
||||
const manifestFiles = (await fs.readdir(ITEMS_ROOT)).filter((file) => file.endsWith('.json'))
|
||||
const readableById = new Map()
|
||||
|
||||
for (const file of manifestFiles) {
|
||||
const manifest = JSON.parse(await fs.readFile(new URL(file, ITEMS_ROOT), 'utf8'))
|
||||
for (const item of manifest.items ?? []) {
|
||||
readableById.set(item.id, item.readable)
|
||||
}
|
||||
}
|
||||
|
||||
const [enUs, zhCn] = await Promise.all([
|
||||
downloadJson(languageUrl('en_us')),
|
||||
downloadJson(languageUrl('zh_cn')),
|
||||
])
|
||||
|
||||
const itemNameIndex = { en_us: {}, zh_cn: {} }
|
||||
for (const [id, readable] of readableById) {
|
||||
const key = itemTranslationKey(id, enUs)
|
||||
if (!key) continue
|
||||
itemNameIndex.en_us[key] = enUs[key] ?? readable
|
||||
itemNameIndex.zh_cn[key] = zhCn[key] ?? itemNameIndex.en_us[key]
|
||||
}
|
||||
|
||||
for (const locale of ['en_us', 'zh_cn']) {
|
||||
itemNameIndex[locale] = Object.fromEntries(
|
||||
Object.entries(itemNameIndex[locale]).sort(([left], [right]) => left.localeCompare(right)),
|
||||
)
|
||||
}
|
||||
|
||||
await fs.mkdir(new URL('.', OUTPUT_FILE), { recursive: true })
|
||||
await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(itemNameIndex, null, '\t')}\n`)
|
||||
|
||||
console.log(
|
||||
`Updated recipe item names from Minecraft assets ${MINECRAFT_ASSETS_VERSION}: ${Object.keys(itemNameIndex.en_us).length} en_us entries and ${Object.keys(itemNameIndex.zh_cn).length} zh_cn entries.`,
|
||||
)
|
||||
136
scripts/axolotl/sync-schematic-resources.mjs
Normal file
136
scripts/axolotl/sync-schematic-resources.mjs
Normal file
@ -0,0 +1,136 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
const SUMMARY_REF = 'b8170fbc07725bf4930d189ad5dc16f70e09b9cd'
|
||||
const ATLAS_REF = 'a73f0316d9cea52a53381664328bda00e5fe79e4'
|
||||
const EXPECTED_VERSION = '26.3-snapshot-6'
|
||||
const OUTPUT_ROOT = new URL(
|
||||
'../../apps/app-frontend/src/lab/schematic-preview/assets/vanilla/',
|
||||
import.meta.url,
|
||||
)
|
||||
|
||||
function summaryUrl(path) {
|
||||
return `https://raw.githubusercontent.com/misode/mcmeta/${SUMMARY_REF}/${path}`
|
||||
}
|
||||
|
||||
function atlasUrl(path) {
|
||||
return `https://raw.githubusercontent.com/misode/mcmeta/${ATLAS_REF}/${path}`
|
||||
}
|
||||
|
||||
async function download(url) {
|
||||
let failure
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
} catch (error) {
|
||||
failure = error
|
||||
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, attempt * 500))
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to download ${url}`, { cause: failure })
|
||||
}
|
||||
|
||||
function parseJson(bytes, source) {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes))
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON from ${source}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
function requireObject(value, source) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${source} must contain a JSON object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function pngDimensions(bytes) {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
if (bytes.length < 24 || signature.some((value, index) => bytes[index] !== value)) {
|
||||
throw new Error('The downloaded texture atlas is not a PNG image')
|
||||
}
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
return [view.getUint32(16), view.getUint32(20)]
|
||||
}
|
||||
|
||||
const sources = {
|
||||
definitions: summaryUrl('assets/block_definition/data.min.json'),
|
||||
models: summaryUrl('assets/model/data.min.json'),
|
||||
blocks: summaryUrl('blocks/data.min.json'),
|
||||
summaryVersion: summaryUrl('version.json'),
|
||||
atlasLayout: atlasUrl('all/data.min.json'),
|
||||
atlasImage: atlasUrl('all/atlas.png'),
|
||||
atlasVersion: atlasUrl('version.json'),
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
Object.entries(sources).map(async ([name, url]) => [name, await download(url)]),
|
||||
)
|
||||
const downloaded = Object.fromEntries(entries)
|
||||
const summaryVersion = requireObject(
|
||||
parseJson(downloaded.summaryVersion, sources.summaryVersion),
|
||||
'summary version',
|
||||
)
|
||||
const atlasVersion = requireObject(
|
||||
parseJson(downloaded.atlasVersion, sources.atlasVersion),
|
||||
'atlas version',
|
||||
)
|
||||
if (summaryVersion.id !== EXPECTED_VERSION || atlasVersion.id !== EXPECTED_VERSION) {
|
||||
throw new Error(
|
||||
`Expected ${EXPECTED_VERSION}, received summary ${summaryVersion.id} and atlas ${atlasVersion.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
const definitions = requireObject(
|
||||
parseJson(downloaded.definitions, sources.definitions),
|
||||
'block definitions',
|
||||
)
|
||||
const models = requireObject(parseJson(downloaded.models, sources.models), 'block models')
|
||||
const blocks = requireObject(parseJson(downloaded.blocks, sources.blocks), 'block summary')
|
||||
const atlasLayout = requireObject(
|
||||
parseJson(downloaded.atlasLayout, sources.atlasLayout),
|
||||
'atlas layout',
|
||||
)
|
||||
const defaultProperties = Object.fromEntries(
|
||||
Object.entries(blocks).map(([blockId, summary]) => {
|
||||
if (
|
||||
!Array.isArray(summary) ||
|
||||
!summary[1] ||
|
||||
typeof summary[1] !== 'object' ||
|
||||
Array.isArray(summary[1])
|
||||
) {
|
||||
throw new Error(`Block summary ${blockId} has no default property object`)
|
||||
}
|
||||
return [blockId, summary[1]]
|
||||
}),
|
||||
)
|
||||
|
||||
const [atlasWidth, atlasHeight] = pngDimensions(downloaded.atlasImage)
|
||||
for (const [textureId, region] of Object.entries(atlasLayout)) {
|
||||
if (
|
||||
!Array.isArray(region) ||
|
||||
region.length !== 4 ||
|
||||
region.some((value) => !Number.isInteger(value) || value < 0) ||
|
||||
region[0] + region[2] > atlasWidth ||
|
||||
region[1] + region[3] > atlasHeight
|
||||
) {
|
||||
throw new Error(`Atlas region ${textureId} is outside the ${atlasWidth}x${atlasHeight} image`)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fs.writeFile(new URL('block-state-index.json', OUTPUT_ROOT), JSON.stringify(definitions)),
|
||||
fs.writeFile(new URL('block-model-index.json', OUTPUT_ROOT), JSON.stringify(models)),
|
||||
fs.writeFile(
|
||||
new URL('block-property-defaults.json', OUTPUT_ROOT),
|
||||
JSON.stringify(defaultProperties),
|
||||
),
|
||||
fs.writeFile(new URL('texture-layout.json', OUTPUT_ROOT), JSON.stringify(atlasLayout)),
|
||||
fs.writeFile(new URL('texture-atlas.png', OUTPUT_ROOT), downloaded.atlasImage),
|
||||
])
|
||||
|
||||
console.log(
|
||||
`Updated schematic resources to ${EXPECTED_VERSION}: ${Object.keys(definitions).length} block definitions, ${Object.keys(models).length} models, and ${Object.keys(atlasLayout).length} atlas regions (${atlasWidth}x${atlasHeight}).`,
|
||||
)
|
||||
45
scripts/axolotl/verify-update-manifest.mjs
Normal file
45
scripts/axolotl/verify-update-manifest.mjs
Normal file
@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
const [manifestPath, tag, source = 'github'] = process.argv.slice(2)
|
||||
const expectedVersion = tag?.replace(/^v/, '')
|
||||
|
||||
if (!manifestPath || !expectedVersion || !['github', 'cnb'].includes(source)) {
|
||||
throw new Error('Usage: node verify-update-manifest.mjs <latest.json> <version-tag> [github|cnb]')
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
if (manifest.version !== expectedVersion) {
|
||||
throw new Error(`Manifest version ${manifest.version} does not match ${expectedVersion}`)
|
||||
}
|
||||
|
||||
const requiredPlatforms = [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
'linux-aarch64',
|
||||
'linux-x86_64',
|
||||
'windows-x86_64',
|
||||
]
|
||||
|
||||
for (const platform of requiredPlatforms) {
|
||||
const update = manifest.platforms?.[platform]
|
||||
|
||||
if (!update || typeof update.signature !== 'string' || update.signature.trim().length < 32) {
|
||||
throw new Error(`Missing signed update for ${platform}`)
|
||||
}
|
||||
|
||||
const url = new URL(update.url)
|
||||
const pathname = decodeURIComponent(url.pathname).toLowerCase()
|
||||
const isExpectedUrl =
|
||||
url.protocol === 'https:' &&
|
||||
(source === 'github'
|
||||
? url.hostname === 'github.com' &&
|
||||
pathname.includes('/mystic-stars/axolotl/releases/download/')
|
||||
: url.hostname === 'cnb.cool' &&
|
||||
pathname.includes(`/axlmc/axolotl/-/releases/download/${tag.toLowerCase()}/`))
|
||||
if (!isExpectedUrl) {
|
||||
throw new Error(`Unexpected ${source} update URL for ${platform}: ${update.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Verified signed ${source} updater manifest for ${expectedVersion}`)
|
||||
Reference in New Issue
Block a user