feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View 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.')

View 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)
}

View 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()

View 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.`)

View 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`)

View 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`,
)

View 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 })
}

View File

@ -0,0 +1,38 @@
import fs from 'node:fs/promises'
// 从 app 前端的公告 catalogapps/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`)

View 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)`,
)

View 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('|')}`,
)
}

View 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.')

View 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()}`)
}

View 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}`)

View 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)
}
}
}

View 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()

View 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.`,
)

View 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.`,
)

View 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}).`,
)

View 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}`)