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'; import { defaultEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); 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 nextPatchVersion(localVersion, remoteVersion) { const local = parseVersion(localVersion, '本地版本'); const remote = remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本'); const base = remote && compareVersions(remote, local) > 0 ? remote : local; const [major, minor, patch] = base.split('.').map(Number); if (patch === Number.MAX_SAFE_INTEGER) { throw new Error(`版本号 patch 已达到上限:${base}`); } return `${major}.${minor}.${patch + 1}`; } 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`); } export async function prepareReleaseVersion() { const localVersion = parseVersion(readPackageJson().version, '本地版本'); const remoteVersion = await readRemoteVersion(); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') : nextPatchVersion(localVersion, remoteVersion); 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', ), ); console.log( requestedVersion ? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})` : `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, ); return nextVersion; } export function buildTauriBuildArguments( args = [], target = releaseTarget, platform = process.platform, ) { const noBundle = args.includes('--no-bundle'); const targetIndex = args.indexOf('--target'); const explicitTarget = targetIndex >= 0 ? args[targetIndex + 1] : args .find((value) => value.startsWith('--target=')) ?.slice('--target='.length); const targetArgs = noBundle || explicitTarget ? [] : ['--target', target]; const features = defaultEditorFeatures( explicitTarget || (noBundle ? platform : target), ); return [ 'build', ...withDefaultCargoFeatures([...targetArgs, ...args], features), ]; } export function runTauriBuild(args = []) { const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const result = spawnSync( npmCommand, [ '--prefix', '../..', 'exec', 'tauri', '--', ...buildTauriBuildArguments(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(); }