import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = path.resolve(appRoot, '../..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; const releaseTarget = process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; const bundleRoot = path.join( appRoot, 'src-tauri', 'target', releaseTarget, 'release', 'bundle', ); const packageJsonPath = path.join(appRoot, 'package.json'); const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json'); const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json'); const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml'); const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; const updateManifestUrl = process.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() || `${process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl}/latest.json`; function readPackageJson() { return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); } export function compareVersions(left, right) { const leftParts = left.split('.').map(Number); const rightParts = right.split('.').map(Number); for (let index = 0; index < 3; index += 1) { if (leftParts[index] !== rightParts[index]) { return leftParts[index] > rightParts[index] ? 1 : -1; } } return 0; } function parseVersion(value, label) { if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/u.test(value)) { throw new Error(`${label} 不是有效的三段版本号:${String(value)}`); } return value; } export function bumpVersion(current, target = 'patch') { const [major, minor, patch] = parseVersion(current, '当前版本').split('.').map(Number); if (target === 'major') return `${major + 1}.0.0`; if (target === 'minor') return `${major}.${minor + 1}.0`; if (target === 'patch' || target == null) { if (patch === Number.MAX_SAFE_INTEGER) { throw new Error(`版本号 patch 已达到上限:${current}`); } return `${major}.${minor}.${patch + 1}`; } if (typeof target === 'string' && /^\d+\.\d+\.\d+$/u.test(target)) { return parseVersion(target, '指定版本'); } throw new Error( `无法识别的版本目标:${String(target)}(支持 patch / minor / major / 明确的三段版本号)`, ); } async function readRemoteVersion() { let response; try { response = await fetch(updateManifestUrl, { headers: { Accept: 'application/json' }, }); } catch (error) { throw new Error(`读取 OSS 版本清单失败:${error.message}`); } if (response.status === 404) return null; if (!response.ok) { throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`); } let manifest; try { manifest = await response.json(); } catch (error) { throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`); } return parseVersion(manifest?.version, 'OSS版本清单 version'); } function replaceVersionLine(source, version, pattern, label) { if (!pattern.test(source)) throw new Error(`未找到${label}版本字段`); return source.replace(pattern, `$1${version}$3`); } const versionFileSources = [ { file: packageJsonPath, pattern: /("version"\s*:\s*")([^"]+)(")/u, label: 'package.json', }, { file: rootPackageLockPath, pattern: /("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u, label: 'package-lock.json', }, { file: tauriConfigPath, pattern: /("version"\s*:\s*")([^"]+)(")/u, label: 'tauri.conf.json', }, { file: cargoManifestPath, pattern: /(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu, label: 'Cargo.toml', }, { file: cargoLockPath, pattern: /(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu, label: 'Cargo.lock', }, ]; export function readLocalVersion() { return parseVersion(readPackageJson().version, '本地版本'); } export function validateVersionConsistency(expectedVersion) { for (const source of versionFileSources) { const match = fs.readFileSync(source.file, 'utf8').match(source.pattern); const actual = match ? match[2].trim() : '<未找到>'; if (actual !== expectedVersion) { throw new Error( `AGC 版本号不一致:${source.label} 应为 ${expectedVersion},实际 ${actual}`, ); } } } export function writeVersionFiles(version) { const nextVersion = parseVersion(version, '目标版本'); const packageSource = fs.readFileSync(packageJsonPath, 'utf8'); fs.writeFileSync( packageJsonPath, replaceVersionLine( packageSource, nextVersion, /("version"\s*:\s*")([^"]+)(")/u, 'package.json', ), ); const rootPackageLockSource = fs.readFileSync(rootPackageLockPath, 'utf8'); fs.writeFileSync( rootPackageLockPath, replaceVersionLine( rootPackageLockSource, nextVersion, /("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u, 'root package-lock.json', ), ); const tauriSource = fs.readFileSync(tauriConfigPath, 'utf8'); fs.writeFileSync( tauriConfigPath, replaceVersionLine( tauriSource, nextVersion, /("version"\s*:\s*")([^"]+)(")/u, 'tauri.conf.json', ), ); const cargoSource = fs.readFileSync(cargoManifestPath, 'utf8'); fs.writeFileSync( cargoManifestPath, replaceVersionLine( cargoSource, nextVersion, /(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu, 'Cargo.toml', ), ); const cargoLockSource = fs.readFileSync(cargoLockPath, 'utf8'); fs.writeFileSync( cargoLockPath, replaceVersionLine( cargoLockSource, nextVersion, /(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu, 'Cargo.lock', ), ); return nextVersion; } export function assertVersionCommitted() { const repoPaths = versionFileSources.map((source) => path.relative(repoRoot, source.file).replaceAll('\\', '/'), ); const changed = spawnSync( 'git', ['diff', '--quiet', 'HEAD', '--', ...repoPaths], { cwd: repoRoot }, ).status === 1; if (changed) { throw new Error( '版本文件相对 HEAD 存在未提交改动,请先提交后再发布:运行 npm --prefix apps/ai-game-creator-shell run bump-version -- --commit', ); } } export function prepareReleaseVersion() { const localVersion = readLocalVersion(); validateVersionConsistency(localVersion); console.log( `[ai-game-creator-shell] 使用仓库版本 ${localVersion}(不再自动递增或自由指定版本)`, ); return localVersion; } export async function assertVersionNotBelowOss(version) { const remoteVersion = await readRemoteVersion(); if (remoteVersion && compareVersions(version, remoteVersion) < 0) { throw new Error( `仓库版本 ${version} 低于线上 OSS 版本 ${remoteVersion}。请先运行 npm --prefix apps/ai-game-creator-shell run bump-version 提升版本后再发布。`, ); } console.log( `[ai-game-creator-shell] 线上 OSS 版本 ${remoteVersion ?? '不存在'},发布版本 ${version}`, ); } export function runTauriBuild(args = []) { const noBundle = args.includes('--no-bundle'); const hasTarget = args.includes('--target'); const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget]; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const result = spawnSync( npmCommand, [ '--prefix', '../..', 'exec', 'tauri', '--', 'build', ...targetArgs, ...args, ], { cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' }, ); if (result.error) throw result.error; if (result.status !== 0) process.exit(result.status ?? 1); } function listFiles(root) { if (!fs.existsSync(root)) return []; return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { const fullPath = path.join(root, entry.name); return entry.isDirectory() ? listFiles(fullPath) : [fullPath]; }); } function artifactPriority(filePath) { const name = path.basename(filePath).toLowerCase(); if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99; if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99; if (name.endsWith('.appimage')) return 0; if (name.endsWith('.deb')) return 1; if (name.endsWith('.rpm')) return 2; return 99; } export function selectReleaseArtifact(files) { const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim(); if (explicit) { const resolved = path.resolve(explicit); if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { throw new Error(`AGC_UPDATE_ARTIFACT 不是有效文件:${resolved}`); } return resolved; } return ( [...files] .filter((filePath) => artifactPriority(filePath) < 99) .sort((left, right) => { const priority = artifactPriority(left) - artifactPriority(right); return priority || left.localeCompare(right); })[0] ?? null ); } export function createUpdateManifest(artifactPath) { const bytes = fs.readFileSync(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); const baseUrl = ( process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl ).replace(/\/+$/u, ''); const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/'); return { version, downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`, sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length, ...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim() ? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() } : {}), }; } export function generateUpdateManifest() { const artifact = selectReleaseArtifact(listFiles(bundleRoot)); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } const manifest = createUpdateManifest(artifact); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`); console.log(`[ai-game-creator-shell] 安装包:${artifact}`); return { artifact, manifestPath, manifest }; } if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const args = process.argv.slice(2); if (!args.includes('--no-bundle')) await prepareReleaseVersion(); runTauriBuild(args); if (!args.includes('--no-bundle')) generateUpdateManifest(); }