diff --git a/.gitignore b/.gitignore index 5529773cd..6c7fc1d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,8 @@ temp*build*/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json /apps/mobile-shell/.expo/ diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs new file mode 100755 index 000000000..7c44d30d4 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -0,0 +1,870 @@ +#!/usr/bin/env node +// AGC 随包资源准备步骤:在 `tauri dev` / `tauri build` 之前把随包资源一次性 staging 到位。 +// +// 合同见 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md §4.3: +// 只替换本工具产物、写入临时目录后原子替换、命中缓存不重写任何文件、失败即关闭并给出可执行提示。 +// 布局与组件白名单来自唯一声明 src-tauri/build_support/package-layout.json(Node 与 Rust 共用)。 +// +// 本里程碑(M1)覆盖两条纯复制路径:随包 Codex CLI 与插件工作区。 +// 编辑器分支产物(Unity/Godot/Cocos)仍由构建脚本生成,归位在 M3。 +// +// 用法(在 apps/ai-game-creator-shell 下): +// node scripts/prepare-bundled-resources.mjs [--target ] [--dry-run] + +import { createHash, randomBytes } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + createReadStream, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defaultEditorFeatures } from './cargo-features.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..'); +/** 应用 src-tauri 目录(随包资源的落点)。 */ +export const SRC_TAURI_DIR = path.join(APP_ROOT, 'src-tauri'); +/** 唯一的随包资源声明文件。 */ +export const DECLARATION_PATH = path.join( + SRC_TAURI_DIR, + 'build_support/package-layout.json', +); +/** 缓存记录(放在 gitignored 的 target 下,丢失只多一次哈希)。 */ +export const RECORD_PATH = path.join( + SRC_TAURI_DIR, + 'target/agc-resource-staging.json', +); + +const HOST_TRIPLES = new Map([ + ['darwin:arm64', 'aarch64-apple-darwin'], + ['darwin:x64', 'x86_64-apple-darwin'], + ['win32:x64', 'x86_64-pc-windows-msvc'], +]); + +class PrepareError extends Error {} + +function fail(message) { + throw new PrepareError(message); +} + +export function resolveHostTarget( + platform = process.platform, + arch = process.arch, +) { + const triple = HOST_TRIPLES.get(`${platform}:${arch}`); + if (!triple) { + fail( + `不支持的目标平台:${platform}/${arch}(随包资源声明只覆盖 ${[...HOST_TRIPLES.values()].join('、')})`, + ); + } + return triple; +} + +export function readDeclaration(declarationPath = DECLARATION_PATH) { + const declaration = JSON.parse(readFileSync(declarationPath, 'utf8')); + if (declaration.schema !== 'agc-package-layout.v1') { + fail(`不支持的随包资源声明 schema:${String(declaration.schema)}`); + } + return declaration; +} + +function codexLayout(declaration, target) { + const layout = declaration.codex.targets.find( + (entry) => entry.target === target, + ); + if (!layout) { + fail(`随包资源声明不含目标 ${target} 的 Codex 布局`); + } + return layout; +} + +/// staging 的替换单位:属于整目录分组时替换整个分组目录(macOS 双架构共用),否则替换自身目录。 +export function stagingUnit(declaration, target) { + const layout = codexLayout(declaration, target); + const group = declaration.codex.universalGroups.find((entry) => + entry.targets.includes(target), + ); + const directory = group ? group.directory : layout.directory; + return { + directory, + targets: (group ? group.targets : [target]).map((member) => ({ + target: member, + layout: codexLayout(declaration, member), + })), + }; +} + +function sha256File(file) { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + createReadStream(file) + .on('data', (chunk) => hash.update(chunk)) + .on('end', () => resolve(hash.digest('hex'))) + .on('error', reject); + }); +} + +function sha256Text(text) { + return createHash('sha256').update(text).digest('hex'); +} + +function copyFilePreservingMode(source, destination) { + const info = statSync(source); + if (!info.isFile()) { + fail(`随包资源来源不是普通文件:${source}`); + } + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); + chmodSync(destination, info.mode & 0o777); +} + +export function readRecord(recordPath = RECORD_PATH) { + if (!existsSync(recordPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(recordPath, 'utf8')); + } catch { + return {}; + } +} + +function writeRecord(record, recordPath) { + mkdirSync(path.dirname(recordPath), { recursive: true }); + writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`); +} + +/// 上游平台包目录:按声明顺序取第一个「声明白名单文件齐全」的候选。 +export function findCodexSource(declaration, target, roots) { + const layout = codexLayout(declaration, target); + const candidates = []; + for (const rootName of declaration.codex.sourceRoots) { + const root = roots[rootName]; + if (!root) { + fail(`未知的声明 sourceRoots 取值:${rootName}`); + } + for (const relative of declaration.codex.sourceRelativePaths) { + candidates.push( + path.join( + root, + relative + .replaceAll('', layout.platform) + .replaceAll('', target), + ), + ); + } + } + const source = candidates.find((candidate) => + layout.files.every((entry) => existsSync(path.join(candidate, entry))), + ); + if (!source) { + fail( + `内置 Codex CLI 上游包缺失;请先在仓库根目录执行 npm ci(已检查:${candidates.join(';')})`, + ); + } + return source; +} + +/// 缓存 key 的锁定信息:上游 package-lock 的 resolved + integrity。 +export function readLockedUpstream( + declaration, + target, + lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), +) { + const layout = codexLayout(declaration, target); + const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8')); + const entry = + lockfile.packages?.[`node_modules/@openai/codex-${layout.platform}`]; + if (!entry?.resolved || !entry?.integrity) { + fail( + `package-lock.json 缺少 @openai/codex-${layout.platform} 的 resolved/integrity;请先在仓库根目录执行 npm ci`, + ); + } + return { resolved: entry.resolved, integrity: entry.integrity }; +} + +function serializeManifest(declaration, layout, hashes) { + const files = {}; + for (const relative of [...layout.files].sort()) { + files[relative] = hashes.get(relative); + } + return `${JSON.stringify( + { + files, + platform: layout.platform, + schemaVersion: declaration.codex.manifestSchema, + version: `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`, + }, + null, + 2, + )}\n`; +} + +function noticeSourceFor(declaration, target) { + const notice = declaration.codex.noticeSources.find((entry) => + entry.targets.includes(target), + ); + if (!notice) { + fail(`随包资源声明缺少目标 ${target} 的第三方声明来源`); + } + return notice; +} + +function unitPathOf(destinationRoot, declaration, unit) { + return path.join( + destinationRoot, + declaration.codex.resourceDirectory, + unit.directory, + ); +} + +function targetPathWithin(unitPath, declaration, unit, target) { + const layout = codexLayout(declaration, target); + const relative = path.relative(unit.directory, layout.directory); + return relative ? path.join(unitPath, relative) : unitPath; +} + +/// 该替换单位允许出现的顶层条目:整目录分组下是各架构子目录,单目标下是组件首段路径 + 第三方声明 + 清单。 +function allowedUnitEntries(declaration, unit) { + const allowed = new Set(); + for (const member of unit.targets) { + const relativeDirectory = path.relative( + unit.directory, + member.layout.directory, + ); + if (relativeDirectory) { + allowed.add(relativeDirectory.split(path.sep)[0]); + continue; + } + for (const relative of member.layout.files) { + allowed.add(relative.split('/')[0]); + } + allowed.add(declaration.codex.noticeFileName); + allowed.add(declaration.codex.manifestFileName); + } + return allowed; +} + +function assertOwnedUnit(unitPath, declaration, unit) { + if (!existsSync(unitPath)) { + return; + } + const allowed = allowedUnitEntries(declaration, unit); + for (const entry of readdirSync(unitPath)) { + const entryPath = path.join(unitPath, entry); + if (!allowed.has(entry)) { + fail( + `随包资源目录被非本工具内容占用:${entryPath};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// 期望产物:每个目标的组件摘要与清单文本。 +async function desiredCodex(declaration, unit, roots) { + const desired = new Map(); + for (const member of unit.targets) { + const source = findCodexSource(declaration, member.target, roots); + const hashes = new Map(); + for (const relative of member.layout.files) { + hashes.set(relative, await sha256File(path.join(source, relative))); + } + desired.set(member.target, { + source, + files: new Map( + [...hashes].map(([relative, digest]) => [ + relative, + { sha256: digest, size: statSync(path.join(source, relative)).size }, + ]), + ), + manifest: serializeManifest(declaration, member.layout, hashes), + }); + } + return desired; +} + +async function unitMatchesExisting(unitPath, declaration, unit, desired) { + if (!existsSync(unitPath)) { + return false; + } + const allowed = allowedUnitEntries(declaration, unit); + for (const entry of readdirSync(unitPath)) { + if (!allowed.has(entry)) { + return false; + } + } + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + const manifestPath = path.join( + targetDir, + declaration.codex.manifestFileName, + ); + if ( + !existsSync(manifestPath) || + readFileSync(manifestPath, 'utf8') !== expected.manifest + ) { + return false; + } + for (const [relative, file] of expected.files) { + const filePath = path.join(targetDir, relative); + if (!existsSync(filePath)) { + return false; + } + const info = statSync(filePath); + if ( + info.size !== file.size || + (await sha256File(filePath)) !== file.sha256 + ) { + return false; + } + if (relative === member.layout.executable && (info.mode & 0o111) === 0) { + return false; + } + } + } + return true; +} + +function recordEntryFor(unitPath, declaration, unit, desired) { + const manifests = {}; + const sizes = {}; + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + manifests[member.target] = sha256Text(expected.manifest); + sizes[member.layout.directory] = Object.fromEntries( + [...expected.files].map(([relative, file]) => [relative, file.size]), + ); + void targetDir; + } + return { manifests, sizes }; +} + +async function unitRecordMatches(unitPath, declaration, unit, recorded) { + if (!recorded) { + return false; + } + for (const member of unit.targets) { + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + const manifestPath = path.join( + targetDir, + declaration.codex.manifestFileName, + ); + if (!existsSync(manifestPath)) { + return false; + } + if ( + sha256Text(readFileSync(manifestPath, 'utf8')) !== + recorded.manifests?.[member.target] + ) { + return false; + } + const expectedSizes = recorded.sizes?.[member.layout.directory]; + if (!expectedSizes) { + return false; + } + for (const relative of member.layout.files) { + const file = path.join(targetDir, relative); + if ( + !existsSync(file) || + statSync(file).size !== expectedSizes[relative] + ) { + return false; + } + } + } + return true; +} + +function buildUnitInto( + stagingPath, + declaration, + unit, + desired, + unitPath, + destinationRoot, +) { + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + stagingPath, + declaration, + unit, + member.target, + ); + for (const relative of member.layout.files) { + copyFilePreservingMode( + path.join(expected.source, relative), + path.join(targetDir, relative), + ); + } + const notice = noticeSourceFor(declaration, member.target); + const noticeDestination = path.join( + targetDir, + declaration.codex.noticeFileName, + ); + const noticeSourcePath = notice.preserve + ? path.join( + targetPathWithin(unitPath, declaration, unit, member.target), + declaration.codex.noticeFileName, + ) + : path.join(destinationRoot, notice.source); + if (!existsSync(noticeSourcePath)) { + fail(`第三方声明来源缺失:${noticeSourcePath}`); + } + copyFilePreservingMode(noticeSourcePath, noticeDestination); + writeFileSync( + path.join(targetDir, declaration.codex.manifestFileName), + expected.manifest, + ); + } +} + +function stageAtomically(unitPath, builder) { + const stagingPath = `${unitPath}-staging-${process.pid}-${randomBytes(4).toString('hex')}`; + rmSync(stagingPath, { recursive: true, force: true }); + mkdirSync(stagingPath, { recursive: true }); + try { + builder(stagingPath); + } catch (error) { + rmSync(stagingPath, { recursive: true, force: true }); + throw error; + } + rmSync(unitPath, { recursive: true, force: true }); + renameSync(stagingPath, unitPath); +} + +async function prepareCodex({ + declaration, + target, + destinationRoot, + roots, + lockfilePath, + record, + dryRun, +}) { + const unit = stagingUnit(declaration, target); + const label = `codex ${unit.targets.map((member) => member.target).join('+')}`; + const unitPath = unitPathOf(destinationRoot, declaration, unit); + const key = [ + `layoutVersion=${declaration.layoutVersion}`, + ...unit.targets.map( + (member) => + `${member.target}:${readLockedUpstream(declaration, member.target, lockfilePath).integrity}`, + ), + ].join('|'); + const recorded = record.codex?.[unit.directory]; + if ( + recorded?.key === key && + (await unitRecordMatches(unitPath, declaration, unit, recorded)) + ) { + return { summary: `${label} 命中缓存(未写入)`, record: undefined }; + } + + const desired = await desiredCodex(declaration, unit, roots); + const entry = { + key, + ...recordEntryFor(unitPath, declaration, unit, desired), + }; + if (await unitMatchesExisting(unitPath, declaration, unit, desired)) { + return { summary: `${label} 命中缓存(内容一致,未写入)`, record: entry }; + } + if (dryRun) { + return { + summary: `${label} 需要重新生成(dry-run 未写入)`, + record: undefined, + }; + } + assertOwnedUnit(unitPath, declaration, unit); + stageAtomically(unitPath, (staging) => + buildUnitInto( + staging, + declaration, + unit, + desired, + unitPath, + destinationRoot, + ), + ); + return { + summary: `${label} 重新生成(${unit.targets.length} 个架构,写入 ${declaration.codex.resourceDirectory}/${unit.directory})`, + record: entry, + }; +} + +export function pluginDirectories(declaration, repoRoot) { + const root = path.join(repoRoot, declaration.plugins.sourceDirectory); + if (!existsSync(root)) { + fail(`插件工作区缺失:${root}`); + } + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => ({ name: entry.name, root: path.join(root, entry.name) })) + .filter((plugin) => + existsSync(path.join(plugin.root, declaration.plugins.manifestFileName)), + ); +} + +function subdirectoryEnabled(subdirectory, target, features) { + const matchesContains = + !subdirectory.targetContains?.length || + subdirectory.targetContains.some((needle) => target.includes(needle)); + const matchesTarget = + !subdirectory.targets?.length || subdirectory.targets.includes(target); + const matchesFeatures = (subdirectory.features ?? []).every((name) => + features.has(name), + ); + return matchesContains && matchesTarget && matchesFeatures; +} + +function skipDirectory(declaration, name) { + return ( + declaration.plugins.skipDirectoryNames.includes(name) || + declaration.plugins.skipDirectoryNamePrefixes.some((prefix) => + name.startsWith(prefix), + ) + ); +} + +function skipFileName(declaration, name) { + return ( + declaration.plugins.skipFileNamePrefixes.some((prefix) => + name.startsWith(prefix), + ) || + declaration.plugins.skipFileNameFragments.some((fragment) => + name.includes(fragment), + ) + ); +} + +function walkFiles(declaration, root) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + if (!existsSync(directory)) { + continue; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail(`插件资源不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(declaration, entry.name)) { + stack.push([relative, entryPath]); + } + } else if (entry.isFile() && !skipFileName(declaration, entry.name)) { + files.push(relative); + } + } + } + return files.sort(); +} + +function copyPluginSubdirectory(declaration, source, destination) { + if (!existsSync(source)) { + return; + } + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source, { withFileTypes: true })) { + const entrySource = path.join(source, entry.name); + const entryDestination = path.join(destination, entry.name); + if (entry.isSymbolicLink()) { + fail(`插件资源不允许符号链接:${entrySource}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(declaration, entry.name)) { + copyPluginSubdirectory(declaration, entrySource, entryDestination); + } + } else if (entry.isFile() && !skipFileName(declaration, entry.name)) { + copyFilePreservingMode(entrySource, entryDestination); + } + } +} + +function pluginSourceFingerprint(declaration, plugins, target, features) { + const lines = []; + for (const plugin of plugins) { + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + const root = path.join(plugin.root, subdirectory.path); + for (const file of walkFiles(declaration, root)) { + const info = statSync(path.join(root, file)); + lines.push( + `${plugin.name}/${subdirectory.path}/${file}:${info.size}:${Math.round(info.mtimeMs)}`, + ); + } + } + const manifest = path.join( + plugin.root, + declaration.plugins.manifestFileName, + ); + const info = statSync(manifest); + lines.push( + `${plugin.name}/plugin.json:${info.size}:${Math.round(info.mtimeMs)}`, + ); + } + return sha256Text(lines.join('\n')); +} + +async function pluginTreeMatches( + declaration, + plugins, + target, + features, + destination, +) { + if (!existsSync(destination)) { + return false; + } + for (const plugin of plugins) { + const pluginDestination = path.join(destination, plugin.name); + if ( + !existsSync( + path.join(pluginDestination, declaration.plugins.manifestFileName), + ) + ) { + return false; + } + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + const sourceRoot = path.join(plugin.root, subdirectory.path); + for (const relative of walkFiles(declaration, sourceRoot)) { + const source = path.join(sourceRoot, relative); + const staged = path.join( + pluginDestination, + subdirectory.path, + relative, + ); + if (!existsSync(staged)) { + return false; + } + if (statSync(source).size !== statSync(staged).size) { + return false; + } + if ((await sha256File(source)) !== (await sha256File(staged))) { + return false; + } + } + } + } + return true; +} + +function assertOwnedPluginRoot(destination, plugins) { + if (!existsSync(destination)) { + return; + } + const known = new Set(plugins.map((plugin) => plugin.name)); + for (const entry of readdirSync(destination)) { + if (!known.has(entry)) { + fail( + `插件随包目录被非本工具内容占用:${path.join(destination, entry)};请人工确认后删除该目录再重试`, + ); + } + } +} + +async function preparePlugins({ + declaration, + target, + destinationRoot, + features, + plugins, + record, + dryRun, +}) { + const destination = path.join( + destinationRoot, + declaration.plugins.destinationDirectory, + ); + const fingerprint = pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ); + if ( + record.plugins?.fingerprint === fingerprint && + (await pluginTreeMatches( + declaration, + plugins, + target, + features, + destination, + )) + ) { + return { + summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + if (dryRun) { + return { + summary: `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + assertOwnedPluginRoot(destination, plugins); + stageAtomically(destination, (staging) => { + for (const plugin of plugins) { + const pluginDestination = path.join(staging, plugin.name); + copyFilePreservingMode( + path.join(plugin.root, declaration.plugins.manifestFileName), + path.join(pluginDestination, declaration.plugins.manifestFileName), + ); + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(pluginDestination, subdirectory.path), + ); + } + } + }); + return { + summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, + record: { fingerprint }, + }; +} + +/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 +export async function prepareBundledResources({ + target = resolveHostTarget(), + destinationRoot = SRC_TAURI_DIR, + declarationPath = DECLARATION_PATH, + features = new Set(defaultEditorFeatures(target)), + recordPath = RECORD_PATH, + lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), + dryRun = false, + repoRoot = REPO_ROOT, + appRoot = path.dirname(destinationRoot), +} = {}) { + const declaration = readDeclaration(declarationPath); + const record = readRecord(recordPath); + const summaries = []; + let changed = false; + const codex = await prepareCodex({ + declaration, + target, + destinationRoot, + roots: { app: appRoot, repo: repoRoot }, + lockfilePath, + record, + dryRun, + }); + summaries.push(codex.summary); + if (codex.record) { + record.codex = { + ...record.codex, + [stagingUnit(declaration, target).directory]: codex.record, + }; + changed = true; + } + if ( + declaration.plugins.targetContainsAny.some((needle) => + target.includes(needle), + ) + ) { + const plugins = await preparePlugins({ + declaration, + target, + destinationRoot, + features, + plugins: pluginDirectories(declaration, repoRoot), + record, + dryRun, + }); + summaries.push(plugins.summary); + if (plugins.record) { + record.plugins = plugins.record; + changed = true; + } + } else { + summaries.push(`plugins 跳过(目标 ${target} 不适用)`); + } + if (changed && !dryRun) { + writeRecord(record, recordPath); + } + return summaries; +} + +function parseArguments(argv) { + const args = { target: undefined, destinationRoot: undefined, dryRun: false }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === '--target') { + args.target = argv[index + 1]; + index += 1; + } else if (value === '--destination') { + args.destinationRoot = argv[index + 1]; + index += 1; + } else if (value === '--dry-run') { + args.dryRun = true; + } else { + fail(`未知参数:${value}`); + } + } + return args; +} + +async function main(argv) { + const args = parseArguments(argv); + const summaries = await prepareBundledResources({ + target: args.target ?? resolveHostTarget(), + destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, + dryRun: args.dryRun, + }); + for (const summary of summaries) { + console.log(`[agc-resources] ${summary}`); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(process.argv.slice(2)).catch((error) => { + if (error instanceof PrepareError) { + console.error(`[agc-resources] ${error.message}`); + process.exit(1); + } + throw error; + }); +} diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs new file mode 100644 index 000000000..4876a3836 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -0,0 +1,401 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + DECLARATION_PATH, + findCodexSource, + pluginDirectories, + prepareBundledResources, + readDeclaration, + resolveHostTarget, + stagingUnit, +} from './prepare-bundled-resources.mjs'; + +const WINDOWS_TARGET = 'x86_64-pc-windows-msvc'; +const MAC_TARGET = 'aarch64-apple-darwin'; + +function sha256File(file) { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。 +function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-')); + const appRoot = path.join(root, 'app'); + const repoRoot = path.join(root, 'repo'); + const destinationRoot = path.join(appRoot, 'src-tauri'); + const declaration = readDeclaration(DECLARATION_PATH); + const lockfile = { packages: {} }; + + for (const target of targets) { + const layout = declaration.codex.targets.find( + (entry) => entry.target === target, + ); + assert.ok(layout, `声明缺少目标 ${target}`); + const vendor = path.join( + appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${target}`, + ); + for (const relative of layout.files) { + const file = path.join(vendor, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `component ${target} ${relative}\n`); + if (relative === layout.executable) { + fs.chmodSync(file, 0o755); + } + } + lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = { + resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`, + integrity: `sha512-${target}`, + }; + } + + fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), { + recursive: true, + }); + for (const entry of declaration.codex.noticeSources) { + if (entry.preserve) { + continue; + } + const file = path.join(destinationRoot, entry.source); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'mac codex notice\n'); + } + if (targets.includes(WINDOWS_TARGET)) { + const tracked = path.join( + destinationRoot, + 'resources/codex/win-x64/NOTICE.md', + ); + fs.mkdirSync(path.dirname(tracked), { recursive: true }); + fs.writeFileSync(tracked, 'windows codex notice\n'); + } + + if (plugins) { + const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor'); + fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, 'plugin.json'), + '{"name":"agc-demo-editor"}\n', + ); + fs.writeFileSync( + path.join(pluginRoot, 'src/entry.mjs'), + 'export const entry = 1;\n', + ); + fs.writeFileSync( + path.join(pluginRoot, 'panels/panel.html'), + '\n', + ); + fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n'); + fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n'); + fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); + fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + } + + const lockfilePath = path.join(root, 'package-lock.json'); + fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2)); + return { + root, + appRoot, + repoRoot, + destinationRoot, + lockfilePath, + recordPath: path.join(root, 'record.json'), + declaration, + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} + +function snapshot(directory) { + const entries = []; + const stack = [['', directory]]; + while (stack.length > 0) { + const [prefix, current] = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push([relative, full]); + } else { + const info = fs.statSync(full); + entries.push({ + relative, + size: info.size, + mtimeMs: info.mtimeMs, + mode: info.mode & 0o777, + sha256: sha256File(full), + }); + } + } + } + return entries.sort((left, right) => + left.relative.localeCompare(right.relative), + ); +} + +function prepare(fixture, overrides = {}) { + return prepareBundledResources({ + target: WINDOWS_TARGET, + destinationRoot: fixture.destinationRoot, + declarationPath: DECLARATION_PATH, + recordPath: fixture.recordPath, + lockfilePath: fixture.lockfilePath, + repoRoot: fixture.repoRoot, + appRoot: fixture.appRoot, + ...overrides, + }); +} + +test('stages declared codex components with manifest and preserved notice', async () => { + const fixture = buildFixture(); + try { + const summaries = await prepare(fixture); + assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/); + + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + layout.directory, + ); + for (const relative of layout.files) { + assert.ok( + fs.existsSync(path.join(unit, relative)), + `缺少组件 ${relative}`, + ); + } + assert.equal( + fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'), + 'windows codex notice\n', + '受版本控制的第三方声明必须原地保留', + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'), + ); + assert.deepEqual(Object.keys(manifest), [ + 'files', + 'platform', + 'schemaVersion', + 'version', + ]); + assert.equal(manifest.platform, layout.platform); + assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema); + assert.equal( + manifest.version, + `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`, + ); + assert.deepEqual( + Object.keys(manifest.files).sort(), + [...layout.files].sort(), + '清单文件集合必须等于组件白名单', + ); + for (const relative of layout.files) { + assert.equal( + manifest.files[relative], + sha256File(path.join(unit, relative)), + ); + } + } finally { + fixture.cleanup(); + } +}); + +test('second run is a no-op: identical content and timestamps', async () => { + const fixture = buildFixture(); + try { + await prepare(fixture); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + 'win-x64', + ); + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + const before = { codex: snapshot(unit), plugins: snapshot(plugins) }; + const summaries = await prepare(fixture); + assert.match(summaries[0], /命中缓存/); + assert.match(summaries[1], /命中缓存/); + assert.deepEqual( + snapshot(unit), + before.codex, + 'codex 产物内容与时间戳必须不变', + ); + assert.deepEqual( + snapshot(plugins), + before.plugins, + '插件产物内容与时间戳必须不变', + ); + } finally { + fixture.cleanup(); + } +}); + +test('stages the macOS universal group with both architectures', async () => { + const fixture = buildFixture({ + targets: [MAC_TARGET, 'x86_64-apple-darwin'], + }); + try { + const summaries = await prepare(fixture, { target: MAC_TARGET }); + assert.match(summaries[0], /mac-native/); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex/mac-native', + ); + for (const directory of ['darwin-arm64', 'darwin-x64']) { + for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) { + assert.ok( + fs.existsSync(path.join(unit, directory, file)), + `缺少 ${directory}/${file}`, + ); + } + assert.equal( + fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'), + 'mac codex notice\n', + ); + } + assert.deepEqual(fs.readdirSync(unit).sort(), [ + 'darwin-arm64', + 'darwin-x64', + ]); + } finally { + fixture.cleanup(); + } +}); + +test('copies only whitelisted plugin subdirectories', async () => { + const fixture = buildFixture(); + try { + await prepare(fixture); + const staged = path.join( + fixture.destinationRoot, + 'resources/plugins/agc-demo-editor', + ); + assert.ok(fs.existsSync(path.join(staged, 'plugin.json'))); + assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs'))); + assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html'))); + assert.ok( + !fs.existsSync(path.join(staged, 'panels/panel.test.mjs')), + '测试文件不随包', + ); + assert.ok( + !fs.existsSync(path.join(staged, 'target')), + '构建产物目录不随包', + ); + assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包'); + assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包'); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the upstream package is missing', async () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.appRoot, 'node_modules'), { + recursive: true, + force: true, + }); + await assert.rejects(prepare(fixture), /npm ci/); + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + 'resources/codex/win-x64/manifest.json', + ), + ), + '失败时不得留下半成品清单', + ); + assert.ok( + !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')), + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed for unsupported targets', async () => { + const fixture = buildFixture(); + try { + await assert.rejects( + prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), + /声明不含目标/, + ); + assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the destination is owned by something else', async () => { + const fixture = buildFixture(); + try { + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); + await assert.rejects(prepare(fixture), /被非本工具内容占用/); + + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + fs.rmSync(path.join(unit, 'foreign.bin'), { force: true }); + fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), { + recursive: true, + }); + await assert.rejects(prepare(fixture), /插件随包目录被非本工具内容占用/); + } finally { + fixture.cleanup(); + } +}); + +test('dry run writes nothing', async () => { + const fixture = buildFixture(); + try { + const summaries = await prepare(fixture, { dryRun: true }); + assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + assert.deepEqual( + fs.readdirSync(unit), + ['NOTICE.md'], + 'dry-run 不得写入任何组件或清单', + ); + assert.ok( + !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')), + ); + assert.ok(!fs.existsSync(fixture.recordPath)); + } finally { + fixture.cleanup(); + } +}); + +test('declaration drives source lookup and staging units', () => { + const declaration = readDeclaration(DECLARATION_PATH); + const windows = stagingUnit(declaration, WINDOWS_TARGET); + assert.equal(windows.directory, 'win-x64'); + assert.deepEqual( + windows.targets.map((member) => member.target), + [WINDOWS_TARGET], + ); + const mac = stagingUnit(declaration, MAC_TARGET); + assert.equal(mac.directory, 'mac-native'); + assert.deepEqual( + mac.targets.map((member) => member.target), + ['aarch64-apple-darwin', 'x86_64-apple-darwin'], + ); + + const fixture = buildFixture(); + try { + const source = findCodexSource(declaration, WINDOWS_TARGET, { + app: fixture.appRoot, + repo: fixture.repoRoot, + }); + assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1); + } finally { + fixture.cleanup(); + } +});